mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
feat(api): introduce select, file and file list form input types to Human Input node (#36322)
Co-authored-by: JzoNg <jzongcode@gmail.com> Co-authored-by: GPT 5.4 <codex@openai.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: -LAN- <laipz8200@outlook.com>
This commit is contained in:
co-authored by
JzoNg
GPT 5.4
autofix-ci[bot]
-LAN-
parent
44725dde74
commit
3c98f96ae8
@@ -26,15 +26,21 @@ export const SelectTrigger = ({
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { children?: ReactNode }) => (
|
||||
<button type="button" {...props}>
|
||||
<button type="button" role="combobox" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
export const SelectValue = ({ placeholder }: { placeholder?: ReactNode }) => <>{placeholder}</>
|
||||
|
||||
export const SelectContent = ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="select-content">{children}</div>
|
||||
export const SelectContent = ({
|
||||
children,
|
||||
popupClassName,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
popupClassName?: string
|
||||
}) => (
|
||||
<div data-side="bottom" data-testid="select-content" className={popupClassName}>{children}</div>
|
||||
)
|
||||
|
||||
export const SelectItem = ({
|
||||
|
||||
@@ -11,6 +11,7 @@ const mockUploadRemoteFileInfo = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({}),
|
||||
usePathname: () => '/',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import type { FormData } from '../form'
|
||||
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
|
||||
import { act, render, renderHook, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import FormContent from '../form'
|
||||
import { useFormSubmit } from '../use-form-submit'
|
||||
|
||||
const mockSubmitForm = vi.hoisted(() => vi.fn())
|
||||
const mockUseGetHumanInputForm = vi.hoisted(() => vi.fn())
|
||||
const mockContentItemState = vi.hoisted(() => ({
|
||||
staleAttachmentInputChange: undefined as ((name: string, value: unknown) => void) | undefined,
|
||||
uploadedFile: {
|
||||
id: 'file-1',
|
||||
name: 'review.pdf',
|
||||
size: 128,
|
||||
type: 'document',
|
||||
progress: 100,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
uploadedId: 'upload-file-1',
|
||||
},
|
||||
uploadingFile: {
|
||||
id: 'file-1',
|
||||
name: 'review.pdf',
|
||||
size: 128,
|
||||
type: 'document',
|
||||
progress: 50,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
uploadedId: undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({ token: 'token-123' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-share', () => ({
|
||||
useGetHumanInputForm: (...args: unknown[]) => mockUseGetHumanInputForm(...args),
|
||||
useSubmitHumanInputForm: () => ({
|
||||
mutate: mockSubmitForm,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({
|
||||
__esModule: true,
|
||||
default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => {
|
||||
const isSummaryField = content.includes('summary')
|
||||
const isAttachmentField = content.includes('attachments')
|
||||
|
||||
if (isAttachmentField && !mockContentItemState.staleAttachmentInputChange)
|
||||
mockContentItemState.staleAttachmentInputChange = onInputChange
|
||||
|
||||
return (
|
||||
<div data-testid="share-form-content-item">
|
||||
{content}
|
||||
{isSummaryField && (
|
||||
<>
|
||||
<button type="button" onClick={() => onInputChange('summary', '')}>
|
||||
share-clear-summary
|
||||
</button>
|
||||
<button type="button" onClick={() => onInputChange('summary', 'updated summary')}>
|
||||
share-update-summary
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isAttachmentField && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mockContentItemState.staleAttachmentInputChange?.('attachments', [mockContentItemState.uploadingFile])}
|
||||
>
|
||||
share-uploading-attachments
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mockContentItemState.staleAttachmentInputChange?.('attachments', [mockContentItemState.uploadedFile])}
|
||||
>
|
||||
share-update-attachments
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/expiration-time', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>expiration-time</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/loading', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/logo/dify-logo', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>dify-logo</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>app-icon</div>,
|
||||
}))
|
||||
|
||||
describe('Human input share form', () => {
|
||||
const formData: FormData = {
|
||||
site: {
|
||||
site: {
|
||||
title: 'Review App',
|
||||
icon_type: 'emoji',
|
||||
icon: 'R',
|
||||
icon_background: '#fff',
|
||||
icon_url: '',
|
||||
default_language: 'en-US',
|
||||
description: '',
|
||||
copyright: '',
|
||||
privacy_policy: '',
|
||||
custom_disclaimer: '',
|
||||
prompt_public: false,
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
},
|
||||
form_content: '{{#$output.summary#}} {{#$output.attachments#}}',
|
||||
inputs: [
|
||||
{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'summary',
|
||||
default: {
|
||||
type: 'constant',
|
||||
value: 'initial summary',
|
||||
selector: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'attachments',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
number_limits: 3,
|
||||
},
|
||||
],
|
||||
resolved_default_values: {},
|
||||
user_actions: [
|
||||
{
|
||||
id: 'approve',
|
||||
title: 'Approve',
|
||||
button_style: UserActionButtonType.Primary,
|
||||
},
|
||||
],
|
||||
expiration_time: 60,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockContentItemState.staleAttachmentInputChange = undefined
|
||||
mockUseGetHumanInputForm.mockReturnValue({
|
||||
data: formData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the loading state while the form is being fetched', () => {
|
||||
mockUseGetHumanInputForm.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<FormContent />)
|
||||
|
||||
expect(screen.getByText('loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render status cards for terminal fetch states', () => {
|
||||
const cases = [
|
||||
{
|
||||
error: { code: 'human_input_form_expired' },
|
||||
title: 'share.humanInput.sorry',
|
||||
subtitle: 'share.humanInput.expired',
|
||||
submissionID: true,
|
||||
},
|
||||
{
|
||||
error: { code: 'human_input_form_submitted' },
|
||||
title: 'share.humanInput.sorry',
|
||||
subtitle: 'share.humanInput.completed',
|
||||
submissionID: true,
|
||||
},
|
||||
{
|
||||
error: { code: 'web_form_rate_limit_exceeded' },
|
||||
title: 'share.humanInput.rateLimitExceeded',
|
||||
subtitle: undefined,
|
||||
submissionID: false,
|
||||
},
|
||||
{
|
||||
error: null,
|
||||
title: 'share.humanInput.formNotFound',
|
||||
subtitle: undefined,
|
||||
submissionID: false,
|
||||
},
|
||||
]
|
||||
|
||||
cases.forEach(({ error, title, subtitle, submissionID }) => {
|
||||
mockUseGetHumanInputForm.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error,
|
||||
})
|
||||
const { unmount } = render(<FormContent />)
|
||||
|
||||
expect(screen.getByText(title)).toBeInTheDocument()
|
||||
if (subtitle)
|
||||
expect(screen.getByText(subtitle)).toBeInTheDocument()
|
||||
else
|
||||
expect(screen.queryByText('share.humanInput.expired')).not.toBeInTheDocument()
|
||||
|
||||
if (submissionID)
|
||||
expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument()
|
||||
else
|
||||
expect(screen.queryByText(/share\.humanInput\.submissionID/)).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
|
||||
expect(screen.getByText('dify-logo')).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('submits typed human input values through the share form mutation', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<FormContent />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'share-update-summary' }))
|
||||
await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
|
||||
expect(mockSubmitForm).toHaveBeenCalledWith({
|
||||
token: 'token-123',
|
||||
data: {
|
||||
action: 'approve',
|
||||
inputs: {
|
||||
summary: 'updated summary',
|
||||
attachments: [{
|
||||
type: 'document',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: '',
|
||||
upload_file_id: 'upload-file-1',
|
||||
}],
|
||||
},
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show the success status after the submit mutation succeeds', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<FormContent />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'share-update-summary' }))
|
||||
await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
|
||||
const options = mockSubmitForm.mock.calls[0]![1] as { onSuccess: () => void }
|
||||
act(() => {
|
||||
options.onSuccess()
|
||||
})
|
||||
|
||||
expect(screen.getByText('share.humanInput.thanks')).toBeInTheDocument()
|
||||
expect(screen.getByText('share.humanInput.recorded')).toBeInTheDocument()
|
||||
expect(screen.getByText('share.humanInput.submissionID:{"id":"token-123"}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should submit empty inputs when there are no form values to process', () => {
|
||||
const { result } = renderHook(() => useFormSubmit('token-empty'))
|
||||
|
||||
act(() => {
|
||||
result.current.submit(
|
||||
undefined as unknown as Record<string, HumanInputFieldValue>,
|
||||
'reject',
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockSubmitForm).toHaveBeenCalledWith({
|
||||
token: 'token-empty',
|
||||
data: {
|
||||
action: 'reject',
|
||||
inputs: {},
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should keep initialized defaults when file upload uses the initial change callback', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<FormContent />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
|
||||
expect(mockSubmitForm).toHaveBeenCalledWith({
|
||||
token: 'token-123',
|
||||
data: {
|
||||
action: 'approve',
|
||||
inputs: {
|
||||
summary: 'initial summary',
|
||||
attachments: [{
|
||||
type: 'document',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: '',
|
||||
upload_file_id: 'upload-file-1',
|
||||
}],
|
||||
},
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should disable action buttons until every required field is filled and files are uploaded', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<FormContent />)
|
||||
|
||||
const approveButton = screen.getByRole('button', { name: 'Approve' })
|
||||
expect(approveButton).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'share-uploading-attachments' }))
|
||||
expect(approveButton).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'share-update-attachments' }))
|
||||
expect(approveButton).toBeEnabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'share-clear-summary' }))
|
||||
expect(approveButton).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'share-update-summary' }))
|
||||
expect(approveButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import FormPage from '../page'
|
||||
|
||||
vi.mock('../form', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>form-content</div>,
|
||||
}))
|
||||
|
||||
describe('Human input share form page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the form content inside the page shell', () => {
|
||||
render(<FormPage />)
|
||||
|
||||
expect(screen.getByText('form-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
|
||||
type FormStatusCardProps = {
|
||||
iconClassName: string
|
||||
title: ReactNode
|
||||
subtitle?: ReactNode
|
||||
submissionID?: string
|
||||
}
|
||||
|
||||
const FormStatusCard = ({
|
||||
iconClassName,
|
||||
title,
|
||||
subtitle,
|
||||
submissionID,
|
||||
}: FormStatusCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={cn('flex size-full flex-col items-center justify-center')}>
|
||||
<div className="max-w-160 min-w-120">
|
||||
<div className="flex h-80 flex-col gap-4 rounded-[20px] border border-divider-subtle bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<span className={cn('size-8', iconClassName)} />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{title}</div>
|
||||
{!!subtitle && (
|
||||
<div className="title-4xl-semi-bold text-text-primary">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
{submissionID && (
|
||||
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">
|
||||
{t('humanInput.submissionID', { id: submissionID, ns: 'share' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-1">
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FormStatusCard
|
||||
@@ -1,34 +1,23 @@
|
||||
'use client'
|
||||
import type { ButtonProps } from '@langgenius/dify-ui/button'
|
||||
import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { HumanInputFormError } from '@/service/use-share'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiCheckboxCircleFill,
|
||||
RiErrorWarningFill,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { produce } from 'immer'
|
||||
import type { HumanInputResolvedValue } from '@/types/workflow'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
|
||||
import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time'
|
||||
import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useParams } from '@/next/navigation'
|
||||
import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share'
|
||||
import { useGetHumanInputForm } from '@/service/use-share'
|
||||
import FormStatusCard from './form-status-card'
|
||||
import LoadedFormContent from './loaded-form-content'
|
||||
import { useFormSubmit } from './use-form-submit'
|
||||
|
||||
export type FormData = {
|
||||
site: { site: SiteInfo }
|
||||
form_content: string
|
||||
inputs: FormInputItem[]
|
||||
resolved_default_values: Record<string, string>
|
||||
resolved_default_values: Record<string, HumanInputResolvedValue>
|
||||
user_actions: UserAction[]
|
||||
expiration_time: number
|
||||
}
|
||||
@@ -39,58 +28,13 @@ const FormContent = () => {
|
||||
const { token } = useParams<{ token: string }>()
|
||||
useDocumentTitle('')
|
||||
|
||||
const [inputs, setInputs] = useState<Record<string, string>>({})
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm()
|
||||
|
||||
const { data: formData, isLoading, error } = useGetHumanInputForm(token)
|
||||
const { isSubmitting, submit, success } = useFormSubmit(token)
|
||||
|
||||
const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired'
|
||||
const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted'
|
||||
const rateLimitExceeded = (error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded'
|
||||
|
||||
const splitByOutputVar = (content: string): string[] => {
|
||||
const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
|
||||
const parts = content.split(outputVarRegex)
|
||||
return parts.filter(part => part.length > 0)
|
||||
}
|
||||
|
||||
const contentList = useMemo(() => {
|
||||
if (!formData?.form_content)
|
||||
return []
|
||||
return splitByOutputVar(formData.form_content)
|
||||
}, [formData?.form_content])
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData?.inputs)
|
||||
return
|
||||
const initialInputs: Record<string, string> = {}
|
||||
formData.inputs.forEach((item) => {
|
||||
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? formData.resolved_default_values[item.output_variable_name] || '' : item.default.value
|
||||
})
|
||||
setInputs(initialInputs)
|
||||
}, [formData?.inputs, formData?.resolved_default_values])
|
||||
|
||||
// use immer
|
||||
const handleInputsChange = (name: string, value: string) => {
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft[name] = value
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}
|
||||
|
||||
const submit = (actionID: string) => {
|
||||
submitForm(
|
||||
{ token, data: { inputs, action: actionID } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccess(true)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Loading type="app" />
|
||||
@@ -99,190 +43,62 @@ const FormContent = () => {
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className={cn('flex size-full flex-col items-center justify-center')}>
|
||||
<div className="max-w-[640px] min-w-[480px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
|
||||
<div className="h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiCheckboxCircleFill className="size-8 text-text-success" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.thanks', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.recorded', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormStatusCard
|
||||
iconClassName="i-ri-checkbox-circle-fill text-text-success"
|
||||
title={t('humanInput.thanks', { ns: 'share' })}
|
||||
subtitle={t('humanInput.recorded', { ns: 'share' })}
|
||||
submissionID={token}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (expired) {
|
||||
return (
|
||||
<div className={cn('flex size-full flex-col items-center justify-center')}>
|
||||
<div className="max-w-[640px] min-w-[480px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiInformation2Fill className="size-8 text-text-accent" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.expired', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormStatusCard
|
||||
iconClassName="i-ri-information-2-fill text-text-accent"
|
||||
title={t('humanInput.sorry', { ns: 'share' })}
|
||||
subtitle={t('humanInput.expired', { ns: 'share' })}
|
||||
submissionID={token}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className={cn('flex size-full flex-col items-center justify-center')}>
|
||||
<div className="max-w-[640px] min-w-[480px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiInformation2Fill className="size-8 text-text-accent" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.completed', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="shrink-0 system-2xs-regular-uppercase text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormStatusCard
|
||||
iconClassName="i-ri-information-2-fill text-text-accent"
|
||||
title={t('humanInput.sorry', { ns: 'share' })}
|
||||
subtitle={t('humanInput.completed', { ns: 'share' })}
|
||||
submissionID={token}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (rateLimitExceeded) {
|
||||
return (
|
||||
<div className={cn('flex size-full flex-col items-center justify-center')}>
|
||||
<div className="max-w-[640px] min-w-[480px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiErrorWarningFill className="size-8 text-text-destructive" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.rateLimitExceeded', { ns: 'share' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormStatusCard
|
||||
iconClassName="i-ri-error-warning-fill text-text-destructive"
|
||||
title={t('humanInput.rateLimitExceeded', { ns: 'share' })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!formData) {
|
||||
return (
|
||||
<div className={cn('flex size-full flex-col items-center justify-center')}>
|
||||
<div className="max-w-[640px] min-w-[480px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
|
||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiErrorWarningFill className="size-8 text-text-destructive" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.formNotFound', { ns: 'share' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormStatusCard
|
||||
iconClassName="i-ri-error-warning-fill text-text-destructive"
|
||||
title={t('humanInput.formNotFound', { ns: 'share' })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const site = formData.site.site
|
||||
|
||||
return (
|
||||
<div className={cn('mx-auto flex h-full w-full max-w-[720px] flex-col items-center')}>
|
||||
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={site.icon_type}
|
||||
icon={site.icon}
|
||||
background={site.icon_background}
|
||||
imageUrl={site.icon_url}
|
||||
/>
|
||||
<div className="grow system-xl-semibold text-text-primary">{site.title}</div>
|
||||
</div>
|
||||
<div className="h-0 w-full grow overflow-y-auto">
|
||||
<div className="border-components-divider-subtle rounded-[20px] border bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-xs">
|
||||
{contentList.map((content, index) => (
|
||||
<ContentItem
|
||||
key={index}
|
||||
content={content}
|
||||
formInputFields={formData.inputs}
|
||||
inputs={inputs}
|
||||
onInputChange={handleInputsChange}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-1 py-1">
|
||||
{formData.user_actions.map((action: UserAction) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
disabled={isSubmitting}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(action.id)}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LoadedFormContent
|
||||
key={token}
|
||||
formData={formData}
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ButtonProps } from '@langgenius/dify-ui/button'
|
||||
import type { FormData } from './form'
|
||||
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
|
||||
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { produce } from 'immer'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
|
||||
import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time'
|
||||
import { getButtonStyle, getRenderedFormInputs, hasInvalidRequiredHumanInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
|
||||
type LoadedFormContentProps = {
|
||||
formData: FormData
|
||||
isSubmitting: boolean
|
||||
onSubmit: (inputs: Record<string, HumanInputFieldValue>, actionID: string, formInputs: FormData['inputs']) => void
|
||||
}
|
||||
|
||||
const LoadedFormContent = ({
|
||||
formData,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
}: LoadedFormContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content)
|
||||
const [inputs, setInputs] = useState<Record<string, HumanInputFieldValue>>(() =>
|
||||
initializeInputs(renderedFormInputs, formData.resolved_default_values),
|
||||
)
|
||||
|
||||
const contentList = useMemo(() => {
|
||||
return splitByOutputVar(formData.form_content)
|
||||
}, [formData.form_content])
|
||||
|
||||
const handleInputsChange = (name: string, value: HumanInputFieldValue) => {
|
||||
setInputs(prevInputs => produce(prevInputs, (draft) => {
|
||||
draft[name] = value
|
||||
}))
|
||||
}
|
||||
|
||||
const submit = (actionID: string) => {
|
||||
onSubmit(inputs, actionID, formData.inputs)
|
||||
}
|
||||
|
||||
const isActionDisabled = isSubmitting || hasInvalidRequiredHumanInput(renderedFormInputs, inputs)
|
||||
const site = formData.site.site
|
||||
|
||||
return (
|
||||
<div className={cn('mx-auto flex size-full max-w-180 flex-col items-center')}>
|
||||
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={site.icon_type}
|
||||
icon={site.icon}
|
||||
background={site.icon_background}
|
||||
imageUrl={site.icon_url}
|
||||
/>
|
||||
<div className="grow system-xl-semibold text-text-primary">{site.title}</div>
|
||||
</div>
|
||||
<div className="h-0 w-full grow overflow-y-auto">
|
||||
<div className="rounded-[20px] border border-divider-subtle bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-xs">
|
||||
{contentList.map((content, index) => (
|
||||
<ContentItem
|
||||
key={index}
|
||||
content={content}
|
||||
formInputFields={formData.inputs}
|
||||
inputs={inputs}
|
||||
onInputChange={handleInputsChange}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-1 py-1">
|
||||
{formData.user_actions.map((action: UserAction) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
disabled={isActionDisabled}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(action.id)}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-1">
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadedFormContent
|
||||
@@ -4,7 +4,7 @@ import FormContent from './form'
|
||||
|
||||
const FormPage = () => {
|
||||
return (
|
||||
<div className="h-full min-w-[300px] bg-chatbot-bg pb-[env(safe-area-inset-bottom)]">
|
||||
<div className="h-full min-w-75 bg-chatbot-bg pb-[env(safe-area-inset-bottom)]">
|
||||
<FormContent />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { getProcessedHumanInputFormInputs } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
|
||||
import { useSubmitHumanInputForm } from '@/service/use-share'
|
||||
|
||||
export const useFormSubmit = (token: string) => {
|
||||
const [success, setSuccess] = useState(false)
|
||||
const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm()
|
||||
|
||||
const submit = useCallback((inputs: Record<string, HumanInputFieldValue>, actionID: string, formInputs: FormInputItem[]) => {
|
||||
submitForm(
|
||||
{
|
||||
token,
|
||||
data: {
|
||||
inputs: getProcessedHumanInputFormInputs(formInputs, inputs) || {},
|
||||
action: actionID,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccess(true)
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [submitForm, token])
|
||||
|
||||
return {
|
||||
isSubmitting,
|
||||
submit,
|
||||
success,
|
||||
}
|
||||
}
|
||||
+29
-4
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import TypeSelector from '../type-select'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/select', () => import('@/__mocks__/base-ui-select'))
|
||||
@@ -9,8 +10,9 @@ vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon',
|
||||
}))
|
||||
|
||||
describe('TypeSelector', () => {
|
||||
it('should toggle open state and select a new variable type', () => {
|
||||
it('should select a new variable type when an option is clicked', async () => {
|
||||
const onSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TypeSelector
|
||||
@@ -23,9 +25,32 @@ describe('TypeSelector', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByText('Number'))
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
const [, numberOption] = await screen.findAllByRole('option')
|
||||
await user.click(numberOption!)
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({ value: 'number', name: 'Number' })
|
||||
})
|
||||
|
||||
it('should size popup content to match the trigger width', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TypeSelector
|
||||
value="text-input"
|
||||
onSelect={vi.fn()}
|
||||
items={[
|
||||
{ value: 'text-input' as any, name: 'Text' },
|
||||
{ value: 'number' as any, name: 'Number' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
const [, numberOption] = await screen.findAllByRole('option')
|
||||
const popup = numberOption!.closest('[data-side]')
|
||||
|
||||
expect(popup).toHaveClass('w-(--anchor-width)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,9 +59,10 @@ const TypeSelector: FC<Props> = ({
|
||||
<div className="flex items-center">
|
||||
<InputVarTypeIcon type={selectedItem?.value as InputVarType} className="size-4 shrink-0 text-text-secondary" />
|
||||
<span
|
||||
className={`
|
||||
ml-1.5 text-components-input-text-filled ${!selectedItem?.name && 'text-components-input-text-placeholder'}
|
||||
`}
|
||||
className={cn(
|
||||
'ml-1.5 truncate text-components-input-text-filled',
|
||||
!selectedItem?.name && 'text-components-input-text-placeholder',
|
||||
)}
|
||||
>
|
||||
{selectedItem?.name}
|
||||
</span>
|
||||
@@ -73,7 +74,7 @@ const TypeSelector: FC<Props> = ({
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
sideOffset={4}
|
||||
popupClassName={cn('w-[432px] rounded-md px-1 py-1 text-base sm:text-sm', popupInnerClassName)}
|
||||
popupClassName={cn('w-(--anchor-width) rounded-md px-1 py-1 text-base sm:text-sm', popupInnerClassName)}
|
||||
listClassName="max-h-80 p-0"
|
||||
>
|
||||
{items.map((item: Item) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type'
|
||||
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
|
||||
import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
@@ -178,7 +179,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
||||
// eslint-disable-next-line react/set-state-in-effect
|
||||
setCurrentTab(getDefaultGenerationTab(workflowProcessData))
|
||||
}, [workflowProcessData])
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record<string, string>, action: string }) => {
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => {
|
||||
if (appSourceType === AppSourceType.installedApp)
|
||||
await submitHumanInputFormService(formToken, formData)
|
||||
else
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type'
|
||||
import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -17,7 +18,7 @@ type WorkflowBodyProps = {
|
||||
depth: number
|
||||
hideProcessDetail?: boolean
|
||||
isError: boolean
|
||||
onSubmitHumanInputForm: (formToken: string, formData: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
onSubmitHumanInputForm: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
|
||||
onSwitchTab: (tab: string) => void
|
||||
showResultTabs: boolean
|
||||
siteInfo: SiteInfo | null
|
||||
|
||||
@@ -1076,6 +1076,10 @@ describe('useChatWithHistory', () => {
|
||||
await waitFor(() => {
|
||||
expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
|
||||
expect(answerNode?.humanInputFormDataList).toHaveLength(1)
|
||||
expect(answerNode?.workflow_run_id).toBe('wf-run-1')
|
||||
})
|
||||
|
||||
it('should set workflow_run_id for normal messages with submitted human_input', async () => {
|
||||
@@ -1114,6 +1118,75 @@ describe('useChatWithHistory', () => {
|
||||
await waitFor(() => {
|
||||
expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
|
||||
expect(answerNode?.humanInputFilledFormDataList).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should parse human input payloads regardless of message status', async () => {
|
||||
const listData = createConversationData({
|
||||
data: [createConversationItem({ id: 'conversation-1' })],
|
||||
})
|
||||
const chatListData = {
|
||||
data: [
|
||||
{
|
||||
id: 'msg-status-agnostic',
|
||||
query: 'Needs review',
|
||||
answer: 'Pending follow-up',
|
||||
message_files: [],
|
||||
feedback: null,
|
||||
retriever_resources: [],
|
||||
agent_thoughts: null,
|
||||
parent_message_id: null,
|
||||
inputs: {},
|
||||
status: 'error',
|
||||
extra_contents: [
|
||||
{
|
||||
type: 'human_input',
|
||||
submitted: true,
|
||||
form_definition: {
|
||||
form_id: 'form-1',
|
||||
node_id: 'node-1',
|
||||
node_title: 'Human Input',
|
||||
form_content: '{{#$output.summary#}}',
|
||||
inputs: [],
|
||||
actions: [],
|
||||
form_token: 'token-1',
|
||||
resolved_default_values: {},
|
||||
display_in_ui: true,
|
||||
expiration_time: 0,
|
||||
},
|
||||
workflow_run_id: 'wf-run-status-agnostic',
|
||||
form_submission_data: {
|
||||
node_id: 'node-1',
|
||||
node_title: 'Human Input',
|
||||
rendered_content: 'Submitted summary',
|
||||
action_id: 'submit',
|
||||
action_text: 'Submit',
|
||||
submitted_data: {
|
||||
summary: 'approved',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
mockFetchConversations.mockResolvedValue(listData)
|
||||
mockFetchChatList.mockResolvedValue(chatListData)
|
||||
|
||||
const { result } = await renderWithClient(() => useChatWithHistory())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result!.current.appPrevChatTree.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const answerNode = result!.current.appPrevChatTree[0]?.children?.[0]
|
||||
expect(answerNode?.humanInputFormDataList).toHaveLength(0)
|
||||
expect(answerNode?.humanInputFilledFormDataList).toHaveLength(1)
|
||||
expect(answerNode?.humanInputFilledFormDataList?.[0]?.form_content).toBe('{{#$output.summary#}}')
|
||||
expect(answerNode?.humanInputFilledFormDataList?.[0]?.inputs).toEqual([])
|
||||
expect(answerNode?.workflow_run_id).toBe('wf-run-status-agnostic')
|
||||
})
|
||||
|
||||
it('should return empty appPrevChatTree when there is no currentConversationId', async () => {
|
||||
@@ -1835,6 +1908,15 @@ describe('useChatWithHistory', () => {
|
||||
expect(messageWithFiles?.message_files).toHaveLength(1)
|
||||
expect(messageWithFiles?.children?.[0]?.message_files).toHaveLength(1)
|
||||
expect(messageWithFiles?.children?.[0]?.agent_thoughts?.[0]?.message_files).toHaveLength(1)
|
||||
|
||||
const normalAnswerNode = messageWithFiles?.children?.[0]
|
||||
const pausedAnswerNode = result!.current.appPrevChatTree.find(item => item.id === 'question-msg-paused-branch')?.children?.[0]
|
||||
|
||||
expect(normalAnswerNode?.humanInputFilledFormDataList).toHaveLength(1)
|
||||
expect(normalAnswerNode?.humanInputFormDataList).toHaveLength(0)
|
||||
expect(pausedAnswerNode?.humanInputFormDataList).toHaveLength(1)
|
||||
expect(pausedAnswerNode?.humanInputFilledFormDataList).toHaveLength(0)
|
||||
expect(pausedAnswerNode?.workflow_run_id).toBe('wf-run-branch')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AppSourceType, delConversation, pinConversation, renameConversation, un
|
||||
import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
|
||||
import { enrichSubmittedHumanInputFormData } from '../chat/answer/human-input-content/submitted-utils'
|
||||
import { CONVERSATION_ID_INFO } from '../constants'
|
||||
import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils'
|
||||
|
||||
@@ -39,21 +40,30 @@ function getFormattedChatList(messages: any[]) {
|
||||
const humanInputFormDataList: HumanInputFormData[] = []
|
||||
const humanInputFilledFormDataList: HumanInputFilledFormData[] = []
|
||||
let workflowRunId = ''
|
||||
if (item.status === 'paused') {
|
||||
item.extra_contents?.forEach((content: ExtraContent) => {
|
||||
if (content.type === 'human_input' && !content.submitted) {
|
||||
humanInputFormDataList.push(content.form_definition)
|
||||
workflowRunId = content.workflow_run_id
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (item.status === 'normal') {
|
||||
item.extra_contents?.forEach((content: ExtraContent) => {
|
||||
if (content.type === 'human_input' && content.submitted) {
|
||||
humanInputFilledFormDataList.push(content.form_submission_data)
|
||||
}
|
||||
})
|
||||
}
|
||||
item.extra_contents?.forEach((content: ExtraContent) => {
|
||||
if (content.type !== 'human_input')
|
||||
return
|
||||
|
||||
const formDefinition = 'form_definition' in content ? content.form_definition : undefined
|
||||
if (!content.submitted) {
|
||||
if (!formDefinition)
|
||||
return
|
||||
humanInputFormDataList.push(formDefinition)
|
||||
workflowRunId = content.workflow_run_id || workflowRunId
|
||||
return
|
||||
}
|
||||
|
||||
if (!('form_submission_data' in content) || !content.form_submission_data)
|
||||
return
|
||||
const currentFormIndex = humanInputFormDataList.findIndex(item => item.node_id === content.form_submission_data.node_id)
|
||||
const requiredFormData = formDefinition || (currentFormIndex > -1
|
||||
? humanInputFormDataList[currentFormIndex]
|
||||
: undefined)
|
||||
if (currentFormIndex > -1)
|
||||
humanInputFormDataList.splice(currentFormIndex, 1)
|
||||
workflowRunId = content.workflow_run_id || workflowRunId
|
||||
humanInputFilledFormDataList.push(enrichSubmittedHumanInputFormData(content.form_submission_data, requiredFormData))
|
||||
})
|
||||
newChatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
|
||||
@@ -411,7 +411,7 @@ describe('useChat', () => {
|
||||
|
||||
// Human input required
|
||||
callbacks.onHumanInputRequired({ data: { node_id: 'n-human' } })
|
||||
callbacks.onHumanInputRequired({ data: { node_id: 'n-human', updated: true } }) // update existing
|
||||
callbacks.onHumanInputRequired({ data: { node_id: 'n-human', updated: true, form_content: '{{#$output.answer#}}', inputs: [] } }) // update existing
|
||||
|
||||
// setTimeout for timeout form
|
||||
callbacks.onHumanInputFormTimeout({ data: { node_id: 'n-human', expiration_time: 123456 } })
|
||||
@@ -437,6 +437,10 @@ describe('useChat', () => {
|
||||
const lastResponse = result.current.chatList[1]
|
||||
expect(lastResponse!.humanInputFormDataList).toHaveLength(0) // Removed when filled
|
||||
expect(lastResponse!.humanInputFilledFormDataList).toHaveLength(2)
|
||||
expect(lastResponse!.humanInputFilledFormDataList![0]).toEqual(expect.objectContaining({
|
||||
form_content: '{{#$output.answer#}}',
|
||||
inputs: [],
|
||||
}))
|
||||
expect(sseGet).toHaveBeenCalled() // from workflowPaused
|
||||
expect(lastResponse!.annotation?.id).toBe('anno-1')
|
||||
expect(lastResponse!.content).toBe('Replaced content')
|
||||
|
||||
+12
-6
@@ -1,5 +1,6 @@
|
||||
import type { HumanInputFilledFormData } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import HumanInputFilledFormList from '../human-input-filled-form-list'
|
||||
|
||||
@@ -12,13 +13,15 @@ const createFormData = (
|
||||
): HumanInputFilledFormData => ({
|
||||
node_id: 'node-1',
|
||||
node_title: 'Node Title',
|
||||
rendered_content: 'fallback content',
|
||||
action_id: 'approve',
|
||||
action_text: 'Approve',
|
||||
submitted_data: {
|
||||
summary: 'Approved',
|
||||
},
|
||||
|
||||
// 👇 IMPORTANT
|
||||
// DO NOT guess properties like `inputs`
|
||||
// Only include fields that actually exist in your project type.
|
||||
// Leave everything else empty via spread.
|
||||
...overrides,
|
||||
} as HumanInputFilledFormData)
|
||||
})
|
||||
|
||||
describe('HumanInputFilledFormList', () => {
|
||||
it('renders nothing when list is empty', () => {
|
||||
@@ -27,12 +30,15 @@ describe('HumanInputFilledFormList', () => {
|
||||
expect(screen.queryByText('Node Title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders one form item', () => {
|
||||
it('renders one form item', async () => {
|
||||
const user = userEvent.setup()
|
||||
const data = [createFormData()]
|
||||
|
||||
render(<HumanInputFilledFormList humanInputFilledFormDataList={data} />)
|
||||
|
||||
expect(screen.getByText('Node Title')).toBeInTheDocument()
|
||||
await user.click(screen.getByTestId('expand-icon'))
|
||||
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Approved')
|
||||
})
|
||||
|
||||
it('renders multiple form items', () => {
|
||||
|
||||
@@ -248,6 +248,13 @@ describe('Operation', () => {
|
||||
expect(screen.getByTestId('log-btn'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep hover-only controls visible when a descendant popup is open', () => {
|
||||
renderOperation({ ...baseProps, showPromptLog: true })
|
||||
|
||||
expect(screen.getByTestId('operation-actions')).toHaveClass('group-has-[[data-popup-open]]:flex')
|
||||
expect(screen.getByTestId('log-btn').parentElement).toHaveClass('group-has-[[data-popup-open]]:block')
|
||||
})
|
||||
|
||||
it('should not show prompt log for opening statements', () => {
|
||||
const item = { ...baseItem, isOpeningStatement: true }
|
||||
renderOperation({ ...baseProps, item, showPromptLog: true })
|
||||
|
||||
+96
-12
@@ -1,13 +1,35 @@
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ContentItem from '../content-item'
|
||||
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content }: { content: string }) => <div data-testid="mock-markdown">{content}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../field-renderer', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
field,
|
||||
onChange,
|
||||
}: {
|
||||
field: FormInputItem
|
||||
onChange: (value: unknown) => void
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`renderer-${field.type}`}
|
||||
aria-label={field.output_variable_name}
|
||||
onClick={() => onChange(field.type === 'paragraph' ? 'updated value' : field.type)}
|
||||
>
|
||||
{field.type}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ContentItem', () => {
|
||||
const mockOnInputChange = vi.fn()
|
||||
const mockFormInputFields: FormInputItem[] = [
|
||||
@@ -49,9 +71,9 @@ describe('ContentItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByTestId('content-item-textarea')
|
||||
const textarea = screen.getByTestId('renderer-paragraph')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect(textarea).toHaveValue('Initial bio')
|
||||
expect(screen.getByRole('button', { name: 'user_bio' })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('mock-markdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -66,10 +88,9 @@ describe('ContentItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByTestId('content-item-textarea')
|
||||
await user.type(textarea, 'x')
|
||||
await user.click(screen.getByTestId('renderer-paragraph'))
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'Initial biox')
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'updated value')
|
||||
})
|
||||
|
||||
it('should render nothing if field name is valid but not found in formInputFields', () => {
|
||||
@@ -85,18 +106,20 @@ describe('ContentItem', () => {
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render nothing if input type is not supported', () => {
|
||||
const { container } = render(
|
||||
it('should delegate select fields to the shared renderer', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ContentItem
|
||||
content="{{#$output.user_bio#}}"
|
||||
formInputFields={[
|
||||
{
|
||||
type: 'text-input',
|
||||
type: 'select',
|
||||
output_variable_name: 'user_bio',
|
||||
default: {
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
value: '',
|
||||
selector: [],
|
||||
value: [],
|
||||
},
|
||||
} as FormInputItem,
|
||||
]}
|
||||
@@ -105,7 +128,68 @@ describe('ContentItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-testid="content-item-textarea"]')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.py-3')?.textContent).toBe('')
|
||||
await user.click(screen.getByTestId('renderer-select'))
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'select')
|
||||
})
|
||||
|
||||
it('should delegate single-file fields to the shared renderer', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ContentItem
|
||||
content="{{#$output.attachment#}}"
|
||||
formInputFields={[
|
||||
{
|
||||
type: 'file',
|
||||
output_variable_name: 'attachment',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: ['document'],
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
} as FormInputItem,
|
||||
]}
|
||||
inputs={{ attachment: null }}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('renderer-file'))
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith('attachment', 'file')
|
||||
})
|
||||
|
||||
it('should delegate file-list fields to the shared renderer', async () => {
|
||||
const user = userEvent.setup()
|
||||
const existingFiles: FileEntity[] = [{
|
||||
id: 'file-1',
|
||||
name: 'brief.pdf',
|
||||
size: 128,
|
||||
type: 'document',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
}]
|
||||
|
||||
render(
|
||||
<ContentItem
|
||||
content="{{#$output.attachments#}}"
|
||||
formInputFields={[
|
||||
{
|
||||
type: 'file-list',
|
||||
output_variable_name: 'attachments',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: ['document'],
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
number_limits: 4,
|
||||
} as FormInputItem,
|
||||
]}
|
||||
inputs={{ attachments: existingFiles }}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('renderer-file-list'))
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith('attachments', 'file-list')
|
||||
})
|
||||
})
|
||||
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import HumanInputFieldRenderer from '../field-renderer'
|
||||
|
||||
function MockTextarea({
|
||||
value,
|
||||
onChange,
|
||||
onValueChange,
|
||||
...props
|
||||
}: {
|
||||
value: string
|
||||
onChange?: (event: { target: { value: string } }) => void
|
||||
onValueChange?: (value: string) => void
|
||||
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
data-testid="content-item-textarea"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
onChange?.({ target: { value: event.target.value } })
|
||||
onValueChange?.(event.target.value)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
vi.mock('@langgenius/dify-ui/textarea', () => ({
|
||||
Textarea: MockTextarea,
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/select', () => ({
|
||||
Select: ({ children, onValueChange }: { children: React.ReactNode, onValueChange: (value: string | null) => void }) => (
|
||||
<div>
|
||||
<button type="button" data-testid="content-item-select-root" onClick={() => onValueChange('alice')}>select alice</button>
|
||||
<button type="button" data-testid="content-item-select-null" onClick={() => onValueChange(null)}>select null</button>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => <button type="button" data-testid="content-item-select">{children}</button>,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItemText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
SelectItemIndicator: () => <span>selected</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: ({ value, onChange, fileConfig }: {
|
||||
value?: FileEntity[]
|
||||
onChange: (files: FileEntity[]) => void
|
||||
fileConfig: { number_limits?: number }
|
||||
}) => (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`content-item-file-${fileConfig.number_limits ?? 0}`}
|
||||
onClick={() => onChange([{ id: 'file-1', name: 'report.pdf', size: 1, type: 'document', progress: 100, transferMethod: TransferMethod.local_file, supportFileType: 'document' }])}
|
||||
>
|
||||
{(value || []).map(file => file.name).join(',')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`content-item-file-clear-${fileConfig.number_limits ?? 0}`}
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('HumanInputFieldRenderer', () => {
|
||||
it('renders paragraph input and emits string changes', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<HumanInputFieldRenderer
|
||||
field={{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'summary',
|
||||
default: { type: 'constant', selector: [], value: '' },
|
||||
}}
|
||||
value="hello"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('content-item-textarea'), {
|
||||
target: { value: 'hello world' },
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith('hello world')
|
||||
})
|
||||
|
||||
it('renders paragraph input with an accessible name', () => {
|
||||
render(
|
||||
<HumanInputFieldRenderer
|
||||
field={{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'summary',
|
||||
default: { type: 'constant', selector: [], value: '' },
|
||||
}}
|
||||
value="hello"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('summary')).toHaveValue('hello')
|
||||
})
|
||||
|
||||
it('renders paragraph input with an empty value when the current value is not a string', () => {
|
||||
render(
|
||||
<HumanInputFieldRenderer
|
||||
field={{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'summary',
|
||||
default: { type: 'constant', selector: [], value: '' },
|
||||
}}
|
||||
value={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('content-item-textarea')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('renders select input and emits selected values', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<HumanInputFieldRenderer
|
||||
field={{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'reviewer',
|
||||
option_source: { type: 'constant', selector: [], value: ['alice', 'bob'] },
|
||||
}}
|
||||
value=""
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('content-item-select-root'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('alice')
|
||||
})
|
||||
|
||||
it('ignores null select values', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<HumanInputFieldRenderer
|
||||
field={{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'reviewer',
|
||||
option_source: { type: 'constant', selector: [], value: ['alice', 'bob'] },
|
||||
}}
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('content-item-select')).toHaveTextContent('')
|
||||
|
||||
await user.click(screen.getByTestId('content-item-select-null'))
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders single-file input and emits one file', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<HumanInputFieldRenderer
|
||||
field={{
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'attachment',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
}}
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('content-item-file-1'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'report.pdf' }))
|
||||
})
|
||||
|
||||
it('renders existing single-file values and emits null when cleared', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<HumanInputFieldRenderer
|
||||
field={{
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'attachment',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
}}
|
||||
value={{ id: 'file-2', name: 'existing.pdf', size: 1, type: 'document', progress: 100, transferMethod: TransferMethod.local_file, supportFileType: 'document' }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('content-item-file-1')).toHaveTextContent('existing.pdf')
|
||||
|
||||
await user.click(screen.getByTestId('content-item-file-clear-1'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('renders file-list input and emits file arrays with max count', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<HumanInputFieldRenderer
|
||||
field={{
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'attachments',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
number_limits: 3,
|
||||
}}
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('content-item-file-3'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([expect.objectContaining({ name: 'report.pdf' })])
|
||||
})
|
||||
|
||||
it('uses the default max count for file-list inputs without an explicit limit', () => {
|
||||
render(
|
||||
<HumanInputFieldRenderer
|
||||
field={{
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'attachments',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
}}
|
||||
value={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('content-item-file-5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing for unsupported input types', () => {
|
||||
const { container } = render(
|
||||
<HumanInputFieldRenderer
|
||||
field={{
|
||||
type: 'unsupported',
|
||||
output_variable_name: 'unknown',
|
||||
} as unknown as Parameters<typeof HumanInputFieldRenderer>[0]['field']}
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
+242
-12
@@ -4,13 +4,74 @@ import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import HumanInputForm from '../human-input-form'
|
||||
|
||||
vi.mock('../content-item', () => ({
|
||||
default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: string) => void }) => (
|
||||
default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: unknown) => void }) => (
|
||||
<div data-testid="mock-content-item">
|
||||
{content}
|
||||
<button data-testid="update-input" onClick={() => onInputChange('field1', 'new value')}>Update</button>
|
||||
<button data-testid="update-select" onClick={() => onInputChange('field2', 'approved')}>Update Select</button>
|
||||
<button
|
||||
data-testid="update-single-file"
|
||||
onClick={() => onInputChange('field4', {
|
||||
id: 'file-2',
|
||||
name: 'main.png',
|
||||
size: 256,
|
||||
type: 'image/png',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'image',
|
||||
uploadedId: 'upload-file-2',
|
||||
})}
|
||||
>
|
||||
Update Single File
|
||||
</button>
|
||||
<button
|
||||
data-testid="update-pending-single-file"
|
||||
onClick={() => onInputChange('field4', {
|
||||
id: 'file-2',
|
||||
name: 'main.png',
|
||||
size: 256,
|
||||
type: 'image/png',
|
||||
progress: 50,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'image',
|
||||
})}
|
||||
>
|
||||
Update Pending Single File
|
||||
</button>
|
||||
<button
|
||||
data-testid="update-input-file"
|
||||
onClick={() => onInputChange('field3', [{
|
||||
id: 'file-1',
|
||||
name: 'avatar.png',
|
||||
size: 128,
|
||||
type: 'image/png',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'image',
|
||||
uploadedId: 'upload-file-1',
|
||||
}])}
|
||||
>
|
||||
Update File
|
||||
</button>
|
||||
<button
|
||||
data-testid="update-pending-input-file"
|
||||
onClick={() => onInputChange('field3', [{
|
||||
id: 'file-1',
|
||||
name: 'avatar.png',
|
||||
size: 128,
|
||||
type: 'image/png',
|
||||
progress: 50,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'image',
|
||||
}])}
|
||||
>
|
||||
Update Pending File
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@@ -50,12 +111,10 @@ describe('HumanInputForm', () => {
|
||||
expect(contentItems[1])!.toHaveTextContent('{{#$output.field1#}}')
|
||||
expect(contentItems[2])!.toHaveTextContent('Part 2')
|
||||
|
||||
const buttons = screen.getAllByRole('button').filter(button => button.textContent !== 'Update')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[0])!.toHaveTextContent('Submit')
|
||||
expect(buttons[1])!.toHaveTextContent('Cancel')
|
||||
expect(buttons[2])!.toHaveTextContent('Accent')
|
||||
expect(buttons[3])!.toHaveTextContent('Ghost')
|
||||
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Accent' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Ghost' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle input changes and submit correctly', async () => {
|
||||
@@ -76,6 +135,175 @@ describe('HumanInputForm', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should submit file field values using the backend payload shape', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
|
||||
const formDataWithFileList: HumanInputFormData = {
|
||||
...mockFormData,
|
||||
form_content: '{{#$output.field1#}} {{#$output.field3#}}',
|
||||
inputs: [
|
||||
{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'field1',
|
||||
default: { type: 'constant', value: 'initial', selector: [] },
|
||||
},
|
||||
{
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'field3',
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
number_limits: 5,
|
||||
},
|
||||
] as FormInputItem[],
|
||||
}
|
||||
|
||||
render(<HumanInputForm formData={formDataWithFileList} onSubmit={mockOnSubmit} />)
|
||||
|
||||
await user.click(screen.getAllByTestId('update-input')[0]!)
|
||||
await user.click(screen.getAllByTestId('update-input-file')[0]!)
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
|
||||
action: 'action_1',
|
||||
inputs: {
|
||||
field1: 'new value',
|
||||
field3: [{
|
||||
type: 'image',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: '',
|
||||
upload_file_id: 'upload-file-1',
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable buttons until select, file, and file list inputs have uploaded values', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
|
||||
const formDataWithRequiredInteractiveFields: HumanInputFormData = {
|
||||
...mockFormData,
|
||||
form_content: '{{#$output.field2#}} {{#$output.field3#}} {{#$output.field4#}}',
|
||||
inputs: [
|
||||
{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'field2',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
value: ['approved'],
|
||||
selector: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'field3',
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
number_limits: 5,
|
||||
},
|
||||
{
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'field4',
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
},
|
||||
] as FormInputItem[],
|
||||
}
|
||||
|
||||
render(<HumanInputForm formData={formDataWithRequiredInteractiveFields} onSubmit={mockOnSubmit} />)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
await user.click(screen.getAllByTestId('update-select')[0]!)
|
||||
await user.click(screen.getAllByTestId('update-pending-single-file')[0]!)
|
||||
await user.click(screen.getAllByTestId('update-input-file')[0]!)
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
await user.click(screen.getAllByTestId('update-single-file')[0]!)
|
||||
await user.click(screen.getAllByTestId('update-pending-input-file')[0]!)
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
await user.click(screen.getAllByTestId('update-input-file')[0]!)
|
||||
expect(submitButton).toBeEnabled()
|
||||
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
|
||||
action: 'action_1',
|
||||
inputs: {
|
||||
field2: 'approved',
|
||||
field3: [{
|
||||
type: 'image',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: '',
|
||||
upload_file_id: 'upload-file-1',
|
||||
}],
|
||||
field4: {
|
||||
type: 'image',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: '',
|
||||
upload_file_id: 'upload-file-2',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore input fields that are not rendered in form content when checking actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
|
||||
const formDataWithStaleInput: HumanInputFormData = {
|
||||
...mockFormData,
|
||||
form_content: '{{#$output.field2#}}',
|
||||
inputs: [
|
||||
{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'field2',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
value: ['approved'],
|
||||
selector: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'stale_select',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
value: ['unused'],
|
||||
selector: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'stale_file',
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
},
|
||||
] as FormInputItem[],
|
||||
}
|
||||
|
||||
render(<HumanInputForm formData={formDataWithStaleInput} onSubmit={mockOnSubmit} />)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByTestId('update-select'))
|
||||
expect(submitButton).toBeEnabled()
|
||||
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
|
||||
action: 'action_1',
|
||||
inputs: {
|
||||
field2: 'approved',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable buttons during submission', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveSubmit: (value: void | PromiseLike<void>) => void
|
||||
@@ -109,19 +337,21 @@ describe('HumanInputForm', () => {
|
||||
expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should handle unsupported input types in initializeInputs', () => {
|
||||
it('should handle mixed supported input types in initializeInputs', () => {
|
||||
const formDataWithUnsupported = {
|
||||
...mockFormData,
|
||||
inputs: [
|
||||
{
|
||||
type: 'text-input',
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'field2',
|
||||
default: { type: 'variable', value: '', selector: [] },
|
||||
option_source: { type: 'variable', value: [], selector: [] },
|
||||
} as FormInputItem,
|
||||
{
|
||||
type: 'number',
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'field3',
|
||||
default: { type: 'constant', value: '0', selector: [] },
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_upload_methods: [],
|
||||
} as FormInputItem,
|
||||
],
|
||||
resolved_default_values: { field2: 'default value' },
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { FileResponse } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import SubmittedContentItem from '../submitted-content-item'
|
||||
|
||||
const attachmentValue: FileResponse = {
|
||||
related_id: 'file-1',
|
||||
upload_file_id: 'upload-1',
|
||||
filename: 'decision.pdf',
|
||||
extension: 'pdf',
|
||||
size: 128,
|
||||
mime_type: 'application/pdf',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/decision.pdf',
|
||||
remote_url: '',
|
||||
}
|
||||
|
||||
const evidenceValues: FileResponse[] = [
|
||||
{
|
||||
related_id: 'file-2',
|
||||
upload_file_id: 'upload-2',
|
||||
filename: 'evidence.png',
|
||||
extension: 'png',
|
||||
size: 256,
|
||||
mime_type: 'image/png',
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
type: 'image',
|
||||
url: 'https://example.com/evidence.png',
|
||||
remote_url: 'https://example.com/evidence.png',
|
||||
},
|
||||
{
|
||||
related_id: 'file-3',
|
||||
upload_file_id: 'upload-3',
|
||||
filename: 'evidence.pdf',
|
||||
extension: 'pdf',
|
||||
size: 512,
|
||||
mime_type: 'application/pdf',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/evidence.pdf',
|
||||
remote_url: '',
|
||||
},
|
||||
]
|
||||
|
||||
const fields: FormInputItem[] = [
|
||||
{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'summary',
|
||||
default: { type: 'constant', value: '', selector: [] },
|
||||
},
|
||||
{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'decision',
|
||||
option_source: { type: 'constant', value: ['approve', 'reject'], selector: [] },
|
||||
},
|
||||
{
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'attachment',
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_upload_methods: [],
|
||||
},
|
||||
{
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'evidence',
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_upload_methods: [],
|
||||
number_limits: 5,
|
||||
},
|
||||
]
|
||||
|
||||
describe('SubmittedContentItem', () => {
|
||||
it('renders file-list output placeholders as readonly file lists', () => {
|
||||
render(
|
||||
<SubmittedContentItem
|
||||
content="{{#$output.evidence#}}"
|
||||
formInputFields={fields}
|
||||
values={{ evidence: evidenceValues }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('submitted-field-evidence')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute('src', 'https://example.com/evidence.png')
|
||||
expect(screen.getByText('evidence.pdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders empty text for paragraph and select placeholders with non-string values', () => {
|
||||
const { rerender } = render(
|
||||
<SubmittedContentItem
|
||||
content="{{#$output.summary#}}"
|
||||
formInputFields={fields}
|
||||
values={{ summary: attachmentValue }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('')
|
||||
|
||||
rerender(
|
||||
<SubmittedContentItem
|
||||
content="{{#$output.decision#}}"
|
||||
formInputFields={fields}
|
||||
values={{ decision: evidenceValues }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'decision' })).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('renders nothing when output placeholders do not have a usable submitted value', () => {
|
||||
const { container, rerender } = render(
|
||||
<SubmittedContentItem
|
||||
content="{{#$output.missing#}}"
|
||||
formInputFields={fields}
|
||||
values={{ missing: 'value' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
rerender(
|
||||
<SubmittedContentItem
|
||||
content="{{#$output.attachment#}}"
|
||||
formInputFields={fields}
|
||||
values={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('skips file placeholders when submitted values have incompatible shapes', () => {
|
||||
const { container, rerender } = render(
|
||||
<SubmittedContentItem
|
||||
content="{{#$output.attachment#}}"
|
||||
formInputFields={fields}
|
||||
values={{ attachment: 'not-a-file' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
rerender(
|
||||
<SubmittedContentItem
|
||||
content="{{#$output.attachment#}}"
|
||||
formInputFields={fields}
|
||||
values={{ attachment: evidenceValues }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
rerender(
|
||||
<SubmittedContentItem
|
||||
content="{{#$output.evidence#}}"
|
||||
formInputFields={fields}
|
||||
values={{ evidence: attachmentValue }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders nothing for unsupported output field types', () => {
|
||||
const { container } = render(
|
||||
<SubmittedContentItem
|
||||
content="{{#$output.unknown#}}"
|
||||
formInputFields={[{
|
||||
type: 'unsupported',
|
||||
output_variable_name: 'unknown',
|
||||
} as unknown as FormInputItem]}
|
||||
values={{ unknown: 'value' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { FileResponse, HumanInputFormValue } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import SubmittedFieldValues from '../submitted-field-values'
|
||||
|
||||
const fields: FormInputItem[] = [
|
||||
{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'summary',
|
||||
default: { type: 'constant', value: '', selector: [] },
|
||||
},
|
||||
{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'decision',
|
||||
option_source: { type: 'constant', value: ['approve', 'reject'], selector: [] },
|
||||
},
|
||||
{
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'attachment',
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_upload_methods: [],
|
||||
},
|
||||
{
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'evidence',
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_upload_methods: [],
|
||||
number_limits: 5,
|
||||
},
|
||||
]
|
||||
|
||||
const attachmentValue: FileResponse = {
|
||||
related_id: 'file-1',
|
||||
upload_file_id: 'upload-1',
|
||||
filename: 'decision.pdf',
|
||||
extension: 'pdf',
|
||||
size: 128,
|
||||
mime_type: 'application/pdf',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/decision.pdf',
|
||||
remote_url: '',
|
||||
}
|
||||
|
||||
const evidenceValues: FileResponse[] = [
|
||||
{
|
||||
related_id: 'file-2',
|
||||
upload_file_id: 'upload-2',
|
||||
filename: 'evidence-1.png',
|
||||
extension: 'png',
|
||||
size: 256,
|
||||
mime_type: 'image/png',
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
type: 'image',
|
||||
url: 'https://example.com/evidence-1.png',
|
||||
remote_url: 'https://example.com/evidence-1.png',
|
||||
},
|
||||
{
|
||||
related_id: 'file-3',
|
||||
upload_file_id: 'upload-3',
|
||||
filename: 'evidence-2.pdf',
|
||||
extension: 'pdf',
|
||||
size: 512,
|
||||
mime_type: 'application/pdf',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/evidence-2.pdf',
|
||||
remote_url: '',
|
||||
},
|
||||
]
|
||||
|
||||
const values: Record<string, HumanInputFormValue> = {
|
||||
summary: 'Need more context',
|
||||
decision: 'approve',
|
||||
attachment: attachmentValue,
|
||||
evidence: evidenceValues,
|
||||
}
|
||||
|
||||
describe('SubmittedFieldValues', () => {
|
||||
it('renders text and select values as text', () => {
|
||||
render(<SubmittedFieldValues fields={fields} values={values} />)
|
||||
|
||||
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Need more context')
|
||||
expect(screen.getByTestId('submitted-field-decision')).toHaveTextContent('approve')
|
||||
})
|
||||
|
||||
it('renders file and file-list values as file lists', () => {
|
||||
render(<SubmittedFieldValues fields={fields} values={values} />)
|
||||
|
||||
expect(screen.getByTestId('submitted-field-attachment')).toHaveTextContent('decision.pdf')
|
||||
expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute('src', 'https://example.com/evidence-1.png')
|
||||
expect(screen.getByText('evidence-2.pdf')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('file-list')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('skips fields with missing values', () => {
|
||||
render(<SubmittedFieldValues fields={fields} values={{ summary: 'Only one field' }} />)
|
||||
|
||||
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Only one field')
|
||||
expect(screen.queryByTestId('submitted-field-decision')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('submitted-field-attachment')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('infers value types when field definitions are unavailable', () => {
|
||||
render(
|
||||
<SubmittedFieldValues
|
||||
values={{
|
||||
summary: 'Unstructured summary',
|
||||
attachment: attachmentValue,
|
||||
evidence: evidenceValues,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('submitted-field-summary')).toHaveTextContent('Unstructured summary')
|
||||
expect(screen.getByTestId('submitted-field-attachment')).toHaveTextContent('decision.pdf')
|
||||
expect(screen.getByRole('img', { name: 'Preview' })).toHaveAttribute('src', 'https://example.com/evidence-1.png')
|
||||
expect(screen.getByText('evidence-2.pdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips file fields when submitted values have incompatible shapes', () => {
|
||||
render(
|
||||
<SubmittedFieldValues
|
||||
fields={fields}
|
||||
values={{
|
||||
attachment: 'not-a-file',
|
||||
evidence: attachmentValue,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('submitted-field-attachment')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('submitted-field-evidence')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+97
@@ -1,6 +1,8 @@
|
||||
import type { HumanInputFilledFormData } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { SubmittedHumanInputContent } from '../submitted'
|
||||
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
@@ -28,4 +30,99 @@ describe('SubmittedHumanInputContent Integration', () => {
|
||||
// Trans component for triggered action. The mock usually renders the key.
|
||||
expect(screen.getByText('nodes.humanInput.userActions.triggered')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prefer structured form data over rendered markdown when available', () => {
|
||||
render(
|
||||
<SubmittedHumanInputContent formData={{
|
||||
...mockFormData,
|
||||
form_content: 'Decision: {{#$output.answer#}}',
|
||||
inputs: [{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'answer',
|
||||
default: { type: 'constant', value: '', selector: [] },
|
||||
}],
|
||||
submitted_data: {
|
||||
answer: 'approved',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('submitted-form-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('submitted-field-answer')).toHaveTextContent('approved')
|
||||
expect(screen.queryByTestId('submitted-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render submitted select and file fields with the original form layout', () => {
|
||||
render(
|
||||
<SubmittedHumanInputContent formData={{
|
||||
...mockFormData,
|
||||
form_content: '{{#$output.decision#}} {{#$output.attachment#}}',
|
||||
inputs: [
|
||||
{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'decision',
|
||||
option_source: { type: 'constant', value: ['approve', 'reject'], selector: [] },
|
||||
},
|
||||
{
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'attachment',
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_upload_methods: [],
|
||||
},
|
||||
],
|
||||
submitted_data: {
|
||||
decision: 'approve',
|
||||
attachment: {
|
||||
related_id: 'file-1',
|
||||
upload_file_id: 'upload-1',
|
||||
filename: 'decision.pdf',
|
||||
extension: 'pdf',
|
||||
size: 128,
|
||||
mime_type: 'application/pdf',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/decision.pdf',
|
||||
remote_url: '',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'decision' })).toBeDisabled()
|
||||
expect(screen.getByRole('combobox', { name: 'decision' })).toHaveTextContent('approve')
|
||||
expect(screen.getByTestId('submitted-field-attachment')).toHaveTextContent('decision.pdf')
|
||||
})
|
||||
|
||||
it('should fallback to rendered markdown when structured form data is empty', () => {
|
||||
render(
|
||||
<SubmittedHumanInputContent formData={{
|
||||
...mockFormData,
|
||||
submitted_data: {},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('submitted-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('mock-markdown')).toHaveTextContent('Rendered **Markdown** content')
|
||||
expect(screen.queryByTestId('submitted-field-values')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render submitted field values when original form layout is unavailable', () => {
|
||||
render(
|
||||
<SubmittedHumanInputContent formData={{
|
||||
...mockFormData,
|
||||
submitted_data: {
|
||||
answer: 'approved',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('submitted-field-values')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('submitted-field-answer')).toHaveTextContent('approved')
|
||||
expect(screen.queryByTestId('submitted-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+307
-27
@@ -1,29 +1,78 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Locale } from '@/i18n-config/language'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import {
|
||||
getButtonStyle,
|
||||
getFormContentInputNames,
|
||||
getProcessedHumanInputFormInputs,
|
||||
getRelativeTime,
|
||||
getRenderedFormInputs,
|
||||
hasInvalidRequiredHumanInput,
|
||||
hasInvalidSelectOrFileInput,
|
||||
initializeInputs,
|
||||
isRelativeTimeSameOrAfter,
|
||||
splitByOutputVar,
|
||||
} from '../utils'
|
||||
|
||||
const createInput = (overrides: Partial<FormInputItem>): FormInputItem => ({
|
||||
label: 'field',
|
||||
variable: 'field',
|
||||
required: false,
|
||||
max_length: 128,
|
||||
type: InputVarType.textInput,
|
||||
default: {
|
||||
type: 'constant' as const,
|
||||
value: '',
|
||||
selector: [], // Dummy selector
|
||||
},
|
||||
const paragraphInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.paragraph }>> = {}): FormInputItem => ({
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'field',
|
||||
default: {
|
||||
type: 'constant',
|
||||
value: '',
|
||||
selector: [],
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as FormInputItem)
|
||||
})
|
||||
|
||||
const selectInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.select }>> = {}): FormInputItem => ({
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'field',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
value: ['option-a', 'option-b'],
|
||||
selector: [],
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const fileInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.singleFile }>> = {}): FormInputItem => ({
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'field',
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const fileListInput = (overrides: Partial<Extract<FormInputItem, { type: InputVarType.multiFiles }>> = {}): FormInputItem => ({
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'field',
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
number_limits: 5,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const uploadedFile = {
|
||||
id: 'file-1',
|
||||
name: 'avatar.png',
|
||||
size: 128,
|
||||
type: 'image',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'image',
|
||||
uploadedId: 'upload-file-1',
|
||||
}
|
||||
|
||||
const uploadingFile = {
|
||||
...uploadedFile,
|
||||
uploadedId: undefined,
|
||||
progress: 50,
|
||||
}
|
||||
|
||||
describe('human-input utils', () => {
|
||||
describe('getButtonStyle', () => {
|
||||
@@ -53,16 +102,31 @@ describe('human-input utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('form content inputs', () => {
|
||||
it('should extract and filter input fields rendered in form content', () => {
|
||||
const formInputs: FormInputItem[] = [
|
||||
selectInput({ output_variable_name: 'visible_select' }),
|
||||
paragraphInput({ output_variable_name: 'visible_paragraph' }),
|
||||
fileInput({ output_variable_name: 'stale_file' }),
|
||||
]
|
||||
const content = 'Select {{#$output.visible_select#}} and write {{#$output.visible_paragraph#}}'
|
||||
|
||||
expect(getFormContentInputNames(content)).toEqual(['visible_select', 'visible_paragraph'])
|
||||
expect(getRenderedFormInputs(formInputs, content).map(input => input.output_variable_name)).toEqual([
|
||||
'visible_select',
|
||||
'visible_paragraph',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('initializeInputs', () => {
|
||||
it('should initialize text fields with constants and variable defaults', () => {
|
||||
const formInputs = [
|
||||
createInput({
|
||||
type: InputVarType.textInput,
|
||||
it('should initialize paragraph fields with constants and variable defaults', () => {
|
||||
const formInputs: FormInputItem[] = [
|
||||
paragraphInput({
|
||||
output_variable_name: 'name',
|
||||
default: { type: 'constant', value: 'John', selector: [] },
|
||||
}),
|
||||
createInput({
|
||||
type: InputVarType.paragraph,
|
||||
paragraphInput({
|
||||
output_variable_name: 'bio',
|
||||
default: { type: 'variable', value: '', selector: [] },
|
||||
}),
|
||||
@@ -74,23 +138,45 @@ describe('human-input utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should set non text-like inputs to undefined', () => {
|
||||
const formInputs = [
|
||||
createInput({
|
||||
type: InputVarType.select,
|
||||
it('should initialize select fields with empty strings', () => {
|
||||
const formInputs: FormInputItem[] = [
|
||||
selectInput({
|
||||
output_variable_name: 'role',
|
||||
}),
|
||||
]
|
||||
|
||||
expect(initializeInputs(formInputs)).toEqual({
|
||||
role: undefined,
|
||||
role: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should initialize single file fields with null', () => {
|
||||
const formInputs: FormInputItem[] = [
|
||||
fileInput({
|
||||
output_variable_name: 'avatar',
|
||||
}),
|
||||
]
|
||||
|
||||
expect(initializeInputs(formInputs)).toEqual({
|
||||
avatar: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('should initialize file list fields with empty arrays', () => {
|
||||
const formInputs: FormInputItem[] = [
|
||||
fileListInput({
|
||||
output_variable_name: 'attachments',
|
||||
}),
|
||||
]
|
||||
|
||||
expect(initializeInputs(formInputs)).toEqual({
|
||||
attachments: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should fallback to empty string when variable default is missing', () => {
|
||||
const formInputs = [
|
||||
createInput({
|
||||
type: InputVarType.textInput,
|
||||
const formInputs: FormInputItem[] = [
|
||||
paragraphInput({
|
||||
output_variable_name: 'summary',
|
||||
default: { type: 'variable', value: '', selector: [] },
|
||||
}),
|
||||
@@ -100,6 +186,200 @@ describe('human-input utils', () => {
|
||||
summary: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should fallback to default paragraph values when resolved defaults are not strings', () => {
|
||||
const formInputs: FormInputItem[] = [
|
||||
paragraphInput({
|
||||
output_variable_name: 'summary',
|
||||
default: { type: 'variable', value: '', selector: [] },
|
||||
}),
|
||||
]
|
||||
|
||||
expect(initializeInputs(formInputs, {
|
||||
summary: {
|
||||
related_id: 'file-1',
|
||||
size: 128,
|
||||
extension: '.pdf',
|
||||
filename: 'brief.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/brief.pdf',
|
||||
upload_file_id: 'upload-file-1',
|
||||
remote_url: '',
|
||||
},
|
||||
})).toEqual({
|
||||
summary: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore resolved defaults for non-paragraph fields', () => {
|
||||
const formInputs: FormInputItem[] = [
|
||||
selectInput({
|
||||
output_variable_name: 'role',
|
||||
}),
|
||||
fileInput({
|
||||
output_variable_name: 'avatar',
|
||||
}),
|
||||
fileListInput({
|
||||
output_variable_name: 'attachments',
|
||||
}),
|
||||
]
|
||||
|
||||
expect(initializeInputs(formInputs, {
|
||||
role: 'maintainer',
|
||||
avatar: {
|
||||
related_id: 'file-2',
|
||||
size: 64,
|
||||
extension: '.png',
|
||||
filename: 'avatar.png',
|
||||
mime_type: 'image/png',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'image',
|
||||
url: 'https://example.com/avatar.png',
|
||||
upload_file_id: 'upload-file-2',
|
||||
remote_url: '',
|
||||
},
|
||||
attachments: [{
|
||||
related_id: 'file-3',
|
||||
size: 16,
|
||||
extension: '.txt',
|
||||
filename: 'notes.txt',
|
||||
mime_type: 'text/plain',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/notes.txt',
|
||||
upload_file_id: 'upload-file-3',
|
||||
remote_url: '',
|
||||
}],
|
||||
})).toEqual({
|
||||
role: '',
|
||||
avatar: null,
|
||||
attachments: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore unsupported input types', () => {
|
||||
expect(initializeInputs([
|
||||
{
|
||||
type: 'unsupported',
|
||||
output_variable_name: 'unknown',
|
||||
} as unknown as FormInputItem,
|
||||
])).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('required input checks', () => {
|
||||
it('should ignore fields that are not present in values', () => {
|
||||
const formInputs: FormInputItem[] = [
|
||||
selectInput({ output_variable_name: 'visible_select' }),
|
||||
fileInput({ output_variable_name: 'stale_file' }),
|
||||
paragraphInput({ output_variable_name: 'stale_paragraph' }),
|
||||
]
|
||||
const values = {
|
||||
visible_select: 'approved',
|
||||
}
|
||||
|
||||
expect(hasInvalidSelectOrFileInput(formInputs, values)).toBe(false)
|
||||
expect(hasInvalidRequiredHumanInput(formInputs, values)).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect empty select values and unuploaded file values', () => {
|
||||
expect(hasInvalidSelectOrFileInput([
|
||||
selectInput({ output_variable_name: 'decision' }),
|
||||
], {
|
||||
decision: '',
|
||||
})).toBe(true)
|
||||
expect(hasInvalidRequiredHumanInput([
|
||||
paragraphInput({ output_variable_name: 'summary' }),
|
||||
], {
|
||||
summary: ' ',
|
||||
})).toBe(true)
|
||||
expect(hasInvalidSelectOrFileInput([
|
||||
fileInput({ output_variable_name: 'attachment' }),
|
||||
], {
|
||||
attachment: uploadingFile,
|
||||
})).toBe(true)
|
||||
expect(hasInvalidRequiredHumanInput([
|
||||
fileListInput({ output_variable_name: 'attachments' }),
|
||||
], {
|
||||
attachments: [uploadedFile, uploadingFile],
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept uploaded single and multiple file values', () => {
|
||||
expect(hasInvalidSelectOrFileInput([
|
||||
fileInput({ output_variable_name: 'attachment' }),
|
||||
fileListInput({ output_variable_name: 'attachments' }),
|
||||
], {
|
||||
attachment: [uploadedFile],
|
||||
attachments: [uploadedFile],
|
||||
})).toBe(false)
|
||||
expect(hasInvalidRequiredHumanInput([
|
||||
fileInput({ output_variable_name: 'attachment' }),
|
||||
fileListInput({ output_variable_name: 'attachments' }),
|
||||
], {
|
||||
attachment: [uploadedFile],
|
||||
attachments: [uploadedFile],
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('should treat unsupported input types as valid by default', () => {
|
||||
const unsupportedInput = {
|
||||
type: 'unsupported',
|
||||
output_variable_name: 'unknown',
|
||||
} as unknown as FormInputItem
|
||||
|
||||
expect(hasInvalidSelectOrFileInput([unsupportedInput], {
|
||||
unknown: 'value',
|
||||
})).toBe(false)
|
||||
expect(hasInvalidRequiredHumanInput([unsupportedInput], {
|
||||
unknown: 'value',
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProcessedHumanInputFormInputs', () => {
|
||||
it('should return undefined when no values are provided', () => {
|
||||
expect(getProcessedHumanInputFormInputs([], undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should process file values and fallback invalid file values', () => {
|
||||
expect(getProcessedHumanInputFormInputs([
|
||||
fileInput({ output_variable_name: 'attachment' }),
|
||||
fileInput({ output_variable_name: 'attachmentFromArray' }),
|
||||
fileInput({ output_variable_name: 'emptyAttachment' }),
|
||||
fileListInput({ output_variable_name: 'attachments' }),
|
||||
fileListInput({ output_variable_name: 'emptyAttachments' }),
|
||||
], {
|
||||
attachment: uploadedFile,
|
||||
attachmentFromArray: [uploadedFile],
|
||||
emptyAttachment: '',
|
||||
attachments: [uploadedFile],
|
||||
emptyAttachments: null,
|
||||
})).toEqual({
|
||||
attachment: {
|
||||
type: 'image',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: '',
|
||||
upload_file_id: 'upload-file-1',
|
||||
},
|
||||
attachmentFromArray: {
|
||||
type: 'image',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: '',
|
||||
upload_file_id: 'upload-file-1',
|
||||
},
|
||||
emptyAttachment: undefined,
|
||||
attachments: [{
|
||||
type: 'image',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: '',
|
||||
upload_file_id: 'upload-file-1',
|
||||
}],
|
||||
emptyAttachments: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('time helpers', () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ContentItemProps } from './type'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import HumanInputFieldRenderer from './field-renderer'
|
||||
|
||||
const ContentItem = ({
|
||||
content,
|
||||
@@ -40,15 +40,11 @@ const ContentItem = ({
|
||||
|
||||
return (
|
||||
<div className="py-3">
|
||||
{formInputField.type === 'paragraph' && (
|
||||
<Textarea
|
||||
aria-label={fieldName}
|
||||
className="h-[104px] sm:text-xs"
|
||||
value={inputs[fieldName]!}
|
||||
onValueChange={(value) => { onInputChange(fieldName, value) }}
|
||||
data-testid="content-item-textarea"
|
||||
/>
|
||||
)}
|
||||
<HumanInputFieldRenderer
|
||||
field={formInputField}
|
||||
value={inputs[fieldName]}
|
||||
onChange={value => onInputChange(fieldName, value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectTrigger,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import {
|
||||
isFileFormInput,
|
||||
isFileListFormInput,
|
||||
isParagraphFormInput,
|
||||
isSelectFormInput,
|
||||
} from '@/app/components/workflow/nodes/human-input/types'
|
||||
|
||||
export type HumanInputFieldValue = string | FileEntity | FileEntity[] | null
|
||||
|
||||
type Props = {
|
||||
field: FormInputItem
|
||||
value?: HumanInputFieldValue
|
||||
onChange: (value: HumanInputFieldValue) => void
|
||||
}
|
||||
|
||||
const HumanInputFieldRenderer = ({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
if (isParagraphFormInput(field)) {
|
||||
return (
|
||||
<Textarea
|
||||
aria-label={field.output_variable_name}
|
||||
className="h-[104px] sm:text-xs"
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onValueChange={nextValue => onChange(nextValue)}
|
||||
data-testid="content-item-textarea"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelectFormInput(field)) {
|
||||
const options = field.option_source.value.map(option => ({
|
||||
name: option,
|
||||
value: option,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onValueChange={(nextValue) => {
|
||||
if (nextValue == null)
|
||||
return
|
||||
onChange(nextValue)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="large" className="w-full" aria-label={field.output_variable_name}>
|
||||
{typeof value === 'string' ? value : ''}
|
||||
</SelectTrigger>
|
||||
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
|
||||
{options.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItemText>{option.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (isFileFormInput(field)) {
|
||||
const singleFileValue = value && !Array.isArray(value) && typeof value !== 'string'
|
||||
? [value]
|
||||
: []
|
||||
|
||||
return (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={singleFileValue}
|
||||
onChange={files => onChange(files[0] || null)}
|
||||
fileConfig={{
|
||||
allowed_file_types: field.allowed_file_types,
|
||||
allowed_file_extensions: field.allowed_file_extensions,
|
||||
allowed_file_upload_methods: field.allowed_file_upload_methods,
|
||||
number_limits: 1,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isFileListFormInput(field)) {
|
||||
return (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={files => onChange(files)}
|
||||
fileConfig={{
|
||||
allowed_file_types: field.allowed_file_types,
|
||||
allowed_file_extensions: field.allowed_file_extensions,
|
||||
allowed_file_upload_methods: field.allowed_file_upload_methods,
|
||||
number_limits: field.number_limits || 5,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default React.memo(HumanInputFieldRenderer)
|
||||
@@ -1,36 +1,43 @@
|
||||
'use client'
|
||||
import type { ButtonProps } from '@langgenius/dify-ui/button'
|
||||
import type { HumanInputFieldValue } from './field-renderer'
|
||||
import type { HumanInputFormProps } from './type'
|
||||
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import ContentItem from './content-item'
|
||||
import { getButtonStyle, initializeInputs, splitByOutputVar } from './utils'
|
||||
import { getButtonStyle, getProcessedHumanInputFormInputs, getRenderedFormInputs, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from './utils'
|
||||
|
||||
const HumanInputForm = ({
|
||||
formData,
|
||||
onSubmit,
|
||||
}: HumanInputFormProps) => {
|
||||
const formToken = formData.form_token
|
||||
const defaultInputs = initializeInputs(formData.inputs, formData.resolved_default_values || {})
|
||||
const contentList = splitByOutputVar(formData.form_content)
|
||||
const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content)
|
||||
const defaultInputs = initializeInputs(renderedFormInputs, formData.resolved_default_values || {})
|
||||
const [inputs, setInputs] = useState(defaultInputs)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleInputsChange = useCallback((name: string, value: string) => {
|
||||
const handleInputsChange = useCallback((name: string, value: HumanInputFieldValue) => {
|
||||
setInputs(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const submit = async (formToken: string, actionID: string, inputs: Record<string, string>) => {
|
||||
const submit = async (formToken: string, actionID: string, inputs: Record<string, HumanInputFieldValue>) => {
|
||||
setIsSubmitting(true)
|
||||
await onSubmit?.(formToken, { inputs, action: actionID })
|
||||
await onSubmit?.(formToken, {
|
||||
inputs: getProcessedHumanInputFormInputs(renderedFormInputs, inputs) || {},
|
||||
action: actionID,
|
||||
})
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const isActionDisabled = isSubmitting || hasInvalidSelectOrFileInput(renderedFormInputs, inputs)
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentList.map((content, index) => (
|
||||
@@ -46,7 +53,7 @@ const HumanInputForm = ({
|
||||
{formData.actions.map((action: UserAction) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
disabled={isSubmitting}
|
||||
disabled={isActionDisabled}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(formToken, action.id, inputs)}
|
||||
>
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFormValue } from '@/types/workflow'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectTrigger,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import * as React from 'react'
|
||||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import {
|
||||
isFileFormInput,
|
||||
isFileListFormInput,
|
||||
isParagraphFormInput,
|
||||
isSelectFormInput,
|
||||
} from '@/app/components/workflow/nodes/human-input/types'
|
||||
|
||||
type SubmittedContentItemProps = {
|
||||
content: string
|
||||
formInputFields: FormInputItem[]
|
||||
values: Record<string, HumanInputFormValue>
|
||||
}
|
||||
|
||||
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/
|
||||
|
||||
const isOutputField = (content: string) => outputVarRegex.test(content)
|
||||
|
||||
const extractFieldName = (content: string) => {
|
||||
const match = outputVarRegex.exec(content)
|
||||
return match ? match[1]! : ''
|
||||
}
|
||||
|
||||
const SubmittedContentItem = ({
|
||||
content,
|
||||
formInputFields,
|
||||
values,
|
||||
}: SubmittedContentItemProps) => {
|
||||
if (!isOutputField(content)) {
|
||||
return (
|
||||
<Markdown content={content} />
|
||||
)
|
||||
}
|
||||
|
||||
const fieldName = extractFieldName(content)
|
||||
const field = formInputFields.find(field => field.output_variable_name === fieldName)
|
||||
const value = values[fieldName]
|
||||
|
||||
if (!field || value == null)
|
||||
return null
|
||||
|
||||
if (isParagraphFormInput(field)) {
|
||||
return (
|
||||
<span
|
||||
className="body-md-regular break-words text-text-primary"
|
||||
data-testid={`submitted-field-${fieldName}`}
|
||||
>
|
||||
{typeof value === 'string' ? value : ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelectFormInput(field)) {
|
||||
const selectedValue = typeof value === 'string' ? value : ''
|
||||
|
||||
return (
|
||||
<div className="py-3" data-testid={`submitted-field-${fieldName}`}>
|
||||
<Select value={selectedValue} disabled>
|
||||
<SelectTrigger size="large" className="w-full" aria-label={field.output_variable_name} disabled>
|
||||
{selectedValue}
|
||||
</SelectTrigger>
|
||||
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
|
||||
{field.option_source.value.map(option => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<SelectItemText>{option}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isFileFormInput(field)) {
|
||||
if (typeof value === 'string' || Array.isArray(value))
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="py-3" data-testid={`submitted-field-${fieldName}`}>
|
||||
<FileList
|
||||
files={getProcessedFilesFromResponse([value])}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isFileListFormInput(field)) {
|
||||
if (typeof value === 'string' || !Array.isArray(value))
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="py-3" data-testid={`submitted-field-${fieldName}`}>
|
||||
<FileList
|
||||
files={getProcessedFilesFromResponse(value)}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default React.memo(SubmittedContentItem)
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFormValue } from '@/types/workflow'
|
||||
import * as React from 'react'
|
||||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
|
||||
import {
|
||||
isFileFormInput,
|
||||
isFileListFormInput,
|
||||
} from '@/app/components/workflow/nodes/human-input/types'
|
||||
|
||||
type SubmittedFieldValuesProps = {
|
||||
fields?: FormInputItem[]
|
||||
values: Record<string, HumanInputFormValue>
|
||||
}
|
||||
|
||||
const SubmittedFieldValues = ({
|
||||
fields,
|
||||
values,
|
||||
}: SubmittedFieldValuesProps) => {
|
||||
const fieldNames = fields?.map(field => field.output_variable_name) ?? Object.keys(values)
|
||||
const fieldMap = new Map(fields?.map(field => [field.output_variable_name, field]) ?? [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3" data-testid="submitted-field-values">
|
||||
{fieldNames.map((fieldName) => {
|
||||
const field = fieldMap.get(fieldName)
|
||||
const value = values[fieldName]
|
||||
|
||||
if (value == null)
|
||||
return null
|
||||
|
||||
let valueKind: 'text' | 'file' | 'file-list' = 'text'
|
||||
if (field && isFileFormInput(field))
|
||||
valueKind = 'file'
|
||||
else if (field && isFileListFormInput(field))
|
||||
valueKind = 'file-list'
|
||||
else if (typeof value === 'string')
|
||||
valueKind = 'text'
|
||||
else if (Array.isArray(value))
|
||||
valueKind = 'file-list'
|
||||
else
|
||||
valueKind = 'file'
|
||||
|
||||
if (valueKind === 'file') {
|
||||
if (typeof value === 'string' || Array.isArray(value))
|
||||
return null
|
||||
|
||||
return (
|
||||
<div key={fieldName} data-testid={`submitted-field-${fieldName}`}>
|
||||
<FileList
|
||||
files={getProcessedFilesFromResponse([value])}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (valueKind === 'file-list') {
|
||||
if (typeof value === 'string' || !Array.isArray(value))
|
||||
return null
|
||||
|
||||
return (
|
||||
<div key={fieldName} data-testid={`submitted-field-${fieldName}`}>
|
||||
<FileList
|
||||
files={getProcessedFilesFromResponse(value)}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fieldName}
|
||||
className="body-md-regular break-words text-text-primary"
|
||||
data-testid={`submitted-field-${fieldName}`}
|
||||
>
|
||||
{String(value)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SubmittedFieldValues)
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFormValue } from '@/types/workflow'
|
||||
import * as React from 'react'
|
||||
import SubmittedContentItem from './submitted-content-item'
|
||||
import { splitByOutputVar } from './utils'
|
||||
|
||||
type SubmittedFormContentProps = {
|
||||
formContent: string
|
||||
formInputFields: FormInputItem[]
|
||||
values: Record<string, HumanInputFormValue>
|
||||
}
|
||||
|
||||
const SubmittedFormContent = ({
|
||||
formContent,
|
||||
formInputFields,
|
||||
values,
|
||||
}: SubmittedFormContentProps) => {
|
||||
const contentList = splitByOutputVar(formContent)
|
||||
|
||||
return (
|
||||
<div data-testid="submitted-form-content">
|
||||
{contentList.map((content, index) => (
|
||||
<SubmittedContentItem
|
||||
key={index}
|
||||
content={content}
|
||||
formInputFields={formInputFields}
|
||||
values={values}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SubmittedFormContent)
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
|
||||
|
||||
export const enrichSubmittedHumanInputFormData = (
|
||||
filledFormData: HumanInputFilledFormData,
|
||||
requiredFormData?: Pick<HumanInputFormData, 'form_content' | 'inputs'>,
|
||||
): HumanInputFilledFormData => {
|
||||
if (!requiredFormData)
|
||||
return filledFormData
|
||||
|
||||
return {
|
||||
...filledFormData,
|
||||
form_content: requiredFormData.form_content,
|
||||
inputs: requiredFormData.inputs,
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import type { SubmittedHumanInputContentProps } from './type'
|
||||
import { useMemo } from 'react'
|
||||
import ExecutedAction from './executed-action'
|
||||
import SubmittedContent from './submitted-content'
|
||||
import SubmittedFieldValues from './submitted-field-values'
|
||||
import SubmittedFormContent from './submitted-form-content'
|
||||
|
||||
export const SubmittedHumanInputContent = ({
|
||||
formData,
|
||||
}: SubmittedHumanInputContentProps) => {
|
||||
const { rendered_content, action_id, action_text } = formData
|
||||
const { rendered_content, action_id, action_text, form_content, submitted_data, inputs } = formData
|
||||
|
||||
const executedAction = useMemo(() => {
|
||||
return {
|
||||
@@ -15,9 +17,21 @@ export const SubmittedHumanInputContent = ({
|
||||
}
|
||||
}, [action_id, action_text])
|
||||
|
||||
const content = form_content && inputs && submitted_data && Object.keys(submitted_data).length > 0
|
||||
? (
|
||||
<SubmittedFormContent
|
||||
formContent={form_content}
|
||||
formInputFields={inputs}
|
||||
values={submitted_data}
|
||||
/>
|
||||
)
|
||||
: submitted_data && Object.keys(submitted_data).length > 0
|
||||
? <SubmittedFieldValues values={submitted_data} />
|
||||
: <SubmittedContent content={rendered_content} />
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubmittedContent content={rendered_content} />
|
||||
{content}
|
||||
{/* Executed Action */}
|
||||
<ExecutedAction executedAction={executedAction} />
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HumanInputFieldValue } from './field-renderer'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
|
||||
|
||||
@@ -11,7 +12,7 @@ export type UnsubmittedHumanInputContentProps = {
|
||||
showEmailTip?: boolean
|
||||
isEmailDebugMode?: boolean
|
||||
showDebugModeTip?: boolean
|
||||
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
onSubmit?: (formToken: string, data: HumanInputFormSubmitData) => Promise<void>
|
||||
}
|
||||
|
||||
export type SubmittedHumanInputContentProps = {
|
||||
@@ -20,12 +21,17 @@ export type SubmittedHumanInputContentProps = {
|
||||
|
||||
export type HumanInputFormProps = {
|
||||
formData: HumanInputFormData
|
||||
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
onSubmit?: (formToken: string, data: HumanInputFormSubmitData) => Promise<void>
|
||||
}
|
||||
|
||||
export type HumanInputFormSubmitData = {
|
||||
inputs: Record<string, unknown>
|
||||
action: string
|
||||
}
|
||||
|
||||
export type ContentItemProps = {
|
||||
content: string
|
||||
formInputFields: FormInputItem[]
|
||||
inputs: Record<string, string>
|
||||
onInputChange: (name: string, value: string) => void
|
||||
inputs: Record<string, HumanInputFieldValue>
|
||||
onInputChange: (name: string, value: HumanInputFieldValue) => void
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { HumanInputFieldValue } from './field-renderer'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import type { HumanInputResolvedValue } from '@/types/workflow'
|
||||
import dayjs from 'dayjs'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { fileIsUploaded, getProcessedFiles } from '@/app/components/base/file-uploader/utils'
|
||||
import {
|
||||
isFileFormInput,
|
||||
isFileListFormInput,
|
||||
isParagraphFormInput,
|
||||
isSelectFormInput,
|
||||
UserActionButtonType,
|
||||
} from '@/app/components/workflow/nodes/human-input/types'
|
||||
import 'dayjs/locale/en'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'dayjs/locale/ja'
|
||||
@@ -31,17 +41,140 @@ export const splitByOutputVar = (content: string): string[] => {
|
||||
return parts.filter(part => part.length > 0)
|
||||
}
|
||||
|
||||
export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, string> = {}) => {
|
||||
const initialInputs: Record<string, any> = {}
|
||||
export const getFormContentInputNames = (content: string) => {
|
||||
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/g
|
||||
return [...content.matchAll(outputVarRegex)].map(match => match[1]!)
|
||||
}
|
||||
|
||||
export const getRenderedFormInputs = (formInputs: FormInputItem[], content: string) => {
|
||||
const inputNames = new Set(getFormContentInputNames(content))
|
||||
return formInputs.filter(input => inputNames.has(input.output_variable_name))
|
||||
}
|
||||
|
||||
export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, HumanInputResolvedValue> = {}) => {
|
||||
const initialInputs: Record<string, HumanInputFieldValue> = {}
|
||||
formInputs.forEach((item) => {
|
||||
if (item.type === 'text-input' || item.type === 'paragraph')
|
||||
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? defaultValues[item.output_variable_name] || '' : item.default.value
|
||||
else
|
||||
initialInputs[item.output_variable_name] = undefined
|
||||
if (isParagraphFormInput(item)) {
|
||||
const resolvedValue = defaultValues[item.output_variable_name]
|
||||
initialInputs[item.output_variable_name] = item.default.type === 'variable' && typeof resolvedValue === 'string'
|
||||
? resolvedValue
|
||||
: item.default.value
|
||||
return
|
||||
}
|
||||
|
||||
if (isSelectFormInput(item)) {
|
||||
initialInputs[item.output_variable_name] = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (isFileFormInput(item)) {
|
||||
initialInputs[item.output_variable_name] = null
|
||||
return
|
||||
}
|
||||
|
||||
if (isFileListFormInput(item)) {
|
||||
initialInputs[item.output_variable_name] = []
|
||||
}
|
||||
})
|
||||
return initialInputs
|
||||
}
|
||||
|
||||
const isHumanInputFileUploaded = (value: HumanInputFieldValue | undefined) => {
|
||||
return !!value
|
||||
&& !Array.isArray(value)
|
||||
&& typeof value !== 'string'
|
||||
&& !!fileIsUploaded(value as FileEntity)
|
||||
}
|
||||
|
||||
const hasUploadedHumanInputFiles = (value: HumanInputFieldValue | undefined) => {
|
||||
return Array.isArray(value)
|
||||
&& value.length > 0
|
||||
&& value.every(file => !!fileIsUploaded(file))
|
||||
}
|
||||
|
||||
export const hasInvalidSelectOrFileInput = (
|
||||
formInputs: FormInputItem[],
|
||||
values: Record<string, HumanInputFieldValue>,
|
||||
) => {
|
||||
return formInputs.some((input) => {
|
||||
if (!(input.output_variable_name in values))
|
||||
return false
|
||||
|
||||
const value = values[input.output_variable_name]
|
||||
|
||||
if (isSelectFormInput(input))
|
||||
return typeof value !== 'string' || value.length === 0
|
||||
|
||||
if (isFileFormInput(input))
|
||||
return Array.isArray(value) ? !hasUploadedHumanInputFiles(value) : !isHumanInputFileUploaded(value)
|
||||
|
||||
if (isFileListFormInput(input))
|
||||
return !hasUploadedHumanInputFiles(value)
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export const hasInvalidRequiredHumanInput = (
|
||||
formInputs: FormInputItem[],
|
||||
values: Record<string, HumanInputFieldValue>,
|
||||
) => {
|
||||
return formInputs.some((input) => {
|
||||
if (!(input.output_variable_name in values))
|
||||
return false
|
||||
|
||||
const value = values[input.output_variable_name]
|
||||
|
||||
if (isParagraphFormInput(input))
|
||||
return typeof value !== 'string' || value.trim().length === 0
|
||||
|
||||
if (isSelectFormInput(input))
|
||||
return typeof value !== 'string' || value.length === 0
|
||||
|
||||
if (isFileFormInput(input))
|
||||
return Array.isArray(value) ? !hasUploadedHumanInputFiles(value) : !isHumanInputFileUploaded(value)
|
||||
|
||||
if (isFileListFormInput(input))
|
||||
return !hasUploadedHumanInputFiles(value)
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export const getProcessedHumanInputFormInputs = (
|
||||
formInputs: FormInputItem[],
|
||||
values: Record<string, HumanInputFieldValue> | undefined,
|
||||
) => {
|
||||
if (!values)
|
||||
return undefined
|
||||
|
||||
const processedInputs: Record<string, unknown> = { ...values }
|
||||
|
||||
formInputs.forEach((input) => {
|
||||
const value = values[input.output_variable_name]
|
||||
|
||||
if (isFileListFormInput(input)) {
|
||||
processedInputs[input.output_variable_name] = Array.isArray(value)
|
||||
? getProcessedFiles(value)
|
||||
: []
|
||||
return
|
||||
}
|
||||
|
||||
if (isFileFormInput(input)) {
|
||||
if (Array.isArray(value)) {
|
||||
processedInputs[input.output_variable_name] = getProcessedFiles(value)[0]
|
||||
return
|
||||
}
|
||||
|
||||
processedInputs[input.output_variable_name] = value && typeof value !== 'string'
|
||||
? getProcessedFiles([value as FileEntity])[0]
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
|
||||
return processedInputs
|
||||
}
|
||||
|
||||
const localeMap: Record<string, string> = {
|
||||
'en-US': 'en',
|
||||
'zh-Hans': 'zh-cn',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HumanInputFormSubmitData } from './human-input-content/type'
|
||||
import type { DeliveryMethod, HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
@@ -8,7 +9,7 @@ import { UnsubmittedHumanInputContent } from './human-input-content/unsubmitted'
|
||||
|
||||
type HumanInputFormListProps = {
|
||||
humanInputFormDataList: HumanInputFormData[]
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
|
||||
getHumanInputNodeData?: (nodeID: string) => Node<HumanInputNodeType> | undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
} from '../../types'
|
||||
import type { HumanInputFormSubmitData } from './human-input-content/type'
|
||||
import type { AppData } from '@/models/share'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
@@ -40,7 +41,7 @@ type AnswerProps = {
|
||||
noChatInput?: boolean
|
||||
switchSibling?: (siblingMessageId: string) => void
|
||||
hideAvatar?: boolean
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
|
||||
}
|
||||
const Answer: FC<AnswerProps> = ({
|
||||
item,
|
||||
|
||||
@@ -41,6 +41,8 @@ type FeedbackTooltipProps = {
|
||||
}
|
||||
|
||||
const feedbackTooltipClassName = 'max-w-[260px]'
|
||||
const answerActiveFlexClassName = 'group-hover:flex group-has-[[data-popup-open]]:flex'
|
||||
const answerActiveBlockClassName = 'group-hover:block group-has-[[data-popup-open]]:block'
|
||||
|
||||
const FeedbackTooltip = ({ content, children }: FeedbackTooltipProps) => {
|
||||
return (
|
||||
@@ -199,7 +201,7 @@ function Operation({
|
||||
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs',
|
||||
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
|
||||
hasUserFeedback ? 'flex' : `hidden ${answerActiveFlexClassName}`,
|
||||
)}
|
||||
>
|
||||
{hasUserFeedback
|
||||
@@ -241,7 +243,7 @@ function Operation({
|
||||
{shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs',
|
||||
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
|
||||
(hasAdminFeedback || hasUserFeedback) ? 'flex' : `hidden ${answerActiveFlexClassName}`,
|
||||
)}
|
||||
>
|
||||
{displayUserFeedback?.rating && (
|
||||
@@ -308,12 +310,12 @@ function Operation({
|
||||
</div>
|
||||
)}
|
||||
{showPromptLog && !isOpeningStatement && (
|
||||
<div className="hidden group-hover:block">
|
||||
<div className={cn('hidden', answerActiveBlockClassName)}>
|
||||
<Log logItem={item} />
|
||||
</div>
|
||||
)}
|
||||
{!isOpeningStatement && (
|
||||
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs group-hover:flex" data-testid="operation-actions">
|
||||
<div className={cn('ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs', answerActiveFlexClassName)} data-testid="operation-actions">
|
||||
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
|
||||
<NewAudioButton
|
||||
id={id}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { v4 as uuidV4 } from 'uuid'
|
||||
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
|
||||
import { enrichSubmittedHumanInputFormData } from '@/app/components/base/chat/chat/answer/human-input-content/submitted-utils'
|
||||
import {
|
||||
getProcessedFiles,
|
||||
getProcessedFilesFromResponse,
|
||||
@@ -538,16 +539,20 @@ export const useChat = (
|
||||
},
|
||||
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined
|
||||
if (responseItem.humanInputFormDataList?.length) {
|
||||
const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
|
||||
if (currentFormIndex > -1)
|
||||
if (currentFormIndex > -1) {
|
||||
requiredFormData = responseItem.humanInputFormDataList[currentFormIndex]
|
||||
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
|
||||
}
|
||||
}
|
||||
const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData(humanInputFilledFormData, requiredFormData)
|
||||
if (!responseItem.humanInputFilledFormDataList) {
|
||||
responseItem.humanInputFilledFormDataList = [humanInputFilledFormData]
|
||||
responseItem.humanInputFilledFormDataList = [enrichedHumanInputFilledFormData]
|
||||
}
|
||||
else {
|
||||
responseItem.humanInputFilledFormDataList.push(humanInputFilledFormData)
|
||||
responseItem.humanInputFilledFormDataList.push(enrichedHumanInputFilledFormData)
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -1108,15 +1113,20 @@ export const useChat = (
|
||||
}
|
||||
},
|
||||
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
|
||||
let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined
|
||||
if (responseItem.humanInputFormDataList?.length) {
|
||||
const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
|
||||
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
|
||||
if (currentFormIndex > -1) {
|
||||
requiredFormData = responseItem.humanInputFormDataList[currentFormIndex]
|
||||
responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
|
||||
}
|
||||
}
|
||||
const enrichedHumanInputFilledFormData = enrichSubmittedHumanInputFormData(humanInputFilledFormData, requiredFormData)
|
||||
if (!responseItem.humanInputFilledFormDataList) {
|
||||
responseItem.humanInputFilledFormDataList = [humanInputFilledFormData]
|
||||
responseItem.humanInputFilledFormDataList = [enrichedHumanInputFilledFormData]
|
||||
}
|
||||
else {
|
||||
responseItem.humanInputFilledFormDataList.push(humanInputFilledFormData)
|
||||
responseItem.humanInputFilledFormDataList.push(enrichedHumanInputFilledFormData)
|
||||
}
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
OnRegenerate,
|
||||
OnSend,
|
||||
} from '../types'
|
||||
import type { HumanInputFormSubmitData } from './answer/human-input-content/type'
|
||||
import type { InputForm } from './type'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import type { AppData } from '@/models/share'
|
||||
@@ -69,7 +70,7 @@ export type ChatProps = {
|
||||
sidebarCollapseState?: boolean
|
||||
hideAvatar?: boolean
|
||||
sendOnEnter?: boolean
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
|
||||
getHumanInputNodeData?: (nodeID: string) => any
|
||||
}
|
||||
|
||||
|
||||
@@ -66,18 +66,22 @@ export type CitationItem = {
|
||||
word_count: number
|
||||
}
|
||||
|
||||
export type ExtraContent
|
||||
= {
|
||||
type: 'human_input'
|
||||
submitted: false
|
||||
form_definition: HumanInputFormData
|
||||
workflow_run_id: string
|
||||
}
|
||||
| {
|
||||
type: 'human_input'
|
||||
submitted: true
|
||||
form_submission_data: HumanInputFilledFormData
|
||||
}
|
||||
type PendingHumanInputExtraContent = {
|
||||
type: 'human_input'
|
||||
submitted: false
|
||||
form_definition: HumanInputFormData
|
||||
workflow_run_id: string
|
||||
}
|
||||
|
||||
type SubmittedHumanInputExtraContent = {
|
||||
type: 'human_input'
|
||||
submitted: true
|
||||
form_definition?: HumanInputFormData
|
||||
form_submission_data: HumanInputFilledFormData
|
||||
workflow_run_id?: string
|
||||
}
|
||||
|
||||
export type ExtraContent = PendingHumanInputExtraContent | SubmittedHumanInputExtraContent
|
||||
|
||||
export type IChatItem = {
|
||||
id: string
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { HumanInputFieldValue } from '../../chat/answer/human-input-content/field-renderer'
|
||||
import type { ChatConfig, ChatItem, ChatItemInTree } from '../../types'
|
||||
import type { EmbeddedChatbotContextValue } from '../context'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
@@ -59,7 +60,7 @@ vi.mock('../../chat', () => ({
|
||||
onSend: (message: string) => void
|
||||
onRegenerate: (chatItem: ChatItem, editedQuestion?: { message: string, files?: never[] }) => void
|
||||
switchSibling: (siblingMessageId: string) => void
|
||||
onHumanInputFormSubmit: (formToken: string, formData: Record<string, string>) => Promise<void>
|
||||
onHumanInputFormSubmit: (formToken: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
|
||||
onStopResponding: () => void
|
||||
}) => (
|
||||
<div>
|
||||
@@ -78,7 +79,7 @@ vi.mock('../../chat', () => ({
|
||||
<button onClick={() => switchSibling('sibling-2')}>switch sibling</button>
|
||||
<button disabled={inputDisabled}>send message</button>
|
||||
<button onClick={onStopResponding}>stop responding</button>
|
||||
<button onClick={() => onHumanInputFormSubmit('form-token', { answer: 'ok' })}>submit human input</button>
|
||||
<button onClick={() => onHumanInputFormSubmit('form-token', { inputs: { answer: 'ok' }, action: 'approve' })}>submit human input</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@@ -351,7 +352,7 @@ describe('EmbeddedChatbot chat-wrapper', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitHumanInputFormService).toHaveBeenCalledWith('form-token', { answer: 'ok' })
|
||||
expect(submitHumanInputFormService).toHaveBeenCalledWith('form-token', { inputs: { answer: 'ok' }, action: 'approve' })
|
||||
})
|
||||
expect(submitHumanInputForm).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -391,7 +392,7 @@ describe('EmbeddedChatbot chat-wrapper', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitHumanInputForm).toHaveBeenCalledWith('form-token', { answer: 'ok' })
|
||||
expect(submitHumanInputForm).toHaveBeenCalledWith('form-token', { inputs: { answer: 'ok' }, action: 'approve' })
|
||||
})
|
||||
expect(handleSend).toHaveBeenCalledTimes(2)
|
||||
const sendOptions = handleSend.mock.calls[0]?.[2] as { onGetSuggestedQuestions: (responseItemId: string) => void }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FileEntity } from '../../file-uploader/types'
|
||||
import type { HumanInputFormSubmitData } from '../chat/answer/human-input-content/type'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
@@ -232,7 +233,7 @@ const ChatWrapper = () => {
|
||||
}
|
||||
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
|
||||
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: HumanInputFormSubmitData) => {
|
||||
if (isInstalledApp)
|
||||
await submitHumanInputFormService(formToken, formData)
|
||||
else
|
||||
|
||||
@@ -5,9 +5,14 @@ import { act, renderHook } from '@testing-library/react'
|
||||
import { useFile, useFileSizeLimit } from '../hooks'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockNavigationState = vi.hoisted(() => ({
|
||||
params: {} as { token?: string },
|
||||
pathname: '/chat',
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({ token: undefined }),
|
||||
useParams: () => mockNavigationState.params,
|
||||
usePathname: () => mockNavigationState.pathname,
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
@@ -42,6 +47,13 @@ vi.mock('@/service/common', () => ({
|
||||
uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args),
|
||||
}))
|
||||
|
||||
const mockUploadHumanInputFormLocalFile = vi.fn()
|
||||
const mockUploadHumanInputFormRemoteFileInfo = vi.fn()
|
||||
vi.mock('@/service/share', () => ({
|
||||
uploadHumanInputFormLocalFile: (...args: unknown[]) => mockUploadHumanInputFormLocalFile(...args),
|
||||
uploadHumanInputFormRemoteFileInfo: (...args: unknown[]) => mockUploadHumanInputFormRemoteFileInfo(...args),
|
||||
}))
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'mock-uuid',
|
||||
}))
|
||||
@@ -109,6 +121,8 @@ describe('useFile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStoreFiles = []
|
||||
mockNavigationState.params = {}
|
||||
mockNavigationState.pathname = '/chat'
|
||||
mockIsAllowedFileExtension.mockReturnValue(true)
|
||||
mockGetSupportFileType.mockReturnValue('document')
|
||||
})
|
||||
@@ -201,6 +215,31 @@ describe('useFile', () => {
|
||||
expect(mockFileUpload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use human input form local upload when re-uploading on form page', () => {
|
||||
mockNavigationState.params = { token: 'form-token' }
|
||||
mockNavigationState.pathname = '/form/form-token'
|
||||
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
mockStoreFiles = [{
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
progress: -1,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
originalFile,
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleReUploadFile('file-1')
|
||||
|
||||
expect(mockUploadHumanInputFormLocalFile).toHaveBeenCalledWith(expect.objectContaining({
|
||||
formToken: 'form-token',
|
||||
file: originalFile,
|
||||
}))
|
||||
expect(mockFileUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not re-upload when file id is not found', () => {
|
||||
mockStoreFiles = []
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
@@ -315,6 +354,24 @@ describe('useFile', () => {
|
||||
expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/file.txt', false)
|
||||
})
|
||||
|
||||
it('should use human input form remote upload on form page', () => {
|
||||
mockNavigationState.params = { token: 'form-token' }
|
||||
mockNavigationState.pathname = '/form/form-token'
|
||||
mockUploadHumanInputFormRemoteFileInfo.mockResolvedValue({
|
||||
id: 'remote-1',
|
||||
mime_type: 'text/plain',
|
||||
size: 100,
|
||||
name: 'remote.txt',
|
||||
url: 'https://example.com/remote.txt',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
|
||||
expect(mockUploadHumanInputFormRemoteFileInfo).toHaveBeenCalledWith('form-token', 'https://example.com/file.txt')
|
||||
expect(mockUploadRemoteFileInfo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove file when extension is not allowed', async () => {
|
||||
mockIsAllowedFileExtension.mockReturnValue(false)
|
||||
mockUploadRemoteFileInfo.mockResolvedValue({
|
||||
@@ -702,6 +759,21 @@ describe('useFile', () => {
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use human input form local upload on form page', () => {
|
||||
mockNavigationState.params = { token: 'form-token' }
|
||||
mockNavigationState.pathname = '/form/form-token'
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockUploadHumanInputFormLocalFile).toHaveBeenCalledWith(expect.objectContaining({
|
||||
formToken: 'form-token',
|
||||
file,
|
||||
}))
|
||||
expect(mockFileUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle fileUpload error callback', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
|
||||
+23
@@ -164,6 +164,29 @@ describe('FileUploaderInAttachmentWrapper', () => {
|
||||
expect(linkButton.closest('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep upload button layout stable when remote_url popup opens', () => {
|
||||
render(
|
||||
<FileUploaderInAttachmentWrapper
|
||||
onChange={vi.fn()}
|
||||
fileConfig={createFileConfig()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const localButton = screen.getByText(/fileUploader\.uploadFromComputer/).closest('button')
|
||||
const linkButton = screen.getByText(/fileUploader\.pasteFileLink/).closest('button')
|
||||
const uploadGroup = localButton?.parentElement?.parentElement
|
||||
|
||||
expect(uploadGroup?.children).toHaveLength(2)
|
||||
|
||||
fireEvent.click(linkButton!)
|
||||
|
||||
expect(uploadGroup?.children).toHaveLength(2)
|
||||
expect(linkButton).toHaveAttribute('data-popup-open')
|
||||
expect(localButton?.parentElement).toHaveClass('min-w-0', 'flex-1')
|
||||
expect(linkButton?.parentElement).toHaveClass('min-w-0', 'flex-1')
|
||||
expect(linkButton).toHaveClass('w-full', 'min-w-0')
|
||||
})
|
||||
|
||||
it('should disable upload buttons when file limit is reached', () => {
|
||||
const files = [
|
||||
createFile({ id: 'f1' }),
|
||||
|
||||
@@ -55,13 +55,12 @@ const FileUploaderInAttachment = ({
|
||||
const renderButton = useCallback((option: Option, open?: boolean) => {
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant="tertiary"
|
||||
className={cn('relative grow', open && 'bg-components-button-tertiary-bg-hover')}
|
||||
className={cn('relative w-full min-w-0', open && 'bg-components-button-tertiary-bg-hover')}
|
||||
disabled={!!(fileConfig.number_limits && files.length >= fileConfig.number_limits)}
|
||||
>
|
||||
{option.icon}
|
||||
<span className="ml-1">{option.label}</span>
|
||||
<span className="shrink-0">{option.icon}</span>
|
||||
<span className="ml-1 min-w-0 truncate">{option.label}</span>
|
||||
{
|
||||
option.value === TransferMethod.local_file && (
|
||||
<FileInput fileConfig={fileConfig} />
|
||||
@@ -74,17 +73,23 @@ const FileUploaderInAttachment = ({
|
||||
return (open: boolean) => renderButton(option, open)
|
||||
}, [renderButton])
|
||||
const renderOption = useCallback((option: Option) => {
|
||||
if (option.value === TransferMethod.local_file && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file))
|
||||
return renderButton(option)
|
||||
if (option.value === TransferMethod.local_file && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.local_file)) {
|
||||
return (
|
||||
<div key={option.value} className="min-w-0 flex-1">
|
||||
{renderButton(option)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (option.value === TransferMethod.remote_url && fileConfig?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)) {
|
||||
return (
|
||||
<FileFromLinkOrLocal
|
||||
key={option.value}
|
||||
showFromLocal={false}
|
||||
trigger={renderTrigger(option)}
|
||||
fileConfig={fileConfig}
|
||||
/>
|
||||
<div key={option.value} className="min-w-0 flex-1">
|
||||
<FileFromLinkOrLocal
|
||||
showFromLocal={false}
|
||||
trigger={renderTrigger(option)}
|
||||
fileConfig={fileConfig}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}, [renderButton, renderTrigger, fileConfig])
|
||||
@@ -92,7 +97,7 @@ const FileUploaderInAttachment = ({
|
||||
return (
|
||||
<div>
|
||||
{!isDisabled && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="flex items-center gap-1">
|
||||
{options.map(renderOption)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -19,8 +19,12 @@ import {
|
||||
VIDEO_SIZE_LIMIT,
|
||||
} from '@/app/components/base/file-uploader/constants'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { useParams } from '@/next/navigation'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import { uploadRemoteFileInfo } from '@/service/common'
|
||||
import {
|
||||
uploadHumanInputFormLocalFile,
|
||||
uploadHumanInputFormRemoteFileInfo,
|
||||
} from '@/service/share'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import { useFileStore } from './store'
|
||||
@@ -51,7 +55,10 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
const { t } = useTranslation()
|
||||
const fileStore = useFileStore()
|
||||
const params = useParams()
|
||||
const pathname = usePathname()
|
||||
const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = useFileSizeLimit(fileConfig.fileUploadConfig)
|
||||
const formToken = typeof params.token === 'string' ? params.token : undefined
|
||||
const isHumanInputFormPage = !!formToken && /(?:^|\/)form\/[^/]+$/.test(pathname)
|
||||
|
||||
const checkSizeLimit = useCallback((fileType: string, fileSize: number) => {
|
||||
switch (fileType) {
|
||||
@@ -156,7 +163,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
draft[index]!.progress = 0
|
||||
})
|
||||
setFiles(newFiles)
|
||||
fileUpload({
|
||||
const uploadParams: Parameters<typeof fileUpload>[0] = {
|
||||
file: uploadingFile!.originalFile!,
|
||||
onProgressCallback: (progress) => {
|
||||
handleUpdateFile({ ...uploadingFile, progress })
|
||||
@@ -164,14 +171,24 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
onSuccessCallback: (res) => {
|
||||
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
|
||||
},
|
||||
onErrorCallback: (error?: any) => {
|
||||
onErrorCallback: (error?: unknown) => {
|
||||
const errorMessage = getFileUploadErrorMessage(error, t('fileUploader.uploadFromComputerUploadError', { ns: 'common' }), t)
|
||||
toast.error(errorMessage)
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
}, !!params.token)
|
||||
}
|
||||
|
||||
if (isHumanInputFormPage) {
|
||||
uploadHumanInputFormLocalFile({
|
||||
formToken: formToken!,
|
||||
...uploadParams,
|
||||
})
|
||||
}
|
||||
else {
|
||||
fileUpload(uploadParams, !!params.token)
|
||||
}
|
||||
}
|
||||
}, [fileStore, t, handleUpdateFile, params])
|
||||
}, [fileStore, t, handleUpdateFile, isHumanInputFormPage, formToken, params.token])
|
||||
|
||||
const startProgressTimer = useCallback((fileId: string) => {
|
||||
const timer = setInterval(() => {
|
||||
@@ -201,7 +218,11 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
handleAddFile(uploadingFile)
|
||||
startProgressTimer(uploadingFile.id)
|
||||
|
||||
uploadRemoteFileInfo(url, !!params.token).then((res) => {
|
||||
const remoteUpload = isHumanInputFormPage
|
||||
? uploadHumanInputFormRemoteFileInfo(formToken!, url)
|
||||
: uploadRemoteFileInfo(url, !!params.token)
|
||||
|
||||
remoteUpload.then((res) => {
|
||||
const newFile = {
|
||||
...uploadingFile,
|
||||
type: res.mime_type,
|
||||
@@ -223,7 +244,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
toast.error(t('fileUploader.pasteFileLinkInvalid', { ns: 'common' }))
|
||||
handleRemoveFile(uploadingFile.id)
|
||||
})
|
||||
}, [checkSizeLimit, handleAddFile, handleUpdateFile, t, handleRemoveFile, fileConfig?.allowed_file_types, fileConfig.allowed_file_extensions, startProgressTimer, params.token])
|
||||
}, [checkSizeLimit, handleAddFile, handleUpdateFile, t, handleRemoveFile, fileConfig?.allowed_file_types, fileConfig.allowed_file_extensions, startProgressTimer, isHumanInputFormPage, formToken, params.token])
|
||||
|
||||
const handleLoadFileFromLinkSuccess = useCallback(noop, [])
|
||||
|
||||
@@ -269,7 +290,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
base64Url: isImage ? reader.result as string : '',
|
||||
}
|
||||
handleAddFile(uploadingFile)
|
||||
fileUpload({
|
||||
const uploadParams: Parameters<typeof fileUpload>[0] = {
|
||||
file: uploadingFile.originalFile,
|
||||
onProgressCallback: (progress) => {
|
||||
handleUpdateFile({ ...uploadingFile, progress })
|
||||
@@ -277,12 +298,22 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
onSuccessCallback: (res) => {
|
||||
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
|
||||
},
|
||||
onErrorCallback: (error?: any) => {
|
||||
const errorMessage = getFileUploadErrorMessage(error, t('fileUploader.uploadFromComputerUploadError', { ns: 'common' }), t as any)
|
||||
onErrorCallback: (error?: unknown) => {
|
||||
const errorMessage = getFileUploadErrorMessage(error, t('fileUploader.uploadFromComputerUploadError', { ns: 'common' }), t)
|
||||
toast.error(errorMessage)
|
||||
handleUpdateFile({ ...uploadingFile, progress: -1 })
|
||||
},
|
||||
}, !!params.token)
|
||||
}
|
||||
|
||||
if (isHumanInputFormPage) {
|
||||
uploadHumanInputFormLocalFile({
|
||||
formToken: formToken!,
|
||||
...uploadParams,
|
||||
})
|
||||
}
|
||||
else {
|
||||
fileUpload(uploadParams, !!params.token)
|
||||
}
|
||||
},
|
||||
false,
|
||||
)
|
||||
@@ -294,7 +325,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
false,
|
||||
)
|
||||
reader.readAsDataURL(file)
|
||||
}, [noNeedToCheckEnable, checkSizeLimit, t, handleAddFile, handleUpdateFile, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions, fileConfig?.enabled])
|
||||
}, [noNeedToCheckEnable, checkSizeLimit, t, handleAddFile, handleUpdateFile, isHumanInputFormPage, formToken, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions, fileConfig?.enabled])
|
||||
|
||||
const handleClipboardPasteFile = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const file = e.clipboardData?.files[0]
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock('../../..', () => ({
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({ token: 'test-token' }),
|
||||
usePathname: () => '/',
|
||||
}))
|
||||
|
||||
describe('FileUploaderField', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BaseFieldType } from '../types'
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({}),
|
||||
usePathname: () => '/',
|
||||
}))
|
||||
|
||||
const createConfig = (overrides: Partial<BaseConfiguration> = {}): BaseConfiguration => ({
|
||||
|
||||
@@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => {
|
||||
})),
|
||||
parseEditorState: vi.fn(() => ({ state: 'parsed' })),
|
||||
setEditorState: vi.fn(),
|
||||
setEditable: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
update: vi.fn((fn: () => void) => fn()),
|
||||
},
|
||||
@@ -71,6 +72,7 @@ vi.mock('lexical', async (importOriginal) => {
|
||||
})),
|
||||
getAllTextNodes: () => [],
|
||||
}),
|
||||
$nodesOfType: () => [],
|
||||
TextNode: class TextNode {
|
||||
__text: string
|
||||
constructor(text = '') {
|
||||
@@ -92,9 +94,8 @@ vi.mock('@lexical/react/LexicalComposer', () => ({
|
||||
try {
|
||||
initialConfig.onError(new Error('test error'))
|
||||
}
|
||||
catch (e) {
|
||||
// ignore error
|
||||
console.error(e)
|
||||
catch {
|
||||
// Ignore the intentional throw from the mocked error boundary path.
|
||||
}
|
||||
}
|
||||
if (initialConfig?.nodes) {
|
||||
@@ -328,6 +329,20 @@ describe('PromptEditor', () => {
|
||||
expect(screen.getByTestId('lexical-composer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should sync editable changes to the lexical editor instance', async () => {
|
||||
const { rerender } = render(<PromptEditor editable={true} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.editor.setEditable).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
rerender(<PromptEditor editable={false} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.editor.setEditable).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render with isSupportFileVar=true', () => {
|
||||
render(<PromptEditor isSupportFileVar={true} />)
|
||||
expect(screen.getByTestId('lexical-composer')).toBeInTheDocument()
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import type { InitialConfigType } from '@lexical/react/LexicalComposer'
|
||||
import type {
|
||||
EditorState,
|
||||
LexicalCommand,
|
||||
} from 'lexical'
|
||||
import type { FC } from 'react'
|
||||
import type { Hotkey } from './plugins/shortcuts-popup-plugin'
|
||||
import type { Hotkey, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
|
||||
import type {
|
||||
ContextBlockType,
|
||||
CurrentBlockType,
|
||||
@@ -97,6 +96,16 @@ const ValueSyncPlugin: FC<{ value?: string }> = ({ value }) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const EditableSyncPlugin: FC<{ editable: boolean }> = ({ editable }) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
editor.setEditable(editable)
|
||||
}, [editor, editable])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export type PromptEditorProps = {
|
||||
instanceId?: string
|
||||
compact?: boolean
|
||||
@@ -122,7 +131,7 @@ export type PromptEditorProps = {
|
||||
errorMessageBlock?: ErrorMessageBlockType
|
||||
lastRunBlock?: LastRunBlockType
|
||||
isSupportFileVar?: boolean
|
||||
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void }> }>
|
||||
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }> }>
|
||||
}
|
||||
|
||||
const PromptEditor: FC<PromptEditorProps> = ({
|
||||
@@ -194,13 +203,13 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
eventEmitter?.emit({
|
||||
type: UPDATE_DATASETS_EVENT_EMITTER,
|
||||
payload: contextBlock?.datasets,
|
||||
} as any)
|
||||
})
|
||||
}, [eventEmitter, contextBlock?.datasets])
|
||||
useEffect(() => {
|
||||
eventEmitter?.emit({
|
||||
type: UPDATE_HISTORY_EVENT_EMITTER,
|
||||
payload: historyBlock?.history,
|
||||
} as any)
|
||||
})
|
||||
}, [eventEmitter, historyBlock?.history])
|
||||
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null)
|
||||
@@ -243,6 +252,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
onEditorChange={handleEditorChange}
|
||||
/>
|
||||
<ValueSyncPlugin value={value} />
|
||||
<EditableSyncPlugin editable={editable} />
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
)
|
||||
|
||||
+153
-10
@@ -1,15 +1,17 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { WorkflowNodesMap } from '../../workflow-variable-block/node'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { FormInputItem, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import HITLInputComponentUI from '../component-ui'
|
||||
import { HITLInputNode } from '../node'
|
||||
|
||||
const createFormInput = (overrides?: Partial<FormInputItem>): FormInputItem => ({
|
||||
const createParagraphFormInput = (overrides?: Partial<ParagraphFormInput>): ParagraphFormInput => ({
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'customer_name',
|
||||
default: {
|
||||
@@ -93,7 +95,7 @@ describe('HITLInputComponentUI', () => {
|
||||
const selector = ['node-2', 'answer'] as ValueSelector
|
||||
|
||||
const { getByText } = renderComponent({
|
||||
formInput: createFormInput({
|
||||
formInput: createParagraphFormInput({
|
||||
default: {
|
||||
type: 'variable',
|
||||
selector,
|
||||
@@ -112,6 +114,124 @@ describe('HITLInputComponentUI', () => {
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.edit' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.remove' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close the edit modal when readonly becomes true', async () => {
|
||||
let setReadonlyValue: ((readonly: boolean) => void) | undefined
|
||||
const Harness = () => {
|
||||
const [readonly, setReadonly] = useState(false)
|
||||
const [namespace] = useState(() => `hitl-input-test-${crypto.randomUUID()}`)
|
||||
|
||||
useEffect(() => {
|
||||
setReadonlyValue = setReadonly
|
||||
return () => {
|
||||
setReadonlyValue = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace,
|
||||
onError: (error: Error) => {
|
||||
throw error
|
||||
},
|
||||
nodes: [HITLInputNode],
|
||||
}}
|
||||
>
|
||||
<HITLInputComponentUI
|
||||
nodeId="node-1"
|
||||
varName="customer_name"
|
||||
workflowNodesMap={createWorkflowNodesMap()}
|
||||
onChange={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
|
||||
expect(await screen.findByRole('textbox')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
setReadonlyValue?.(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render select option summary for constant options', () => {
|
||||
const { getByText } = renderComponent({
|
||||
formInput: {
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'customer_name',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: ['alpha', 'beta'],
|
||||
},
|
||||
} satisfies FormInputItem,
|
||||
})
|
||||
|
||||
expect(getByText('alpha, beta')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input type label after the summary content', () => {
|
||||
const { getByText } = renderComponent({
|
||||
formInput: {
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'customer_name',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: ['alpha', 'beta'],
|
||||
},
|
||||
} satisfies FormInputItem,
|
||||
})
|
||||
|
||||
const summary = getByText('alpha, beta')
|
||||
const typeLabel = getByText('appDebug.variableConfig.select')
|
||||
|
||||
expect(summary.compareDocumentPosition(typeLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render file-list placeholder instead of selected file details', () => {
|
||||
const { getAllByText, queryByText } = renderComponent({
|
||||
formInput: {
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'customer_name',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
number_limits: 4,
|
||||
} satisfies FormInputItem,
|
||||
})
|
||||
|
||||
expect(getAllByText('appDebug.variableConfig.multi-files')).toHaveLength(2)
|
||||
expect(queryByText(/document/)).not.toBeInTheDocument()
|
||||
expect(queryByText(/4/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render single-file placeholder instead of selected file details', () => {
|
||||
const { getAllByText, queryByText } = renderComponent({
|
||||
formInput: {
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'customer_name',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
} satisfies FormInputItem,
|
||||
})
|
||||
|
||||
expect(getAllByText('appDebug.variableConfig.single-file')).toHaveLength(2)
|
||||
expect(queryByText(/document/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remove action', () => {
|
||||
@@ -145,10 +265,33 @@ describe('HITLInputComponentUI', () => {
|
||||
|
||||
expect(queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prevent renaming to an existing variable name', async () => {
|
||||
const {
|
||||
findByRole,
|
||||
onChange,
|
||||
onRename,
|
||||
} = renderComponent({
|
||||
unavailableVariableNames: ['existing_name'],
|
||||
})
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
|
||||
const textbox = await findByRole('textbox')
|
||||
fireEvent.change(textbox, { target: { value: 'existing_name' } })
|
||||
|
||||
expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameDuplicated')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(onRename).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default formInput', () => {
|
||||
it('should pass default payload to InputField when formInput is undefined', async () => {
|
||||
it('should open an empty default editor when formInput is undefined', async () => {
|
||||
const { findByRole } = renderComponent({
|
||||
formInput: undefined,
|
||||
})
|
||||
@@ -156,16 +299,16 @@ describe('HITLInputComponentUI', () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
|
||||
const textbox = await findByRole('textbox')
|
||||
const saveButton = await screen.findByRole('button', { name: 'common.operation.save' })
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(textbox).toHaveValue('customer_name')
|
||||
expect(textbox).toHaveValue('')
|
||||
expect(saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render variable selector when workflowNodesMap fallback is used', () => {
|
||||
const { getByText } = renderComponent({
|
||||
workflowNodesMap: undefined as unknown as WorkflowNodesMap,
|
||||
formInput: createFormInput({
|
||||
formInput: createParagraphFormInput({
|
||||
default: {
|
||||
type: 'variable',
|
||||
selector: ['node-2', 'answer'] as ValueSelector,
|
||||
|
||||
+38
-11
@@ -1,5 +1,5 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { FormInputItem, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
@@ -15,15 +15,17 @@ vi.mock('../../../hooks', () => ({
|
||||
|
||||
vi.mock('../component-ui', () => ({
|
||||
default: ({ formInput, onChange }: { formInput?: FormInputItem, onChange: (payload: FormInputItem) => void }) => {
|
||||
const basePayload: FormInputItem = formInput ?? {
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'user_name',
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: 'hello',
|
||||
},
|
||||
}
|
||||
const basePayload: ParagraphFormInput = (formInput && formInput.type === InputVarType.paragraph
|
||||
? formInput
|
||||
: {
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'user_name',
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: 'hello',
|
||||
},
|
||||
}) satisfies ParagraphFormInput
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
@@ -63,7 +65,7 @@ const createHookReturn = (): [RefObject<HTMLDivElement | null>, boolean] => {
|
||||
return [{ current: null }, false]
|
||||
}
|
||||
|
||||
const createInput = (overrides?: Partial<FormInputItem>): FormInputItem => ({
|
||||
const createInput = (overrides?: Partial<ParagraphFormInput>): ParagraphFormInput => ({
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'user_name',
|
||||
default: {
|
||||
@@ -127,6 +129,31 @@ describe('HITLInputComponent', () => {
|
||||
expect(onChange.mock.calls[0]![0][0].output_variable_name).toBe('renamed_name')
|
||||
})
|
||||
|
||||
it('should ignore rename when the target variable name already exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<HITLInputComponent
|
||||
nodeKey="node-key-duplicate"
|
||||
nodeId="node-duplicate"
|
||||
varName="user_name"
|
||||
formInputs={[
|
||||
createInput(),
|
||||
createInput({ output_variable_name: 'renamed_name' }),
|
||||
]}
|
||||
onChange={onChange}
|
||||
onRename={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'emit-rename' }))
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update existing payload when variable name stays the same', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
+69
-1
@@ -1,11 +1,13 @@
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import {
|
||||
$nodesOfType,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
BlockEnum,
|
||||
InputVarType,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
import { CustomTextNode } from '../../custom-text/node'
|
||||
import {
|
||||
getNodeCount,
|
||||
readEditorStateValue,
|
||||
readRootTextContent,
|
||||
renderLexicalEditor,
|
||||
selectRootEnd,
|
||||
@@ -76,6 +79,12 @@ const createInsertPayload = () => ({
|
||||
onFormInputItemRemove: vi.fn(),
|
||||
})
|
||||
|
||||
const readHITLReadonlyValues = (editor: LexicalEditor): boolean[] => {
|
||||
return readEditorStateValue(editor, () => {
|
||||
return $nodesOfType(HITLInputNode).map(node => node.getReadonly())
|
||||
})
|
||||
}
|
||||
|
||||
const renderHITLInputBlock = (props?: {
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
@@ -169,6 +178,65 @@ describe('HITLInputBlock', () => {
|
||||
expect(getNodeCount(editor, HITLInputNode)).toBe(1)
|
||||
})
|
||||
|
||||
it('should update existing and newly inserted nodes when readonly changes', async () => {
|
||||
let setReadonlyValue: ((readonly: boolean) => void) | undefined
|
||||
const ReadonlyHarness = () => {
|
||||
const [readonly, setReadonly] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setReadonlyValue = setReadonly
|
||||
return () => {
|
||||
setReadonlyValue = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<HITLInputBlock
|
||||
nodeId="node-1"
|
||||
formInputs={[createFormInput()]}
|
||||
onFormInputItemRename={vi.fn()}
|
||||
onFormInputItemRemove={vi.fn()}
|
||||
workflowNodesMap={createWorkflowNodesMap('First Node')}
|
||||
readonly={readonly}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const { getEditor } = renderLexicalEditor({
|
||||
namespace: 'hitl-input-block-readonly-update-test',
|
||||
nodes: [CustomTextNode, HITLInputNode],
|
||||
children: <ReadonlyHarness />,
|
||||
})
|
||||
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
selectRootEnd(editor)
|
||||
act(() => {
|
||||
editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload())
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readHITLReadonlyValues(editor)).toEqual([false])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
setReadonlyValue?.(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readHITLReadonlyValues(editor)).toEqual([true])
|
||||
})
|
||||
|
||||
selectRootEnd(editor)
|
||||
act(() => {
|
||||
editor.dispatchCommand(INSERT_HITL_INPUT_BLOCK_COMMAND, createInsertPayload())
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readHITLReadonlyValues(editor)).toEqual([true, true])
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onDelete when delete command is dispatched', async () => {
|
||||
const onDelete = vi.fn()
|
||||
const { getEditor } = renderHITLInputBlock({ onDelete })
|
||||
|
||||
+359
-3
@@ -1,15 +1,20 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { FormInputItem, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { InputVarType, SupportUploadFileTypes, VarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import InputField from '../input-field'
|
||||
|
||||
type VarReferencePickerProps = {
|
||||
onChange: (value: string[]) => void
|
||||
filterVar?: (payload: { type: VarType }) => boolean
|
||||
}
|
||||
|
||||
let lastVarReferencePickerProps: VarReferencePickerProps | undefined
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
default: (props: VarReferencePickerProps) => {
|
||||
lastVarReferencePickerProps = props
|
||||
return (
|
||||
<button type="button" onClick={() => props.onChange(['node-a', 'var-a'])}>
|
||||
pick-variable
|
||||
@@ -18,7 +23,58 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference
|
||||
},
|
||||
}))
|
||||
|
||||
const createPayload = (overrides?: Partial<FormInputItem>): FormInputItem => ({
|
||||
vi.mock('@/app/components/app/configuration/config-var/config-modal/type-select', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onSelect }: { onSelect: (item: { value: InputVarType }) => void }) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onSelect({ value: InputVarType.paragraph })}>
|
||||
select-paragraph
|
||||
</button>
|
||||
<button type="button" onClick={() => onSelect({ value: InputVarType.select })}>
|
||||
select-select
|
||||
</button>
|
||||
<button type="button" onClick={() => onSelect({ value: InputVarType.singleFile })}>
|
||||
select-file
|
||||
</button>
|
||||
<button type="button" onClick={() => onSelect({ value: InputVarType.multiFiles })}>
|
||||
select-file-list
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config-var/config-select', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onChange }: { onChange: (options: string[]) => void }) => (
|
||||
<button type="button" onClick={() => onChange(['alpha', 'beta'])}>
|
||||
config-select
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onChange }: { onChange: (payload: {
|
||||
allowed_file_extensions: string[]
|
||||
allowed_file_types: SupportUploadFileTypes[]
|
||||
allowed_file_upload_methods: TransferMethod[]
|
||||
max_length?: number
|
||||
}) => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
max_length: 4,
|
||||
})}
|
||||
>
|
||||
file-upload-setting
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const createPayload = (overrides?: Partial<ParagraphFormInput>): ParagraphFormInput => ({
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'valid_name',
|
||||
default: {
|
||||
@@ -32,6 +88,7 @@ const createPayload = (overrides?: Partial<FormInputItem>): FormInputItem => ({
|
||||
describe('InputField', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
lastVarReferencePickerProps = undefined
|
||||
})
|
||||
|
||||
it('should disable save and show validation error when variable name is invalid', async () => {
|
||||
@@ -59,6 +116,31 @@ describe('InputField', () => {
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable save and show validation error when variable name already exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="node-duplicate-name"
|
||||
isEdit={false}
|
||||
payload={createPayload()}
|
||||
unavailableVariableNames={['existing_name']}
|
||||
onChange={onChange}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
await user.clear(inputs[0]!)
|
||||
await user.type(inputs[0]!, 'existing_name')
|
||||
|
||||
expect(screen.getByText('workflow.nodes.humanInput.insertInputField.variableNameDuplicated')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i })).toBeDisabled()
|
||||
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onChange when saving a valid payload in edit mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
@@ -274,4 +356,278 @@ describe('InputField', () => {
|
||||
value: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch to select payload when field type changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="node-7"
|
||||
isEdit={false}
|
||||
payload={createPayload()}
|
||||
onChange={onChange}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select-select' }))
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange.mock.calls[0]![0]).toEqual({
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'valid_name',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: [],
|
||||
},
|
||||
})
|
||||
expect(screen.queryByText(/workflow\.nodes\.humanInput\.insertInputField\.prePopulateField/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep paragraph pre-populate editor available after switching back to paragraph', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="node-8"
|
||||
isEdit={false}
|
||||
payload={createPayload()}
|
||||
onChange={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.prePopulateField/i)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select-file' }))
|
||||
expect(screen.queryByText(/workflow\.nodes\.humanInput\.insertInputField\.prePopulateField/i)).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select-paragraph' }))
|
||||
expect(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.prePopulateField/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save constant select options', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="node-9"
|
||||
isEdit={false}
|
||||
payload={createPayload()}
|
||||
onChange={onChange}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select-select' }))
|
||||
await user.click(screen.getByRole('button', { name: 'config-select' }))
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange.mock.calls[0]![0]).toEqual({
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'valid_name',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: ['alpha', 'beta'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve constant and variable select sources when toggling', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="node-10"
|
||||
isEdit={false}
|
||||
payload={createPayload()}
|
||||
onChange={onChange}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select-select' }))
|
||||
await user.click(screen.getByRole('button', { name: 'config-select' }))
|
||||
await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i))
|
||||
await user.click(screen.getByText('pick-variable'))
|
||||
await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useConstantInstead/i))
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange.mock.calls[0]![0]).toEqual({
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'valid_name',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
selector: ['node-a', 'var-a'],
|
||||
value: ['alpha', 'beta'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should only allow array[string] variables for select option source', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="node-11"
|
||||
isEdit={false}
|
||||
payload={createPayload()}
|
||||
onChange={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select-select' }))
|
||||
await user.click(screen.getByText(/workflow\.nodes\.humanInput\.insertInputField\.useVarInstead/i))
|
||||
|
||||
expect(lastVarReferencePickerProps?.filterVar?.({ type: VarType.arrayString })).toBe(true)
|
||||
expect(lastVarReferencePickerProps?.filterVar?.({ type: VarType.string })).toBe(false)
|
||||
expect(lastVarReferencePickerProps?.filterVar?.({ type: VarType.arrayFile })).toBe(false)
|
||||
})
|
||||
|
||||
it('should clear paragraph default state when switching to select', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="node-12"
|
||||
isEdit={false}
|
||||
payload={createPayload({
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: 'paragraph-default',
|
||||
},
|
||||
})}
|
||||
onChange={onChange}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select-select' }))
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange.mock.calls[0]![0]).not.toHaveProperty('default')
|
||||
})
|
||||
|
||||
it('should save single file upload settings', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="node-13"
|
||||
isEdit={false}
|
||||
payload={createPayload()}
|
||||
onChange={onChange}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select-file' }))
|
||||
await user.click(screen.getByRole('button', { name: 'file-upload-setting' }))
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange.mock.calls[0]![0]).toEqual({
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'valid_name',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: ['document'],
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear paragraph default state when switching to single file', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="node-13-1"
|
||||
isEdit={false}
|
||||
payload={createPayload({
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: 'paragraph-default',
|
||||
},
|
||||
})}
|
||||
onChange={onChange}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select-file' }))
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange.mock.calls[0]![0]).not.toHaveProperty('default')
|
||||
})
|
||||
|
||||
it('should save file-list upload settings and max upload count', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="node-14"
|
||||
isEdit={false}
|
||||
payload={createPayload()}
|
||||
onChange={onChange}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select-file-list' }))
|
||||
await user.click(screen.getByRole('button', { name: 'file-upload-setting' }))
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange.mock.calls[0]![0]).toEqual({
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'valid_name',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: ['document'],
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
number_limits: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear paragraph default state when switching to file-list', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<InputField
|
||||
nodeId="node-14-1"
|
||||
isEdit={false}
|
||||
payload={createPayload({
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: 'paragraph-default',
|
||||
},
|
||||
})}
|
||||
onChange={onChange}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select-file-list' }))
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.insertInputField\.insert/i }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange.mock.calls[0]![0]).not.toHaveProperty('default')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -98,6 +98,8 @@ describe('HITLInputNode', () => {
|
||||
expect(node.getConversationVariables()).toEqual(props.conversationVariables)
|
||||
expect(node.getRagVariables()).toEqual(props.ragVariables)
|
||||
expect(node.getReadonly()).toBe(true)
|
||||
node.setReadonly(false)
|
||||
expect(node.getReadonly()).toBe(false)
|
||||
expect(node.getTextContent()).toBe('{{#$output.user_name#}}')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,16 +9,24 @@ import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
createDefaultParagraphFormInput,
|
||||
isFileFormInput,
|
||||
isParagraphFormInput,
|
||||
isSelectFormInput,
|
||||
} from '@/app/components/workflow/nodes/human-input/types'
|
||||
import ActionButton from '../../../action-button'
|
||||
import { VariableX } from '../../../icons/src/vender/workflow'
|
||||
import InputField from './input-field'
|
||||
import VariableBlock from './variable-block'
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput.insertInputField'
|
||||
|
||||
type HITLInputComponentUIProps = {
|
||||
nodeId: string
|
||||
varName: string
|
||||
formInput?: FormInputItem
|
||||
unavailableVariableNames?: string[]
|
||||
onChange: (input: FormInputItem) => void
|
||||
onRename: (payload: FormInputItem, oldName: string) => void
|
||||
onRemove: (varName: string) => void
|
||||
@@ -36,15 +44,8 @@ type HITLInputComponentUIProps = {
|
||||
const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
nodeId,
|
||||
varName,
|
||||
formInput = {
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: varName,
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
formInput,
|
||||
unavailableVariableNames = [],
|
||||
onChange,
|
||||
onRename,
|
||||
onRemove,
|
||||
@@ -56,11 +57,20 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
readonly,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const resolvedFormInput = formInput || createDefaultParagraphFormInput(varName)
|
||||
const paragraphDefault = isParagraphFormInput(resolvedFormInput)
|
||||
? resolvedFormInput.default
|
||||
: null
|
||||
const [isShowEditModal, {
|
||||
setTrue: showEditModal,
|
||||
setFalse: hideEditModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (readonly)
|
||||
hideEditModal()
|
||||
}, [hideEditModal, readonly])
|
||||
|
||||
// Lexical delegate the click make it unable to add click by the method of react
|
||||
const editBtnRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
@@ -72,8 +82,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
if (editBtn)
|
||||
editBtn.removeEventListener('click', showEditModal)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [showEditModal])
|
||||
|
||||
const removeBtnRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
@@ -89,16 +98,47 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
}, [onRemove, varName])
|
||||
|
||||
const handleChange = useCallback((newPayload: FormInputItem) => {
|
||||
if (newPayload.output_variable_name !== varName && unavailableVariableNames.includes(newPayload.output_variable_name))
|
||||
return
|
||||
|
||||
if (varName === newPayload.output_variable_name)
|
||||
onChange(newPayload)
|
||||
else
|
||||
onRename(newPayload, varName)
|
||||
hideEditModal()
|
||||
}, [hideEditModal, onChange, onRename, varName])
|
||||
}, [hideEditModal, onChange, onRename, unavailableVariableNames, varName])
|
||||
|
||||
const isDefaultValueVariable = useMemo(() => {
|
||||
return formInput.default?.type === 'variable'
|
||||
}, [formInput.default?.type])
|
||||
return paragraphDefault?.type === 'variable'
|
||||
}, [paragraphDefault])
|
||||
const inputTypeLabel = useMemo(() => {
|
||||
if (isParagraphFormInput(resolvedFormInput))
|
||||
return t('variableConfig.paragraph', { ns: 'appDebug' })
|
||||
if (isSelectFormInput(resolvedFormInput))
|
||||
return t('variableConfig.select', { ns: 'appDebug' })
|
||||
if (isFileFormInput(resolvedFormInput))
|
||||
return t('variableConfig.single-file', { ns: 'appDebug' })
|
||||
return t('variableConfig.multi-files', { ns: 'appDebug' })
|
||||
}, [resolvedFormInput, t])
|
||||
const variableSelector = useMemo(() => {
|
||||
if (isDefaultValueVariable)
|
||||
return paragraphDefault?.selector || []
|
||||
if (isSelectFormInput(resolvedFormInput) && resolvedFormInput.option_source.type === 'variable')
|
||||
return resolvedFormInput.option_source.selector
|
||||
return null
|
||||
}, [isDefaultValueVariable, paragraphDefault?.selector, resolvedFormInput])
|
||||
const summaryText = useMemo(() => {
|
||||
if (isParagraphFormInput(resolvedFormInput))
|
||||
return paragraphDefault?.value || inputTypeLabel
|
||||
|
||||
if (isSelectFormInput(resolvedFormInput)) {
|
||||
if (resolvedFormInput.option_source.type === 'variable')
|
||||
return t(`${i18nPrefix}.variable`, { ns: 'workflow' })
|
||||
return resolvedFormInput.option_source.value.join(', ') || inputTypeLabel
|
||||
}
|
||||
|
||||
return inputTypeLabel
|
||||
}, [inputTypeLabel, paragraphDefault?.value, resolvedFormInput, t])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -112,22 +152,27 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center gap-x-0.5 pr-5">
|
||||
<div className="flex w-full items-center gap-x-2 pr-5">
|
||||
<div className="min-w-0 grow">
|
||||
{/* Default Value Info */}
|
||||
{isDefaultValueVariable && (
|
||||
<VariableBlock
|
||||
variables={formInput.default?.selector}
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
getVarType={getVarType}
|
||||
environmentVariables={environmentVariables}
|
||||
conversationVariables={conversationVariables}
|
||||
ragVariables={ragVariables}
|
||||
/>
|
||||
)}
|
||||
{!isDefaultValueVariable && (
|
||||
<div className="max-w-full truncate system-xs-medium text-components-input-text-filled">{formInput.default?.value}</div>
|
||||
)}
|
||||
{variableSelector
|
||||
? (
|
||||
<VariableBlock
|
||||
variables={variableSelector}
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
getVarType={getVarType}
|
||||
environmentVariables={environmentVariables}
|
||||
conversationVariables={conversationVariables}
|
||||
ragVariables={ragVariables}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="max-w-full truncate system-xs-medium text-components-input-text-filled">
|
||||
{summaryText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 system-2xs-medium text-text-tertiary uppercase">
|
||||
{inputTypeLabel}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -168,6 +213,7 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
nodeId={nodeId}
|
||||
isEdit
|
||||
payload={formInput}
|
||||
unavailableVariableNames={unavailableVariableNames}
|
||||
onChange={handleChange}
|
||||
onCancel={hideEditModal}
|
||||
/>
|
||||
|
||||
@@ -45,8 +45,14 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({
|
||||
}) => {
|
||||
const [ref] = useSelectOrDelete(nodeKey, DELETE_HITL_INPUT_BLOCK_COMMAND)
|
||||
const payload = formInputs.find(item => item.output_variable_name === varName)
|
||||
const unavailableVariableNames = formInputs
|
||||
.map(item => item.output_variable_name)
|
||||
.filter(name => name !== varName)
|
||||
|
||||
const handleChange = useCallback((newPayload: FormInputItem) => {
|
||||
if (newPayload.output_variable_name !== varName && unavailableVariableNames.includes(newPayload.output_variable_name))
|
||||
return
|
||||
|
||||
if (!payload) {
|
||||
onChange([...formInputs, newPayload])
|
||||
return
|
||||
@@ -58,7 +64,7 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({
|
||||
return
|
||||
}
|
||||
onChange(formInputs.map(item => item.output_variable_name === varName ? newPayload : item))
|
||||
}, [formInputs, onChange, payload, varName])
|
||||
}, [formInputs, onChange, payload, unavailableVariableNames, varName])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -69,6 +75,7 @@ const HITLInputComponent: FC<HITLInputComponentProps> = ({
|
||||
nodeId={nodeId}
|
||||
varName={varName}
|
||||
formInput={payload}
|
||||
unavailableVariableNames={unavailableVariableNames}
|
||||
onChange={handleChange}
|
||||
onRename={onRename}
|
||||
onRemove={onRemove}
|
||||
|
||||
+45
-3
@@ -1,5 +1,6 @@
|
||||
import type { TextNode } from 'lexical'
|
||||
import type { HITLInputBlockType } from '../../types'
|
||||
import type { Var } from '@/app/components/workflow/types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { HITL_INPUT_REG } from '@/config'
|
||||
import { decoratorTransform } from '../../utils'
|
||||
@@ -31,13 +33,39 @@ const HITLInputReplacementBlock = ({
|
||||
|
||||
const environmentVariables = useMemo(() => variables?.find(o => o.nodeId === 'env')?.vars || [], [variables])
|
||||
const conversationVariables = useMemo(() => variables?.find(o => o.nodeId === 'conversation')?.vars || [], [variables])
|
||||
const ragVariables = useMemo(() => variables?.reduce<any[]>((acc, curr) => {
|
||||
const ragVariables = useMemo(() => variables?.reduce<Var[]>((acc, curr) => {
|
||||
if (curr.nodeId === 'rag')
|
||||
acc.push(...curr.vars)
|
||||
else
|
||||
acc.push(...curr.vars.filter(v => v.isRagVariable))
|
||||
return acc
|
||||
}, []), [variables])
|
||||
const latestConfigRef = useRef({
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
})
|
||||
latestConfigRef.current = {
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HITLInputNode]))
|
||||
@@ -46,6 +74,20 @@ const HITLInputReplacementBlock = ({
|
||||
|
||||
const createHITLInputBlockNode = useCallback((textNode: TextNode): HITLInputNode => {
|
||||
const varName = textNode.getTextContent().split('.')[1]!.replace(/#\}\}$/, '')
|
||||
const {
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
} = latestConfigRef.current
|
||||
|
||||
return $applyNodeReplacement($createHITLInputNode(
|
||||
varName,
|
||||
nodeId,
|
||||
@@ -60,7 +102,7 @@ const HITLInputReplacementBlock = ({
|
||||
ragVariables,
|
||||
readonly,
|
||||
))
|
||||
}, [nodeId, formInputs, onFormInputsChange, onFormInputItemRename, onFormInputItemRemove, workflowNodesMap, getVarType, environmentVariables, conversationVariables, ragVariables, readonly])
|
||||
}, [])
|
||||
|
||||
const getMatch = useCallback((text: string) => {
|
||||
const matchArr = REGEX.exec(text)
|
||||
@@ -81,7 +123,7 @@ const HITLInputReplacementBlock = ({
|
||||
return mergeRegister(
|
||||
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createHITLInputBlockNode)),
|
||||
)
|
||||
}, [])
|
||||
}, [editor, getMatch, createHITLInputBlockNode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$insertNodes,
|
||||
$nodesOfType,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
createCommand,
|
||||
} from 'lexical'
|
||||
@@ -43,6 +44,14 @@ const HITLInputBlock = memo(({
|
||||
})
|
||||
}, [editor, workflowNodesMap, workflowAvailableVariables])
|
||||
|
||||
useEffect(() => {
|
||||
editor.update(() => {
|
||||
$nodesOfType(HITLInputNode).forEach((node) => {
|
||||
node.setReadonly(readonly)
|
||||
})
|
||||
})
|
||||
}, [editor, readonly])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HITLInputNode]))
|
||||
throw new Error('HITLInputBlockPlugin: HITLInputBlock not registered on editor')
|
||||
@@ -95,7 +104,7 @@ const HITLInputBlock = memo(({
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor, onInsert, onDelete])
|
||||
}, [editor, onInsert, onDelete, workflowNodesMap, getVarType, readonly])
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import type { FormInputItem, FormInputItemDefault } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
import type { Item as TypeSelectItem } from '@/app/components/app/configuration/config-var/config-modal/type-select'
|
||||
import type { FormInputItem, FormInputItemDefault, ParagraphFormInput } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { UploadFileSetting, ValueSelector } from '@/app/components/workflow/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TypeSelector from '@/app/components/app/configuration/config-var/config-modal/type-select'
|
||||
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import {
|
||||
createDefaultFormInputByType,
|
||||
createDefaultParagraphFormInput,
|
||||
isFileFormInput,
|
||||
isFileListFormInput,
|
||||
isParagraphFormInput,
|
||||
isSelectFormInput,
|
||||
} from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { InputVarType, VarType } from '@/app/components/workflow/types'
|
||||
import PrePopulate from './pre-populate'
|
||||
import TypeSwitch from './type-switch'
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput.insertInputField'
|
||||
|
||||
@@ -17,42 +32,79 @@ type InputFieldProps = {
|
||||
nodeId: string
|
||||
isEdit: boolean
|
||||
payload?: FormInputItem
|
||||
unavailableVariableNames?: string[]
|
||||
onChange: (newPayload: FormInputItem) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
const defaultPayload: FormInputItem = {
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: '',
|
||||
default: { type: 'constant', selector: [], value: '' },
|
||||
}
|
||||
const InputField: React.FC<InputFieldProps> = ({
|
||||
nodeId,
|
||||
isEdit,
|
||||
payload,
|
||||
unavailableVariableNames = [],
|
||||
onChange,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [tempPayload, setTempPayload] = useState(payload || defaultPayload)
|
||||
const nameValid = useMemo(() => {
|
||||
const [tempPayload, setTempPayload] = useState<FormInputItem>(() => payload || createDefaultParagraphFormInput())
|
||||
const fieldTypeItems = useMemo<TypeSelectItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
name: t('variableConfig.paragraph', { ns: 'appDebug' }),
|
||||
value: InputVarType.paragraph,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.select', { ns: 'appDebug' }),
|
||||
value: InputVarType.select,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.single-file', { ns: 'appDebug' }),
|
||||
value: InputVarType.singleFile,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.multi-files', { ns: 'appDebug' }),
|
||||
value: InputVarType.multiFiles,
|
||||
},
|
||||
]
|
||||
}, [t])
|
||||
const paragraphPayload = useMemo<ParagraphFormInput>(() => {
|
||||
if (isParagraphFormInput(tempPayload)) {
|
||||
return {
|
||||
...tempPayload,
|
||||
default: tempPayload.default || createDefaultParagraphFormInput().default,
|
||||
}
|
||||
}
|
||||
|
||||
return createDefaultParagraphFormInput(tempPayload.output_variable_name)
|
||||
}, [tempPayload])
|
||||
const unavailableVariableNameSet = useMemo(() => {
|
||||
return new Set(unavailableVariableNames.map(name => name.trim()).filter(Boolean))
|
||||
}, [unavailableVariableNames])
|
||||
const variableNameError = useMemo(() => {
|
||||
const name = tempPayload.output_variable_name.trim()
|
||||
if (!name)
|
||||
return false
|
||||
return null
|
||||
if (name.includes(' '))
|
||||
return false
|
||||
return /^[a-z_]\w{0,29}$/.test(name)
|
||||
}, [tempPayload.output_variable_name])
|
||||
return 'variableNameInvalid'
|
||||
if (!/^[a-z_]\w{0,29}$/.test(name))
|
||||
return 'variableNameInvalid'
|
||||
if (unavailableVariableNameSet.has(name))
|
||||
return 'variableNameDuplicated'
|
||||
return null
|
||||
}, [tempPayload.output_variable_name, unavailableVariableNameSet])
|
||||
const nameValid = useMemo(() => {
|
||||
return !!tempPayload.output_variable_name.trim() && !variableNameError
|
||||
}, [tempPayload.output_variable_name, variableNameError])
|
||||
const handleSave = useCallback(() => {
|
||||
if (!nameValid)
|
||||
return
|
||||
onChange(tempPayload)
|
||||
}, [nameValid, onChange, tempPayload])
|
||||
const defaultValueConfig = tempPayload.default
|
||||
const handleTypeChange = useCallback((item: TypeSelectItem) => {
|
||||
setTempPayload(prev => createDefaultFormInputByType(item.value as FormInputItem['type'], prev.output_variable_name))
|
||||
}, [])
|
||||
const handleDefaultValueChange = useCallback((key: keyof FormInputItemDefault) => {
|
||||
return (value: ValueSelector | string) => {
|
||||
const nextValue = produce(tempPayload, (draft) => {
|
||||
if (!draft.default)
|
||||
draft.default = { type: 'constant', selector: [], value: '' }
|
||||
const nextValue = produce(paragraphPayload, (draft) => {
|
||||
if (key === 'selector') {
|
||||
draft.default.type = 'variable'
|
||||
draft.default.selector = value as ValueSelector
|
||||
@@ -67,7 +119,78 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
})
|
||||
setTempPayload(nextValue)
|
||||
}
|
||||
}, [tempPayload])
|
||||
}, [paragraphPayload])
|
||||
const handleSelectOptionsChange = useCallback((options: string[]) => {
|
||||
setTempPayload((prev) => {
|
||||
if (!isSelectFormInput(prev))
|
||||
return prev
|
||||
|
||||
return {
|
||||
...prev,
|
||||
option_source: {
|
||||
...prev.option_source,
|
||||
type: 'constant',
|
||||
value: options,
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
const handleSelectOptionSourceTypeChange = useCallback((isVariable: boolean) => {
|
||||
setTempPayload((prev) => {
|
||||
if (!isSelectFormInput(prev))
|
||||
return prev
|
||||
|
||||
return {
|
||||
...prev,
|
||||
option_source: {
|
||||
...prev.option_source,
|
||||
type: isVariable ? 'variable' : 'constant',
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
const handleSelectOptionSourceSelectorChange = useCallback((selector: ValueSelector | string) => {
|
||||
setTempPayload((prev) => {
|
||||
if (!isSelectFormInput(prev))
|
||||
return prev
|
||||
|
||||
return {
|
||||
...prev,
|
||||
option_source: {
|
||||
...prev.option_source,
|
||||
type: 'variable',
|
||||
selector: selector as ValueSelector,
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
const handleFilePayloadChange = useCallback((payload: UploadFileSetting) => {
|
||||
setTempPayload((prev) => {
|
||||
if (!isFileFormInput(prev))
|
||||
return prev
|
||||
|
||||
return {
|
||||
...prev,
|
||||
allowed_file_extensions: payload.allowed_file_extensions || [],
|
||||
allowed_file_types: payload.allowed_file_types,
|
||||
allowed_file_upload_methods: payload.allowed_file_upload_methods,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
const handleFileListPayloadChange = useCallback((payload: UploadFileSetting) => {
|
||||
setTempPayload((prev) => {
|
||||
if (!isFileListFormInput(prev))
|
||||
return prev
|
||||
|
||||
return {
|
||||
...prev,
|
||||
allowed_file_extensions: payload.allowed_file_extensions || [],
|
||||
allowed_file_types: payload.allowed_file_types,
|
||||
allowed_file_upload_methods: payload.allowed_file_upload_methods,
|
||||
number_limits: payload.max_length,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -83,74 +206,151 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
}, [handleSave])
|
||||
|
||||
return (
|
||||
<div className="w-[372px]
|
||||
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
|
||||
<div className="mt-3">
|
||||
<div className="system-xs-medium text-text-secondary">
|
||||
{t(`${i18nPrefix}.saveResponseAs`, { ns: 'workflow' })}
|
||||
<span className="relative system-xs-regular text-text-destructive-secondary">*</span>
|
||||
<div className="flex max-h-[540px] w-[372px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3 pb-0">
|
||||
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
|
||||
<div className="mt-3">
|
||||
<div className="system-xs-medium text-text-secondary">
|
||||
{t(`${i18nPrefix}.fieldType`, { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<TypeSelector
|
||||
value={tempPayload.type}
|
||||
items={fieldTypeItems}
|
||||
onSelect={handleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
className="mt-1.5"
|
||||
placeholder={t(`${i18nPrefix}.saveResponseAsPlaceholder`, { ns: 'workflow' })}
|
||||
value={tempPayload.output_variable_name}
|
||||
onChange={(e) => {
|
||||
setTempPayload(prev => ({ ...prev, output_variable_name: e.target.value }))
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{tempPayload.output_variable_name && !nameValid && (
|
||||
<div className="mt-1 px-1 system-xs-regular text-text-destructive-secondary">
|
||||
{t(`${i18nPrefix}.variableNameInvalid`, { ns: 'workflow' })}
|
||||
<div className="mt-3">
|
||||
<div className="system-xs-medium text-text-secondary">
|
||||
{t(`${i18nPrefix}.saveResponseAs`, { ns: 'workflow' })}
|
||||
<span className="relative system-xs-regular text-text-destructive-secondary">*</span>
|
||||
</div>
|
||||
<Input
|
||||
className="mt-1.5"
|
||||
placeholder={t(`${i18nPrefix}.saveResponseAsPlaceholder`, { ns: 'workflow' })}
|
||||
value={tempPayload.output_variable_name}
|
||||
onChange={(e) => {
|
||||
setTempPayload(prev => ({ ...prev, output_variable_name: e.target.value }))
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{tempPayload.output_variable_name && variableNameError && (
|
||||
<div className="mt-1 px-1 system-xs-regular text-text-destructive-secondary">
|
||||
{t(`${i18nPrefix}.${variableNameError}`, { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isParagraphFormInput(tempPayload) && (
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 system-xs-medium text-text-secondary">
|
||||
{t(`${i18nPrefix}.prePopulateField`, { ns: 'workflow' })}
|
||||
</div>
|
||||
<PrePopulate
|
||||
isVariable={paragraphPayload.default.type === 'variable'}
|
||||
onIsVariableChange={(isVariable) => {
|
||||
handleDefaultValueChange('type')(isVariable ? 'variable' : 'constant')
|
||||
}}
|
||||
nodeId={nodeId}
|
||||
valueSelector={paragraphPayload.default.selector}
|
||||
onValueSelectorChange={handleDefaultValueChange('selector')}
|
||||
value={paragraphPayload.default.value}
|
||||
onValueChange={handleDefaultValueChange('value')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isSelectFormInput(tempPayload) && (
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 system-xs-medium text-text-secondary">
|
||||
{t(`${i18nPrefix}.options`, { ns: 'workflow' })}
|
||||
</div>
|
||||
{tempPayload.option_source.type === 'variable'
|
||||
? (
|
||||
<div className="relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal px-3 pt-2 pb-8">
|
||||
<VarReferencePicker
|
||||
nodeId={nodeId}
|
||||
value={tempPayload.option_source.selector}
|
||||
onChange={handleSelectOptionSourceSelectorChange}
|
||||
readonly={false}
|
||||
isJustShowValue
|
||||
filterVar={varPayload => varPayload.type === VarType.arrayString}
|
||||
/>
|
||||
<TypeSwitch
|
||||
className="absolute bottom-1 left-1.5"
|
||||
isVariable
|
||||
onIsVariableChange={handleSelectOptionSourceTypeChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={cn('rounded-lg border border-transparent bg-components-input-bg-normal p-2')}>
|
||||
<ConfigSelect
|
||||
options={tempPayload.option_source.value}
|
||||
onChange={handleSelectOptionsChange}
|
||||
/>
|
||||
<TypeSwitch
|
||||
className="mt-2"
|
||||
isVariable={false}
|
||||
onIsVariableChange={handleSelectOptionSourceTypeChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isFileFormInput(tempPayload) && (
|
||||
<div className="mt-4">
|
||||
<FileUploadSetting
|
||||
payload={{
|
||||
...tempPayload,
|
||||
max_length: 1,
|
||||
}}
|
||||
isMultiple={false}
|
||||
onChange={handleFilePayloadChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isFileListFormInput(tempPayload) && (
|
||||
<div className="mt-4">
|
||||
<FileUploadSetting
|
||||
payload={{
|
||||
...tempPayload,
|
||||
max_length: tempPayload.number_limits || 5,
|
||||
}}
|
||||
isMultiple
|
||||
onChange={handleFileListPayloadChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 system-xs-medium text-text-secondary">
|
||||
{t(`${i18nPrefix}.prePopulateField`, { ns: 'workflow' })}
|
||||
<div className="shrink-0 p-3">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
{isEdit
|
||||
? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!nameValid}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
className="flex"
|
||||
variant="primary"
|
||||
disabled={!nameValid}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<span className="mr-1">{t(`${i18nPrefix}.insert`, { ns: 'workflow' })}</span>
|
||||
<KbdGroup>
|
||||
{['Mod', 'Enter'].map(key => (
|
||||
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<PrePopulate
|
||||
isVariable={defaultValueConfig?.type === 'variable'}
|
||||
onIsVariableChange={(isVariable) => {
|
||||
handleDefaultValueChange('type')(isVariable ? 'variable' : 'constant')
|
||||
}}
|
||||
nodeId={nodeId}
|
||||
valueSelector={defaultValueConfig?.selector}
|
||||
onValueSelectorChange={handleDefaultValueChange('selector')}
|
||||
value={defaultValueConfig?.value}
|
||||
onValueChange={handleDefaultValueChange('value')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
{isEdit
|
||||
? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!nameValid}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
className="flex"
|
||||
variant="primary"
|
||||
disabled={!nameValid}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<span className="mr-1">{t(`${i18nPrefix}.insert`, { ns: 'workflow' })}</span>
|
||||
<KbdGroup>
|
||||
{['Mod', 'Enter'].map(key => (
|
||||
<Kbd key={key} color="white">{formatForDisplay(key)}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -109,6 +109,11 @@ export class HITLInputNode extends DecoratorNode<React.JSX.Element> {
|
||||
return self.__readonly || false
|
||||
}
|
||||
|
||||
setReadonly(readonly?: boolean): void {
|
||||
const self = this.getWritable()
|
||||
self.__readonly = readonly
|
||||
}
|
||||
|
||||
static override clone(node: HITLInputNode): HITLInputNode {
|
||||
return new HITLInputNode(
|
||||
node.__variableName,
|
||||
|
||||
@@ -29,7 +29,6 @@ type PlaceholderProps = {
|
||||
value: ValueSelector
|
||||
onChange: (valueSelector: ValueSelector | string) => void
|
||||
readonly: boolean
|
||||
zIndex: number
|
||||
filterVar: (varPayload: Var) => boolean
|
||||
isJustShowValue?: boolean
|
||||
}
|
||||
@@ -86,7 +85,6 @@ const PrePopulate: FC<Props> = ({
|
||||
value: valueSelector || [],
|
||||
onChange: onValueSelectorChange!,
|
||||
readonly: false,
|
||||
zIndex: 1000000, // bigger than shortcut plugin popup
|
||||
filterVar: (varPayload: Var) => {
|
||||
return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
|
||||
},
|
||||
|
||||
+29
-2
@@ -149,6 +149,30 @@ describe('ShortcutsPopupPlugin', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not close on mousedown inside a Base UI portal overlay', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
|
||||
const portal = document.createElement('div')
|
||||
portal.setAttribute('data-base-ui-portal', '')
|
||||
const portalChild = document.createElement('button')
|
||||
portalChild.textContent = 'portal-child'
|
||||
portal.appendChild(portalChild)
|
||||
document.body.appendChild(portal)
|
||||
|
||||
fireEvent.mouseDown(portalChild)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
portal.remove()
|
||||
})
|
||||
|
||||
// ─── Container / portal ───
|
||||
it('portals into provided container when container is set', async () => {
|
||||
render(<MinimalEditor withContainer />)
|
||||
@@ -162,7 +186,10 @@ describe('ShortcutsPopupPlugin', () => {
|
||||
render(<MinimalEditor withContainer={false} />)
|
||||
focusAndTriggerHotkey('/')
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
const floatingDiv = screen.getByTestId('shortcuts-popup')
|
||||
expect(document.body).toContainElement(portalContent)
|
||||
expect(floatingDiv).toHaveStyle({ zIndex: '50' })
|
||||
expect(floatingDiv).toHaveStyle({ overflow: 'visible' })
|
||||
})
|
||||
|
||||
// ─── matchHotkey: string hotkey ───
|
||||
@@ -407,8 +434,8 @@ describe('ShortcutsPopupPlugin', () => {
|
||||
it('applies custom className to floating popup', async () => {
|
||||
render(<MinimalEditor className="custom-popup-class" />)
|
||||
focusAndTriggerHotkey('/')
|
||||
const content = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
const floatingDiv = content.closest('div')
|
||||
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
const floatingDiv = screen.getByTestId('shortcuts-popup')
|
||||
expect(floatingDiv).toHaveClass('custom-popup-class')
|
||||
})
|
||||
|
||||
|
||||
@@ -158,10 +158,9 @@ export default function ShortcutsPopupPlugin({
|
||||
size({
|
||||
apply({ availableWidth, availableHeight, elements }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
maxWidth: `${Math.min(400, availableWidth)}px`,
|
||||
maxHeight: `${Math.max(0, availableHeight)}px`,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
'--shortcut-popup-max-width': `${Math.min(400, availableWidth)}px`,
|
||||
'--shortcut-popup-max-height': `${Math.max(0, availableHeight)}px`,
|
||||
'overflow': 'visible',
|
||||
})
|
||||
},
|
||||
padding: 8,
|
||||
@@ -275,6 +274,9 @@ export default function ShortcutsPopupPlugin({
|
||||
/* v8 ignore next 2 -- outside-click listener can race with ref cleanup during close/unmount; null-ref path is a safety guard. @preserve */
|
||||
if (!portalRef.current)
|
||||
return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target?.closest('[data-base-ui-portal]'))
|
||||
return
|
||||
if (!portalRef.current.contains(e.target as Node))
|
||||
closePortal()
|
||||
}
|
||||
@@ -292,21 +294,25 @@ export default function ShortcutsPopupPlugin({
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-testid="shortcuts-popup"
|
||||
ref={(node) => {
|
||||
portalRef.current = node
|
||||
refs.setFloating(node)
|
||||
}}
|
||||
className={cn(
|
||||
useContainer ? '' : 'z-999999',
|
||||
'absolute rounded-xl bg-components-panel-bg-blur shadow-lg',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: useContainer ? undefined : 50,
|
||||
overflow: 'visible',
|
||||
visibility: isPositioned ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
|
||||
<div className="max-h-(--shortcut-popup-max-height) max-w-(--shortcut-popup-max-width) overflow-x-hidden overflow-y-auto rounded-xl">
|
||||
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
|
||||
</div>
|
||||
</div>,
|
||||
containerEl,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type {
|
||||
EditorState,
|
||||
LexicalCommand,
|
||||
} from 'lexical'
|
||||
import type { EditorState } from 'lexical'
|
||||
import type { FC } from 'react'
|
||||
import type { Hotkey } from './plugins/shortcuts-popup-plugin'
|
||||
import type { Hotkey, ShortcutPopupInsertHandler } from './plugins/shortcuts-popup-plugin'
|
||||
import type {
|
||||
ContextBlockType,
|
||||
CurrentBlockType,
|
||||
@@ -71,7 +68,7 @@ import {
|
||||
|
||||
type ShortcutPopup = {
|
||||
hotkey: Hotkey
|
||||
Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand<unknown>, params: unknown[]) => void }>
|
||||
Popup: React.ComponentType<{ onClose: () => void, onInsert: ShortcutPopupInsertHandler }>
|
||||
}
|
||||
|
||||
type PromptEditorContentProps = {
|
||||
|
||||
+2
@@ -139,6 +139,8 @@ describe('workflow-stream-handlers helpers', () => {
|
||||
expect(workflowProcessData.humanInputFilledFormDataList).toEqual([
|
||||
expect.objectContaining({
|
||||
action_text: 'Submit',
|
||||
form_content: 'content',
|
||||
inputs: [],
|
||||
}),
|
||||
])
|
||||
expect(workflowProcessData.tracing[0]).toEqual(expect.objectContaining({
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
||||
import type { IOtherOptions } from '@/service/base'
|
||||
import type { HumanInputFormTimeoutData, NodeTracing, WorkflowFinishedResponse } from '@/types/workflow'
|
||||
import { produce } from 'immer'
|
||||
import { enrichSubmittedHumanInputFormData } from '@/app/components/base/chat/chat/answer/human-input-content/submitted-utils'
|
||||
import { getFilesInLogs } from '@/app/components/base/file-uploader/utils'
|
||||
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { sseGet } from '@/service/base'
|
||||
@@ -204,16 +205,20 @@ const updateHumanInputFilled = (
|
||||
data: NonNullable<WorkflowProcess['humanInputFilledFormDataList']>[number],
|
||||
) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
let requiredFormData: NonNullable<WorkflowProcess['humanInputFormDataList']>[number] | undefined
|
||||
if (draft.humanInputFormDataList?.length) {
|
||||
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentFormIndex > -1)
|
||||
if (currentFormIndex > -1) {
|
||||
requiredFormData = draft.humanInputFormDataList[currentFormIndex]
|
||||
draft.humanInputFormDataList.splice(currentFormIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedData = enrichSubmittedHumanInputFormData(data, requiredFormData)
|
||||
if (!draft.humanInputFilledFormDataList)
|
||||
draft.humanInputFilledFormDataList = [data]
|
||||
draft.humanInputFilledFormDataList = [enrichedData]
|
||||
else
|
||||
draft.humanInputFilledFormDataList.push(data)
|
||||
draft.humanInputFilledFormDataList.push(enrichedData)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+53
-3
@@ -4,7 +4,7 @@ import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { Node, PromptItem } from '@/app/components/workflow/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { BlockEnum, EditionType, PromptRole } from '@/app/components/workflow/types'
|
||||
import { BlockEnum, EditionType, InputVarType, PromptRole } from '@/app/components/workflow/types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getNodeUsedVars, updateNodeVars } from '../utils'
|
||||
|
||||
@@ -62,7 +62,26 @@ describe('variable utils', () => {
|
||||
title: 'Human Input',
|
||||
desc: '',
|
||||
form_content: '',
|
||||
inputs: [],
|
||||
inputs: [
|
||||
{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'summary',
|
||||
default: {
|
||||
type: 'variable',
|
||||
selector: ['conversation', 'memory'],
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'decision',
|
||||
option_source: {
|
||||
type: 'variable',
|
||||
selector: ['env', 'OPTIONS'],
|
||||
value: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
user_actions: [],
|
||||
timeout: 1,
|
||||
timeout_unit: 'day',
|
||||
@@ -84,6 +103,8 @@ describe('variable utils', () => {
|
||||
expect(getNodeUsedVars(node)).toEqual(
|
||||
expect.arrayContaining([
|
||||
['env', 'API_KEY'],
|
||||
['conversation', 'memory'],
|
||||
['env', 'OPTIONS'],
|
||||
]),
|
||||
)
|
||||
})
|
||||
@@ -129,7 +150,26 @@ describe('variable utils', () => {
|
||||
title: 'Human Input',
|
||||
desc: '',
|
||||
form_content: '',
|
||||
inputs: [],
|
||||
inputs: [
|
||||
{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'summary',
|
||||
default: {
|
||||
type: 'variable',
|
||||
selector: ['env', 'API_KEY'],
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'decision',
|
||||
option_source: {
|
||||
type: 'variable',
|
||||
selector: ['env', 'API_KEY'],
|
||||
value: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
user_actions: [],
|
||||
timeout: 1,
|
||||
timeout_unit: 'day',
|
||||
@@ -154,6 +194,16 @@ describe('variable utils', () => {
|
||||
subject: 'Subject {{#conversation.memory#}}',
|
||||
body: 'Body {{#env.RENAMED_KEY#}}',
|
||||
})
|
||||
expect((updatedNode.data as HumanInputNodeType).inputs[0]).toMatchObject({
|
||||
default: {
|
||||
selector: ['env', 'RENAMED_KEY'],
|
||||
},
|
||||
})
|
||||
expect((updatedNode.data as HumanInputNodeType).inputs[1]).toMatchObject({
|
||||
option_source: {
|
||||
selector: ['env', 'RENAMED_KEY'],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1530,7 +1530,14 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
|
||||
return []
|
||||
return [method.config.body]
|
||||
})
|
||||
res = matchNotSystemVars([formContent, ...mailTemplates])
|
||||
const inputSelectors = payload.inputs.flatMap((input) => {
|
||||
if (input.type === InputVarType.paragraph && input.default.type === 'variable')
|
||||
return [input.default.selector]
|
||||
if (input.type === InputVarType.select && input.option_source.type === 'variable')
|
||||
return [input.option_source.selector]
|
||||
return []
|
||||
})
|
||||
res = [...matchNotSystemVars([formContent, ...mailTemplates]), ...inputSelectors]
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -2032,6 +2039,15 @@ export const updateNodeVars = (
|
||||
},
|
||||
}
|
||||
})
|
||||
payload.inputs = payload.inputs.map((input) => {
|
||||
if (input.type === InputVarType.paragraph && input.default.type === 'variable' && input.default.selector.join('.') === oldVarSelector.join('.')) {
|
||||
input.default.selector = newVarSelector
|
||||
}
|
||||
if (input.type === InputVarType.select && input.option_source.type === 'variable' && input.option_source.selector.join('.') === oldVarSelector.join('.')) {
|
||||
input.option_source.selector = newVarSelector
|
||||
}
|
||||
return input
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +444,88 @@ describe('DSL Import with Human Input Node', () => {
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate that enabled email delivery methods have complete configuration', () => {
|
||||
const t = (key: string) => key
|
||||
const payload = {
|
||||
...humanInputDefault.defaultValue,
|
||||
delivery_methods: [
|
||||
{
|
||||
id: 'dm-email',
|
||||
type: DeliveryMethodType.Email,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
user_actions: [
|
||||
{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary },
|
||||
],
|
||||
} as HumanInputNodeType
|
||||
|
||||
const result = humanInputDefault.checkValid(payload, t)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.errorMessage).toBe('nodes.humanInput.errorMsg.emailConfigIncomplete')
|
||||
})
|
||||
|
||||
it('should validate email delivery config fields before user actions', () => {
|
||||
const t = (key: string) => key
|
||||
const payload = {
|
||||
...humanInputDefault.defaultValue,
|
||||
delivery_methods: [
|
||||
{
|
||||
id: 'dm-email',
|
||||
type: DeliveryMethodType.Email,
|
||||
enabled: true,
|
||||
config: {
|
||||
recipients: { whole_workspace: false, items: [] },
|
||||
subject: 'Review request',
|
||||
body: 'Please review {{#url#}}',
|
||||
debug_mode: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
user_actions: [
|
||||
{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary },
|
||||
],
|
||||
} as HumanInputNodeType
|
||||
|
||||
const result = humanInputDefault.checkValid(payload, t)
|
||||
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.errorMessage).toBe('nodes.humanInput.errorMsg.emailConfigIncomplete')
|
||||
})
|
||||
|
||||
it('should validate enabled email subject and body content', () => {
|
||||
const t = (key: string) => key
|
||||
const createPayload = (body: string, subject = 'Review request') => ({
|
||||
...humanInputDefault.defaultValue,
|
||||
delivery_methods: [
|
||||
{
|
||||
id: 'dm-email',
|
||||
type: DeliveryMethodType.Email,
|
||||
enabled: true,
|
||||
config: {
|
||||
recipients: { whole_workspace: true, items: [] },
|
||||
subject,
|
||||
body,
|
||||
debug_mode: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
user_actions: [
|
||||
{ id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary },
|
||||
],
|
||||
}) as HumanInputNodeType
|
||||
|
||||
expect(humanInputDefault.checkValid(createPayload('{{#url#}}', ' '), t)).toEqual({
|
||||
isValid: false,
|
||||
errorMessage: 'nodes.humanInput.errorMsg.emailConfigIncomplete',
|
||||
})
|
||||
expect(humanInputDefault.checkValid(createPayload('Please review'), t)).toEqual({
|
||||
isValid: false,
|
||||
errorMessage: 'nodes.humanInput.errorMsg.emailConfigIncomplete',
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate that user actions are required', () => {
|
||||
const t = (key: string) => key
|
||||
const payload = {
|
||||
@@ -477,6 +559,30 @@ describe('DSL Import with Human Input Node', () => {
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate that user action ids and titles are not empty', () => {
|
||||
const t = (key: string) => key
|
||||
const createPayload = (userActions: HumanInputNodeType['user_actions']) => ({
|
||||
...humanInputDefault.defaultValue,
|
||||
delivery_methods: [
|
||||
{ id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true },
|
||||
],
|
||||
user_actions: userActions,
|
||||
}) as HumanInputNodeType
|
||||
|
||||
expect(humanInputDefault.checkValid(createPayload([
|
||||
{ id: ' ', title: 'Approve', button_style: UserActionButtonType.Primary },
|
||||
]), t)).toEqual({
|
||||
isValid: false,
|
||||
errorMessage: 'nodes.humanInput.errorMsg.emptyActionId',
|
||||
})
|
||||
expect(humanInputDefault.checkValid(createPayload([
|
||||
{ id: 'approve', title: ' ', button_style: UserActionButtonType.Primary },
|
||||
]), t)).toEqual({
|
||||
isValid: false,
|
||||
errorMessage: 'nodes.humanInput.errorMsg.emptyActionTitle',
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass validation with correct configuration', () => {
|
||||
const t = (key: string) => key
|
||||
const payload = {
|
||||
@@ -503,8 +609,10 @@ describe('DSL Import with Human Input Node', () => {
|
||||
const payload = {
|
||||
...humanInputDefault.defaultValue,
|
||||
inputs: [
|
||||
{ type: 'text-input', output_variable_name: 'review_result', default: { selector: [], type: 'constant' as const, value: '' } },
|
||||
{ type: 'text-input', output_variable_name: 'comment', default: { selector: [], type: 'constant' as const, value: '' } },
|
||||
{ type: 'paragraph', output_variable_name: 'review_result', default: { selector: [], type: 'constant' as const, value: '' } },
|
||||
{ type: 'file', output_variable_name: 'attachment', allowed_file_extensions: [], allowed_file_types: [], allowed_file_upload_methods: [] },
|
||||
{ type: 'file-list', output_variable_name: 'attachments', allowed_file_extensions: [], allowed_file_types: [], allowed_file_upload_methods: [], number_limits: 3 },
|
||||
{ type: 'select', output_variable_name: 'comment', option_source: { type: 'constant', selector: [], value: ['A', 'B'] } },
|
||||
],
|
||||
} as HumanInputNodeType
|
||||
|
||||
@@ -512,6 +620,8 @@ describe('DSL Import with Human Input Node', () => {
|
||||
|
||||
expect(outputVars).toEqual([
|
||||
{ variable: 'review_result', type: 'string' },
|
||||
{ variable: 'attachment', type: 'file' },
|
||||
{ variable: 'attachments', type: 'array[file]' },
|
||||
{ variable: 'comment', type: 'string' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ const createData = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNode
|
||||
}],
|
||||
form_content: 'Please review this request',
|
||||
inputs: [{
|
||||
type: InputVarType.textInput,
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'review_result',
|
||||
default: {
|
||||
selector: [],
|
||||
|
||||
@@ -98,7 +98,7 @@ vi.mock('../components/form-content', () => ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onFormInputsChange([{
|
||||
type: InputVarType.textInput,
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'email',
|
||||
default: {
|
||||
selector: [],
|
||||
@@ -225,7 +225,7 @@ const createData = (overrides: Partial<HumanInputNodeType> = {}): HumanInputNode
|
||||
}],
|
||||
form_content: 'Please review this request',
|
||||
inputs: [{
|
||||
type: InputVarType.textInput,
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'review_result',
|
||||
default: {
|
||||
selector: [],
|
||||
@@ -286,6 +286,9 @@ describe('human-input/panel', () => {
|
||||
availableVars: [{
|
||||
variable: ['start', 'email'],
|
||||
type: VarType.string,
|
||||
}, {
|
||||
variable: ['code', 'result'],
|
||||
type: VarType.arrayString,
|
||||
}, {
|
||||
variable: ['start', 'files'],
|
||||
type: VarType.file,
|
||||
@@ -315,6 +318,12 @@ describe('human-input/panel', () => {
|
||||
expect(screen.getByText('__action_id:string:Action ID user triggered')).toBeInTheDocument()
|
||||
expect(screen.getByText('__action_value:string:Selected action value')).toBeInTheDocument()
|
||||
expect(screen.getByText('__rendered_content:string:Rendered content')).toBeInTheDocument()
|
||||
expect(mockDeliveryMethod).toHaveBeenCalledWith(expect.objectContaining({
|
||||
nodesOutputVars: [
|
||||
expect.objectContaining({ type: VarType.string }),
|
||||
expect.objectContaining({ type: VarType.arrayString }),
|
||||
],
|
||||
}))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'delivery-method:editable' }))
|
||||
await user.click(screen.getByRole('button', { name: /workflow\.nodes\.humanInput\.formContent\.preview/ }))
|
||||
@@ -378,4 +387,33 @@ describe('human-input/panel', () => {
|
||||
expect(screen.getByRole('button', { name: 'timeout:readonly' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('form-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders file outputs with file-aware types', () => {
|
||||
mockUseConfig.mockReturnValue(createConfigResult({
|
||||
inputs: createData({
|
||||
inputs: [
|
||||
{
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'attachment',
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_upload_methods: [],
|
||||
},
|
||||
{
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'attachments',
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_upload_methods: [],
|
||||
number_limits: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}))
|
||||
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('attachment:file:Form input value')).toBeInTheDocument()
|
||||
expect(screen.getByText('attachments:array[file]:Form input value')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import {
|
||||
createDefaultFormInputByType,
|
||||
createDefaultParagraphFormInput,
|
||||
isFileFormInput,
|
||||
isFileListFormInput,
|
||||
isParagraphFormInput,
|
||||
isSelectFormInput,
|
||||
} from '../types'
|
||||
|
||||
describe('human-input/types', () => {
|
||||
describe('type guards', () => {
|
||||
it('should identify each form input kind', () => {
|
||||
const paragraph = createDefaultFormInputByType(InputVarType.paragraph, 'comment')
|
||||
const select = createDefaultFormInputByType(InputVarType.select, 'decision')
|
||||
const singleFile = createDefaultFormInputByType(InputVarType.singleFile, 'attachment')
|
||||
const multiFiles = createDefaultFormInputByType(InputVarType.multiFiles, 'attachments')
|
||||
|
||||
expect(isParagraphFormInput(paragraph)).toBe(true)
|
||||
expect(isParagraphFormInput(select)).toBe(false)
|
||||
expect(isSelectFormInput(select)).toBe(true)
|
||||
expect(isSelectFormInput(singleFile)).toBe(false)
|
||||
expect(isFileFormInput(singleFile)).toBe(true)
|
||||
expect(isFileFormInput(multiFiles)).toBe(false)
|
||||
expect(isFileListFormInput(multiFiles)).toBe(true)
|
||||
expect(isFileListFormInput(paragraph)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('default factories', () => {
|
||||
it('should create a paragraph input with an empty constant default', () => {
|
||||
expect(createDefaultParagraphFormInput('review')).toEqual({
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'review',
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: '',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should create defaults for every supported input type', () => {
|
||||
expect(createDefaultFormInputByType(InputVarType.select, 'choice')).toEqual({
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'choice',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: [],
|
||||
},
|
||||
})
|
||||
|
||||
expect(createDefaultFormInputByType(InputVarType.singleFile, 'file')).toEqual({
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'file',
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
})
|
||||
|
||||
expect(createDefaultFormInputByType(InputVarType.multiFiles, 'files')).toEqual({
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'files',
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
number_limits: 5,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import type { FormInputItem } from '../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import AddInputField from '../add-input-field'
|
||||
|
||||
const mockInputField = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor/plugins/hitl-input-block/input-field', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
nodeId: string
|
||||
isEdit: boolean
|
||||
unavailableVariableNames?: string[]
|
||||
onChange: (newPayload: FormInputItem) => void
|
||||
onCancel: () => void
|
||||
}) => {
|
||||
mockInputField(props)
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onChange({
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'comment',
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: '',
|
||||
},
|
||||
})}
|
||||
>
|
||||
save
|
||||
</button>
|
||||
<button type="button" onClick={props.onCancel}>cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('human-input/components/add-input-field', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render InputField in create mode and forward callbacks', () => {
|
||||
const handleSave = vi.fn()
|
||||
const handleCancel = vi.fn()
|
||||
|
||||
render(
|
||||
<AddInputField
|
||||
nodeId="human-node"
|
||||
unavailableVariableNames={['comment']}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockInputField).toHaveBeenCalledWith(expect.objectContaining({
|
||||
nodeId: 'human-node',
|
||||
isEdit: false,
|
||||
unavailableVariableNames: ['comment'],
|
||||
onChange: handleSave,
|
||||
onCancel: handleCancel,
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'save' }))
|
||||
expect(handleSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'comment',
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'cancel' }))
|
||||
expect(handleCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
+28
-3
@@ -64,10 +64,14 @@ vi.mock('../variable-in-markdown', () => ({
|
||||
rehypeNotes: vi.fn(),
|
||||
rehypeVariable: vi.fn(),
|
||||
Variable: ({ path }: { path: string }) => <div data-testid="variable-path">{path}</div>,
|
||||
Note: ({ defaultInput, nodeName }: {
|
||||
defaultInput: { selector: string[] }
|
||||
Note: ({ input, nodeName }: {
|
||||
input: { type: string, default?: { selector: string[] }, option_source?: { selector: string[] } }
|
||||
nodeName: (nodeId: string) => string
|
||||
}) => <div data-testid="note">{nodeName(defaultInput.selector[0]!)}</div>,
|
||||
}) => (
|
||||
<div data-testid="note">
|
||||
{input.default?.selector?.length ? nodeName(input.default.selector[0]!) : input.option_source?.selector?.join('.') || input.type}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('FormContentPreview', () => {
|
||||
@@ -131,4 +135,25 @@ describe('FormContentPreview', () => {
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should pass non-paragraph inputs through the preview note renderer', () => {
|
||||
render(
|
||||
<FormContentPreview
|
||||
content="content"
|
||||
formInputs={[{
|
||||
type: 'select' as never,
|
||||
output_variable_name: 'field_1',
|
||||
option_source: {
|
||||
type: 'variable',
|
||||
selector: ['node-1', 'items'],
|
||||
value: [],
|
||||
},
|
||||
}]}
|
||||
userActions={[]}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('note')).toHaveTextContent('node-1.items')
|
||||
})
|
||||
})
|
||||
|
||||
+73
-3
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import FormContent from '../form-content'
|
||||
|
||||
const mockUseTranslation = vi.hoisted(() => vi.fn())
|
||||
@@ -54,12 +55,13 @@ vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
}) => {
|
||||
mockPromptEditor(props)
|
||||
const popup = props.shortcutPopups?.[0]
|
||||
const Popup = popup?.Popup
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={props.onFocus}>focus-editor</button>
|
||||
<button type="button" onClick={props.onBlur}>blur-editor</button>
|
||||
<button type="button" onClick={() => props.onChange('updated value')}>change-editor</button>
|
||||
{popup && popup.Popup({ onClose: vi.fn(), onInsert: mockOnInsert })}
|
||||
{Popup && <Popup onClose={vi.fn()} onInsert={mockOnInsert} />}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -67,7 +69,8 @@ vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
|
||||
vi.mock('../add-input-field', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
default: function MockAddInputField(props: {
|
||||
unavailableVariableNames?: string[]
|
||||
onSave: (payload: {
|
||||
type: string
|
||||
output_variable_name: string
|
||||
@@ -78,10 +81,17 @@ vi.mock('../add-input-field', () => ({
|
||||
}
|
||||
}) => void
|
||||
onCancel: () => void
|
||||
}) => {
|
||||
}) {
|
||||
const [draftName, setDraftName] = React.useState('')
|
||||
|
||||
mockAddInputField(props)
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
aria-label="field-name"
|
||||
value={draftName}
|
||||
onChange={event => setDraftName(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onSave({
|
||||
@@ -235,6 +245,41 @@ describe('FormContent', () => {
|
||||
expect(container.firstChild).toHaveClass('pointer-events-none')
|
||||
})
|
||||
|
||||
it('should not insert a new input when the variable name already exists', () => {
|
||||
render(
|
||||
<FormContent
|
||||
nodeId="node-2"
|
||||
value="Initial content"
|
||||
onChange={onChange}
|
||||
formInputs={[{
|
||||
type: 'paragraph',
|
||||
output_variable_name: 'approval',
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: '',
|
||||
},
|
||||
} as never]}
|
||||
onFormInputsChange={onFormInputsChange}
|
||||
onFormInputItemRename={onFormInputItemRename}
|
||||
onFormInputItemRemove={onFormInputItemRemove}
|
||||
editorKey={1}
|
||||
isExpand={false}
|
||||
availableVars={[]}
|
||||
availableNodes={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockAddInputField).toHaveBeenCalledWith(expect.objectContaining({
|
||||
unavailableVariableNames: ['approval'],
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByText('save-input'))
|
||||
|
||||
expect(mockOnInsert).not.toHaveBeenCalled()
|
||||
expect(onFormInputsChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render the mac hotkey hint when focused on macOS', () => {
|
||||
mockFormatForDisplay.mockReturnValue('⌘')
|
||||
|
||||
@@ -259,4 +304,29 @@ describe('FormContent', () => {
|
||||
expect(screen.getByText('⌘')).toBeInTheDocument()
|
||||
expect(screen.getByText('/')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve add-input draft state across parent rerenders', () => {
|
||||
const props = {
|
||||
nodeId: 'node-2',
|
||||
value: 'Initial content',
|
||||
onChange,
|
||||
formInputs: [],
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
editorKey: 1,
|
||||
isExpand: false,
|
||||
availableVars: [],
|
||||
availableNodes: [],
|
||||
}
|
||||
const { rerender } = render(<FormContent {...props} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'field-name' }), {
|
||||
target: { value: 'approval_name' },
|
||||
})
|
||||
|
||||
rerender(<FormContent {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'field-name' })).toHaveValue('approval_name')
|
||||
})
|
||||
})
|
||||
|
||||
+147
-18
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -12,25 +13,54 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/answer/human-input-content/content-item', () => ({
|
||||
__esModule: true,
|
||||
default: ({ content }: { content: string }) => <div>{content}</div>,
|
||||
default: ({
|
||||
content,
|
||||
formInputFields,
|
||||
inputs,
|
||||
onInputChange,
|
||||
}: {
|
||||
content: string
|
||||
formInputFields: FormInputItem[]
|
||||
inputs: Record<string, HumanInputFieldValue>
|
||||
onInputChange: (name: string, value: HumanInputFieldValue) => void
|
||||
}) => {
|
||||
const fieldName = /\{\{#\$output\.([^#]+)#\}\}/.exec(content)?.[1]
|
||||
if (!fieldName)
|
||||
return <div>{content}</div>
|
||||
|
||||
const field = formInputFields.find(field => field.output_variable_name === fieldName)
|
||||
if (!field)
|
||||
return null
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<select
|
||||
aria-label={fieldName}
|
||||
value={typeof inputs[fieldName] === 'string' ? inputs[fieldName] : ''}
|
||||
onChange={event => onInputChange(fieldName, event.target.value)}
|
||||
>
|
||||
<option value="">Select</option>
|
||||
{field.option_source.value.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'paragraph') {
|
||||
return (
|
||||
<textarea
|
||||
aria-label={fieldName}
|
||||
value={typeof inputs[fieldName] === 'string' ? inputs[fieldName] : ''}
|
||||
onChange={event => onInputChange(fieldName, event.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <div>{fieldName}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInputFormData => ({
|
||||
@@ -60,6 +90,10 @@ const createFormData = (overrides: Partial<HumanInputFormData> = {}): HumanInput
|
||||
})
|
||||
|
||||
describe('SingleRunForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the back action as a named button and forwards clicks', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleBack = vi.fn()
|
||||
@@ -99,4 +133,99 @@ describe('SingleRunForm', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('submits updated paragraph input values', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<SingleRunForm
|
||||
nodeName="Review"
|
||||
data={createFormData()}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.clear(screen.getByRole('textbox', { name: 'review' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'review' }), 'updated review')
|
||||
await user.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
inputs: { review: 'updated review' },
|
||||
action: 'approve',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('uses resolved default values for variable paragraph inputs', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<SingleRunForm
|
||||
nodeName="Review"
|
||||
data={createFormData({
|
||||
inputs: [{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'review',
|
||||
default: {
|
||||
selector: ['source', 'answer'],
|
||||
type: 'variable',
|
||||
value: 'fallback review',
|
||||
},
|
||||
}],
|
||||
resolved_default_values: {
|
||||
review: 'resolved review',
|
||||
},
|
||||
})}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
inputs: { review: 'resolved review' },
|
||||
action: 'approve',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('disables submit actions until a select input has a value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<SingleRunForm
|
||||
nodeName="Review"
|
||||
data={createFormData({
|
||||
form_content: 'Choose {{#$output.choice#}}',
|
||||
inputs: [{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'choice',
|
||||
option_source: {
|
||||
selector: [],
|
||||
type: 'constant',
|
||||
value: ['approve', 'reject'],
|
||||
},
|
||||
}],
|
||||
})}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled()
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'choice' }), 'approve')
|
||||
await user.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
inputs: { choice: 'approve' },
|
||||
action: 'approve',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+102
-8
@@ -1,4 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { Note, rehypeNotes, rehypeVariable, Variable } from '../variable-in-markdown'
|
||||
|
||||
describe('variable-in-markdown', () => {
|
||||
@@ -86,10 +89,14 @@ describe('variable-in-markdown', () => {
|
||||
it('should render note values and replace node ids with labels for variable defaults', () => {
|
||||
const { rerender } = render(
|
||||
<Note
|
||||
defaultInput={{
|
||||
type: 'variable',
|
||||
selector: ['node-1', 'output'],
|
||||
value: '',
|
||||
input={{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'approval',
|
||||
default: {
|
||||
type: 'variable',
|
||||
selector: ['node-1', 'output'],
|
||||
value: '',
|
||||
},
|
||||
}}
|
||||
nodeName={nodeId => nodeId === 'node-1' ? 'Start Node' : nodeId}
|
||||
/>,
|
||||
@@ -99,10 +106,14 @@ describe('variable-in-markdown', () => {
|
||||
|
||||
rerender(
|
||||
<Note
|
||||
defaultInput={{
|
||||
type: 'constant',
|
||||
value: 'Plain value',
|
||||
selector: [],
|
||||
input={{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'approval',
|
||||
default: {
|
||||
type: 'constant',
|
||||
value: 'Plain value',
|
||||
selector: [],
|
||||
},
|
||||
}}
|
||||
nodeName={nodeId => nodeId}
|
||||
/>,
|
||||
@@ -110,5 +121,88 @@ describe('variable-in-markdown', () => {
|
||||
|
||||
expect(screen.getByText('Plain value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a select preview control for select inputs', () => {
|
||||
render(
|
||||
<Note
|
||||
input={{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'approval',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: ['Approved', 'Rejected'],
|
||||
},
|
||||
}}
|
||||
nodeName={nodeId => nodeId}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('human-input-note-select-preview')).toBeInTheDocument()
|
||||
expect(screen.getByText('Approved')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dynamic select inputs as variable information without a select control', () => {
|
||||
render(
|
||||
<Note
|
||||
input={{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'approval',
|
||||
option_source: {
|
||||
type: 'variable',
|
||||
selector: ['node-1', 'options'],
|
||||
value: [],
|
||||
},
|
||||
}}
|
||||
nodeName={nodeId => nodeId === 'node-1' ? 'Start Node' : nodeId}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('human-input-note-select-preview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('combobox', { name: 'human-input-note-select' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('{{Start Node/options}}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the select preview and show option items', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<Note
|
||||
input={{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'approval',
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: ['Approved', 'Rejected'],
|
||||
},
|
||||
}}
|
||||
nodeName={nodeId => nodeId}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: 'human-input-note-select' }))
|
||||
|
||||
expect(await screen.findByRole('option', { name: 'Rejected' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render upload placeholders for file inputs', () => {
|
||||
render(
|
||||
<Note
|
||||
input={{
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'attachment',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: [],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
}}
|
||||
nodeName={nodeId => nodeId}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('human-input-note-file-preview')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.fileUploader.uploadFromComputer')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.fileUploader.pasteFileLink')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,12 +6,14 @@ import InputField from '@/app/components/base/prompt-editor/plugins/hitl-input-b
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
unavailableVariableNames?: string[]
|
||||
onSave: (newPayload: FormInputItem) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const AddInputField: FC<Props> = ({
|
||||
nodeId,
|
||||
unavailableVariableNames,
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
@@ -19,6 +21,7 @@ const AddInputField: FC<Props> = ({
|
||||
<InputField
|
||||
nodeId={nodeId}
|
||||
isEdit={false}
|
||||
unavailableVariableNames={unavailableVariableNames}
|
||||
onChange={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ const createEmailConfig = (overrides: Partial<EmailConfig> = {}): EmailConfig =>
|
||||
})
|
||||
|
||||
const formInputs: FormInputItem[] = [{
|
||||
type: InputVarType.textInput,
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'name',
|
||||
default: {
|
||||
selector: ['start', 'name'],
|
||||
|
||||
+214
-5
@@ -1,16 +1,26 @@
|
||||
/* eslint-disable react/no-context-provider -- use-context-selector contexts require .Provider in tests. */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { EmailConfig, FormInputItem } from '../../../types'
|
||||
import type { EmailConfig, FormInputItem, ParagraphFormInput, SelectFormInput } from '../../../types'
|
||||
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider'
|
||||
import { createHooksStore } from '@/app/components/workflow/hooks-store/store'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
|
||||
import { AppContext, initialLangGeniusVersionInfo, initialWorkspaceInfo, userProfilePlaceholder } from '@/context/app-context'
|
||||
import EmailSenderModal from '../test-email-sender'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
type RecordedRequest = {
|
||||
url: string
|
||||
method: string
|
||||
@@ -145,8 +155,8 @@ const createConfig = (overrides: Partial<EmailConfig> = {}): EmailConfig => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem => ({
|
||||
type: InputVarType.textInput,
|
||||
const createFormInput = (overrides: Partial<ParagraphFormInput> = {}): FormInputItem => ({
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'user_name',
|
||||
default: {
|
||||
type: 'variable',
|
||||
@@ -156,6 +166,16 @@ const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createDynamicSelectInput = (): SelectFormInput => ({
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'decision',
|
||||
option_source: {
|
||||
type: 'variable',
|
||||
selector: ['code', 'result'],
|
||||
value: [],
|
||||
},
|
||||
})
|
||||
|
||||
describe('human-input/delivery-method/test-email-sender', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -233,7 +253,7 @@ describe('human-input/delivery-method/test-email-sender', () => {
|
||||
delivery_method_id: 'delivery-1',
|
||||
inputs: {
|
||||
'#start.user_name#': 'Ada',
|
||||
'#start.score#': '42',
|
||||
'#start.score#': 42,
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -243,6 +263,81 @@ describe('human-input/delivery-method/test-email-sender', () => {
|
||||
expect(handleOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should submit variables referenced by dynamic select option sources', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { requests } = setupFetch()
|
||||
|
||||
renderWithProviders(
|
||||
<EmailSenderModal
|
||||
nodeId="human-node"
|
||||
deliveryId="delivery-1"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
jumpToEmailConfigModal={vi.fn()}
|
||||
config={createConfig({
|
||||
body: '{{#url#}}',
|
||||
})}
|
||||
formInputs={[createDynamicSelectInput()]}
|
||||
availableNodes={[
|
||||
{
|
||||
id: 'code',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
type: BlockEnum.Code,
|
||||
variables: [],
|
||||
code_language: CodeLanguage.python3,
|
||||
code: '',
|
||||
outputs: {
|
||||
result: {
|
||||
type: VarType.arrayString,
|
||||
children: null,
|
||||
},
|
||||
},
|
||||
} as CodeNodeType,
|
||||
},
|
||||
]}
|
||||
nodesOutputVars={[
|
||||
{
|
||||
nodeId: 'code',
|
||||
title: 'Code',
|
||||
vars: [
|
||||
{
|
||||
variable: 'result',
|
||||
type: VarType.arrayString,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
const sendButton = screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' })
|
||||
expect(sendButton).toBeDisabled()
|
||||
|
||||
expect(screen.queryByPlaceholderText('result')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByTestId('monaco-editor'), {
|
||||
target: { value: '["approve","reject"]' },
|
||||
})
|
||||
expect(sendButton).toBeEnabled()
|
||||
|
||||
await user.click(sendButton)
|
||||
|
||||
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
|
||||
url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test',
|
||||
method: 'POST',
|
||||
body: {
|
||||
delivery_method_id: 'delivery-1',
|
||||
inputs: {
|
||||
'#code.result#': ['approve', 'reject'],
|
||||
},
|
||||
},
|
||||
})))
|
||||
})
|
||||
|
||||
it('should render fallback variable inputs and allow cancelling', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupFetch()
|
||||
@@ -295,4 +390,118 @@ describe('human-input/delivery-method/test-email-sender', () => {
|
||||
expect(screen.getByText('external@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('nodes.humanInput.deliveryMethod.emailSender.tip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show a validation toast when generated JSON input is invalid', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { requests } = setupFetch()
|
||||
|
||||
renderWithProviders(
|
||||
<EmailSenderModal
|
||||
nodeId="human-node"
|
||||
deliveryId="delivery-1"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
jumpToEmailConfigModal={vi.fn()}
|
||||
config={createConfig({
|
||||
body: 'Please review {{#code.payload#}}',
|
||||
})}
|
||||
availableNodes={[
|
||||
{
|
||||
id: 'code',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Code',
|
||||
desc: '',
|
||||
type: BlockEnum.Code,
|
||||
variables: [],
|
||||
code_language: CodeLanguage.python3,
|
||||
code: '',
|
||||
outputs: {
|
||||
payload: {
|
||||
type: VarType.object,
|
||||
children: null,
|
||||
},
|
||||
},
|
||||
} as CodeNodeType,
|
||||
},
|
||||
]}
|
||||
nodesOutputVars={[
|
||||
{
|
||||
nodeId: 'code',
|
||||
title: 'Code',
|
||||
vars: [
|
||||
{
|
||||
variable: 'payload',
|
||||
type: VarType.object,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('monaco-editor'), {
|
||||
target: { value: '{invalid' },
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' }))
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('workflow.errorMsg.invalidJson:{"field":"payload"}')
|
||||
expect(requests).not.toContainEqual(expect.objectContaining({
|
||||
url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test',
|
||||
method: 'POST',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show debug success copy after sending in debug mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupFetch()
|
||||
|
||||
renderWithProviders(
|
||||
<EmailSenderModal
|
||||
nodeId="human-node"
|
||||
deliveryId="delivery-1"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
jumpToEmailConfigModal={vi.fn()}
|
||||
config={createConfig({
|
||||
body: 'Plain body',
|
||||
debug_mode: true,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.debugModeTip')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('nodes.humanInput.deliveryMethod.emailSender.debugDone')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show specific-recipient success copy after sending', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupFetch()
|
||||
|
||||
renderWithProviders(
|
||||
<EmailSenderModal
|
||||
nodeId="human-node"
|
||||
deliveryId="delivery-1"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
jumpToEmailConfigModal={vi.fn()}
|
||||
config={createConfig({
|
||||
body: 'Plain body',
|
||||
recipients: {
|
||||
whole_workspace: false,
|
||||
items: [{ type: 'external', email: 'external@example.com' }],
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3')).toBeInTheDocument())
|
||||
expect(screen.getByText('external@example.com')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+34
@@ -136,6 +136,40 @@ describe('human-input/delivery-method/recipient/email-input', () => {
|
||||
expect(handleSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should keep typing focused and stop keyboard events from reaching workflow listeners', () => {
|
||||
const handleParentKeyDown = vi.fn()
|
||||
const handleWindowKeyDown = vi.fn()
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
|
||||
try {
|
||||
render(
|
||||
<div onKeyDown={handleParentKeyDown}>
|
||||
<EmailInput
|
||||
email="owner@example.com"
|
||||
value={[]}
|
||||
list={members}
|
||||
onDelete={vi.fn()}
|
||||
onSelect={vi.fn()}
|
||||
onAdd={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
input.focus()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'a' } })
|
||||
fireEvent.keyDown(input, { key: 'a', code: 'KeyA' })
|
||||
|
||||
expect(document.activeElement).toBe(input)
|
||||
expect(handleParentKeyDown).not.toHaveBeenCalled()
|
||||
expect(handleWindowKeyDown).not.toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
}
|
||||
})
|
||||
|
||||
it('should delete the last recipient with backspace, flag missing members as errors, and stop focusing when disabled', () => {
|
||||
const handleDelete = vi.fn()
|
||||
const { container, rerender } = render(
|
||||
|
||||
+42
-1
@@ -11,9 +11,22 @@ vi.mock('../member-list', () => ({
|
||||
searchValue: string
|
||||
list: Member[]
|
||||
email: string
|
||||
onSearchChange: (value: string) => void
|
||||
onSelect: (memberId: string) => void
|
||||
}) => {
|
||||
mockMemberList(props)
|
||||
return <div data-testid="member-list" />
|
||||
return (
|
||||
<div data-testid="member-list">
|
||||
<input
|
||||
aria-label="member search"
|
||||
value={props.searchValue}
|
||||
onChange={e => props.onSearchChange(e.target.value)}
|
||||
/>
|
||||
<button type="button" onClick={() => props.onSelect('member-1')}>
|
||||
select member
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -66,4 +79,32 @@ describe('human-input/delivery-method/recipient/member-selector', () => {
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByTestId('member-list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update search value and close the list after selecting a member', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<MemberSelector
|
||||
value={[]}
|
||||
email="owner@example.com"
|
||||
onSelect={handleSelect}
|
||||
list={members}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', {
|
||||
name: 'workflow.nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger',
|
||||
}))
|
||||
await user.type(screen.getByRole('textbox', { name: 'member search' }), 'member one')
|
||||
|
||||
expect(mockMemberList).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
searchValue: 'member one',
|
||||
}))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select member' }))
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith('member-1')
|
||||
expect(screen.queryByTestId('member-list')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+3
@@ -104,6 +104,8 @@ const EmailInput = ({
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation()
|
||||
|
||||
if (e.key === 'Enter' || e.key === 'Tab' || e.key === ' ' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
handleEmailAdd()
|
||||
@@ -155,6 +157,7 @@ const EmailInput = ({
|
||||
sideOffset={4}
|
||||
alignOffset={-40}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
popupProps={{ initialFocus: false, finalFocus: false }}
|
||||
positionerProps={{ anchor: inputRef }}
|
||||
>
|
||||
<MemberList
|
||||
|
||||
+52
-12
@@ -2,12 +2,12 @@ import type { EmailConfig, FormInputItem } from '../../types'
|
||||
import type {
|
||||
Node,
|
||||
NodeOutPutVar,
|
||||
ValueSelector,
|
||||
Var,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiArrowRightSFill } from '@remixicon/react'
|
||||
import { noop, unionBy } from 'es-toolkit/compat'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
@@ -16,6 +16,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants'
|
||||
import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item'
|
||||
import { formatValue } from '@/app/components/workflow/nodes/_base/components/before-run-form/helpers'
|
||||
import {
|
||||
getNodeInfoById,
|
||||
isConversationVar,
|
||||
@@ -26,7 +27,7 @@ import { InputVarType, VarType } from '@/app/components/workflow/types'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { useTestEmailSender } from '@/service/use-workflow'
|
||||
import { isOutput } from '../../utils'
|
||||
import { getHumanInputFormDependencySelectors, isOutput } from '../../utils'
|
||||
import EmailInput from './recipient/email-input'
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput'
|
||||
@@ -70,6 +71,45 @@ const getOriginVar = (valueSelector: string[], list: NodeOutPutVar[]) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const varTypeToInputVarType = (type: VarType) => {
|
||||
if (type === VarType.number)
|
||||
return InputVarType.number
|
||||
if (type === VarType.boolean)
|
||||
return InputVarType.checkbox
|
||||
if ([VarType.object, VarType.array, VarType.arrayNumber, VarType.arrayString, VarType.arrayObject].includes(type))
|
||||
return InputVarType.json
|
||||
if (type === VarType.file)
|
||||
return InputVarType.singleFile
|
||||
if (type === VarType.arrayFile)
|
||||
return InputVarType.multiFiles
|
||||
|
||||
return InputVarType.textInput
|
||||
}
|
||||
|
||||
const formatEmailSenderInputs = (
|
||||
variables: Array<{ variable: string, type: InputVarType, label: unknown }>,
|
||||
values: Record<string, unknown>,
|
||||
) => {
|
||||
const formattedValues: Record<string, unknown> = {}
|
||||
let parseErrorJsonField = ''
|
||||
|
||||
variables.forEach((variable) => {
|
||||
try {
|
||||
formattedValues[variable.variable] = formatValue(values[variable.variable], variable.type)
|
||||
}
|
||||
catch {
|
||||
parseErrorJsonField = typeof variable.label === 'object' && variable.label !== null && 'variable' in variable.label
|
||||
? String(variable.label.variable)
|
||||
: variable.variable
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
formattedValues,
|
||||
parseErrorJsonField,
|
||||
}
|
||||
}
|
||||
|
||||
const EmailSenderModal = ({
|
||||
nodeId,
|
||||
deliveryId,
|
||||
@@ -96,14 +136,9 @@ const EmailSenderModal = ({
|
||||
const accounts = members?.accounts || []
|
||||
|
||||
const generatedInputs = useMemo(() => {
|
||||
const defaultValueSelectors = (formInputs || []).reduce((acc, input) => {
|
||||
if (input.default.type === 'variable') {
|
||||
acc.push(input.default.selector)
|
||||
}
|
||||
return acc
|
||||
}, [] as ValueSelector[])
|
||||
const formInputDependencySelectors = getHumanInputFormDependencySelectors(formInputs || [])
|
||||
const valueSelectors = doGetInputVars((formContent || '') + (config?.body || ''))
|
||||
const variables = unionBy([...valueSelectors, ...defaultValueSelectors], item => item.join('.')).map((item) => {
|
||||
const variables = unionBy([...valueSelectors, ...formInputDependencySelectors], item => item.join('.')).map((item) => {
|
||||
const varInfo = getNodeInfoById(availableNodes, item[0]!)?.data
|
||||
|
||||
return {
|
||||
@@ -132,7 +167,7 @@ const EmailSenderModal = ({
|
||||
return {
|
||||
label: item.label || item.variable,
|
||||
variable: item.variable,
|
||||
type: originalVar.type === VarType.number ? InputVarType.number : InputVarType.textInput,
|
||||
type: varTypeToInputVarType(originalVar.type),
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
@@ -166,20 +201,25 @@ const EmailSenderModal = ({
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!confirmChecked)
|
||||
return
|
||||
const { formattedValues, parseErrorJsonField } = formatEmailSenderInputs(generatedInputs, inputs)
|
||||
if (parseErrorJsonField) {
|
||||
toast.error(t('errorMsg.invalidJson', { ns: 'workflow', field: parseErrorJsonField }))
|
||||
return
|
||||
}
|
||||
setSendingEmail(true)
|
||||
try {
|
||||
await testEmailSender({
|
||||
appID: appDetail?.id || '',
|
||||
nodeID: nodeId,
|
||||
deliveryID: deliveryId,
|
||||
inputs,
|
||||
inputs: formattedValues,
|
||||
})
|
||||
setDone(true)
|
||||
}
|
||||
finally {
|
||||
setSendingEmail(false)
|
||||
}
|
||||
}, [confirmChecked, testEmailSender, appDetail?.id, nodeId, deliveryId, inputs])
|
||||
}, [confirmChecked, generatedInputs, inputs, testEmailSender, appDetail?.id, nodeId, deliveryId, t])
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
|
||||
@@ -38,6 +38,21 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({
|
||||
return node?.data.title || nodeId
|
||||
}, [nodes])
|
||||
|
||||
const renderInputPreview = React.useCallback(({ node }: { node?: { properties?: Record<string, unknown> } }) => {
|
||||
const name = String(node?.properties?.dataName ?? '')
|
||||
const input = formInputs.find(i => i.output_variable_name === name)
|
||||
if (!input) {
|
||||
return (
|
||||
<div>
|
||||
Can't find note:
|
||||
{name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <Note input={input} nodeName={nodeName} />
|
||||
}, [formInputs, nodeName])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-[112px] z-10 max-h-[calc(100vh-116px)] w-[600px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-3 shadow-xl"
|
||||
@@ -64,22 +79,7 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({
|
||||
}
|
||||
return <Variable path={newPath} />
|
||||
},
|
||||
section: ({ node }) => (() => {
|
||||
const name = String(node?.properties?.dataName ?? '')
|
||||
const input = formInputs.find(i => i.output_variable_name === name)
|
||||
if (!input) {
|
||||
return (
|
||||
<div>
|
||||
Can't find note:
|
||||
{name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const defaultInput = input.default
|
||||
return (
|
||||
<Note defaultInput={defaultInput!} nodeName={nodeName} />
|
||||
)
|
||||
})(),
|
||||
section: renderInputPreview,
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap gap-1 py-1">
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
'use client'
|
||||
import type { LexicalCommand } from 'lexical'
|
||||
import type { FC } from 'react'
|
||||
import type { FormInputItem } from '../types'
|
||||
import type { ShortcutPopupInsertHandler } from '@/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin'
|
||||
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
|
||||
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import { INSERT_HITL_INPUT_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/hitl-input-block'
|
||||
@@ -49,11 +50,20 @@ const FormContent: FC<FormContentProps> = ({
|
||||
|
||||
const getVarType = useWorkflowVariableType()
|
||||
|
||||
const [needToAddFormInput, setNeedToAddFormInput] = useState(false)
|
||||
const [newFormInputs, setNewFormInputs] = useState<FormInputItem[]>([])
|
||||
const handleInsertHITLNode = (onInsert: (command: LexicalCommand<unknown>, params: any) => void) => {
|
||||
const pendingFormInputsRef = useRef<{
|
||||
value: string
|
||||
formInputs: FormInputItem[]
|
||||
} | null>(null)
|
||||
const handleInsertHITLNode = useCallback((onInsert: ShortcutPopupInsertHandler) => {
|
||||
return (payload: FormInputItem) => {
|
||||
if (formInputs.some(input => input.output_variable_name === payload.output_variable_name))
|
||||
return
|
||||
|
||||
const newFormInputs = [...(formInputs || []), payload]
|
||||
pendingFormInputsRef.current = {
|
||||
value,
|
||||
formInputs: newFormInputs,
|
||||
}
|
||||
onInsert(INSERT_HITL_INPUT_BLOCK_COMMAND, {
|
||||
variableName: payload.output_variable_name,
|
||||
nodeId,
|
||||
@@ -62,30 +72,37 @@ const FormContent: FC<FormContentProps> = ({
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
})
|
||||
setNewFormInputs(newFormInputs)
|
||||
setNeedToAddFormInput(true)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
formInputs,
|
||||
nodeId,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRemove,
|
||||
onFormInputItemRename,
|
||||
value,
|
||||
])
|
||||
|
||||
// avoid update formInputs would overwrite the value just inserted
|
||||
useEffect(() => {
|
||||
if (needToAddFormInput) {
|
||||
onFormInputsChange(newFormInputs)
|
||||
setNeedToAddFormInput(false)
|
||||
}
|
||||
}, [value])
|
||||
const pendingFormInputs = pendingFormInputsRef.current
|
||||
if (!pendingFormInputs || pendingFormInputs.value === value)
|
||||
return
|
||||
|
||||
onFormInputsChange(pendingFormInputs.formInputs)
|
||||
pendingFormInputsRef.current = null
|
||||
}, [onFormInputsChange, value])
|
||||
|
||||
const [isFocus, {
|
||||
setTrue: setFocus,
|
||||
setFalse: setBlur,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const workflowNodesMap = availableNodes.reduce((acc: any, node) => {
|
||||
const workflowNodesMap = availableNodes.reduce<WorkflowNodesMap>((acc, node) => {
|
||||
acc[node.id] = {
|
||||
title: node.data.title,
|
||||
type: node.data.type,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
width: node.width ?? undefined,
|
||||
height: node.height ?? undefined,
|
||||
position: node.position,
|
||||
}
|
||||
if (node.data.type === BlockEnum.Start) {
|
||||
@@ -96,6 +113,48 @@ const FormContent: FC<FormContentProps> = ({
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
const unavailableVariableNames = useMemo(() => {
|
||||
return formInputs.map(input => input.output_variable_name)
|
||||
}, [formInputs])
|
||||
const addInputFieldConfigRef = useRef({
|
||||
nodeId,
|
||||
unavailableVariableNames,
|
||||
handleInsertHITLNode,
|
||||
})
|
||||
addInputFieldConfigRef.current = {
|
||||
nodeId,
|
||||
unavailableVariableNames,
|
||||
handleInsertHITLNode,
|
||||
}
|
||||
const shortcutPopups = useMemo(() => {
|
||||
if (readonly)
|
||||
return []
|
||||
|
||||
return [{
|
||||
hotkey: ['mod', '/'],
|
||||
// Keep this component type stable while the popup is open; it reads fresh props from a ref.
|
||||
// eslint-disable-next-line react/no-nested-component-definitions
|
||||
Popup: ({ onClose, onInsert }: {
|
||||
onClose: () => void
|
||||
onInsert: ShortcutPopupInsertHandler
|
||||
}) => {
|
||||
const {
|
||||
nodeId,
|
||||
unavailableVariableNames,
|
||||
handleInsertHITLNode,
|
||||
} = addInputFieldConfigRef.current
|
||||
|
||||
return (
|
||||
<AddInputField
|
||||
nodeId={nodeId}
|
||||
unavailableVariableNames={unavailableVariableNames}
|
||||
onSave={handleInsertHITLNode(onInsert)}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}]
|
||||
}, [readonly])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -130,22 +189,11 @@ const FormContent: FC<FormContentProps> = ({
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: availableVars || [],
|
||||
getVarType: getVarType as any,
|
||||
getVarType,
|
||||
workflowNodesMap,
|
||||
}}
|
||||
editable={!readonly}
|
||||
shortcutPopups={readonly
|
||||
? []
|
||||
: [{
|
||||
hotkey: ['mod', '/'],
|
||||
Popup: ({ onClose, onInsert }) => (
|
||||
<AddInputField
|
||||
nodeId={nodeId}
|
||||
onSave={handleInsertHITLNode(onInsert!)}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
),
|
||||
}]}
|
||||
shortcutPopups={shortcutPopups}
|
||||
/>
|
||||
</div>
|
||||
{isFocus && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import type { ButtonProps } from '@langgenius/dify-ui/button'
|
||||
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
|
||||
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -9,14 +10,14 @@ import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
|
||||
import { getButtonStyle, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
|
||||
import { getButtonStyle, getRenderedFormInputs, hasInvalidSelectOrFileInput, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
|
||||
|
||||
type Props = {
|
||||
nodeName: string
|
||||
data: HumanInputFormData
|
||||
showBackButton?: boolean
|
||||
handleBack?: () => void
|
||||
onSubmit?: ({ inputs, action }: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
onSubmit?: ({ inputs, action }: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
|
||||
}
|
||||
|
||||
const FormContent = ({
|
||||
@@ -27,18 +28,21 @@ const FormContent = ({
|
||||
onSubmit,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const defaultInputs = initializeInputs(data.inputs, data.resolved_default_values || {})
|
||||
const contentList = splitByOutputVar(data.form_content)
|
||||
const renderedFormInputs = getRenderedFormInputs(data.inputs, data.form_content)
|
||||
const defaultInputs = initializeInputs(renderedFormInputs, data.resolved_default_values || {})
|
||||
const [inputs, setInputs] = useState(defaultInputs)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleInputsChange = (name: string, value: string) => {
|
||||
const handleInputsChange = (name: string, value: HumanInputFieldValue) => {
|
||||
setInputs(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const hasEmptySelectOrFileInput = hasInvalidSelectOrFileInput(renderedFormInputs, inputs)
|
||||
|
||||
const submit = async (actionID: string) => {
|
||||
setIsSubmitting(true)
|
||||
await onSubmit?.({ inputs, action: actionID })
|
||||
@@ -75,7 +79,7 @@ const FormContent = ({
|
||||
{data.actions.map((action: UserAction) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || hasEmptySelectOrFileInput}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(action.id)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type * as React from 'react'
|
||||
import type { FormInputItemDefault } from '../types'
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import type { FormInputItem } from '../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { isFileFormInput, isFileListFormInput, isSelectFormInput } from '../types'
|
||||
|
||||
const variableRegex = /\{\{#(.+?)#\}\}/g
|
||||
const noteRegex = /\{\{#\$(.+?)#\}\}/g
|
||||
@@ -87,6 +93,17 @@ const formatVariablePath = (path: string) => {
|
||||
.replace('#}}', '}}')
|
||||
}
|
||||
|
||||
const sourceToVariablePath = (
|
||||
source: { selector: string[] },
|
||||
nodeName: (nodeId: string) => string,
|
||||
) => {
|
||||
if (!source.selector.length)
|
||||
return ''
|
||||
|
||||
const path = `{{#${source.selector.join('.')}#}}`
|
||||
return replaceNodeIdsWithNames(path, nodeName)
|
||||
}
|
||||
|
||||
export function rehypeVariable() {
|
||||
return (tree: MarkdownNode) => {
|
||||
visitTextNodes(tree, (value) => {
|
||||
@@ -132,13 +149,98 @@ export const Variable: React.FC<{ path: string }> = ({ path }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const Note: React.FC<{ defaultInput: FormInputItemDefault, nodeName: (nodeId: string) => string }> = ({ defaultInput, nodeName }) => {
|
||||
const isVariable = defaultInput.type === 'variable'
|
||||
const path = `{{#${defaultInput.selector.join('.')}#}}`
|
||||
const newPath = path ? replaceNodeIdsWithNames(path, nodeName) : path
|
||||
const SelectPreview: React.FC<{ label: string, options: string[] }> = ({ label, options }) => {
|
||||
const [value, setValue] = React.useState(options[0] || label)
|
||||
|
||||
return (
|
||||
<div data-testid="human-input-note-select-preview" className="my-3">
|
||||
<Select value={value} onValueChange={nextValue => nextValue && setValue(nextValue)}>
|
||||
<SelectTrigger size="large" className="w-full rounded-[10px]" aria-label="human-input-note-select">
|
||||
{value}
|
||||
</SelectTrigger>
|
||||
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
|
||||
{options.map(option => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<SelectItemText>{option}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FileUploadPreview: React.FC<{ methods: TransferMethod[], t: (key: string, options?: Record<string, unknown>) => string }> = ({ methods, t }) => {
|
||||
const normalizedMethods = methods.length
|
||||
? methods
|
||||
: [TransferMethod.local_file, TransferMethod.remote_url]
|
||||
const actions = [
|
||||
normalizedMethods.includes(TransferMethod.local_file) && {
|
||||
iconClassName: 'i-ri-upload-cloud-2-line',
|
||||
label: t('fileUploader.uploadFromComputer', { ns: 'common' }),
|
||||
},
|
||||
normalizedMethods.includes(TransferMethod.remote_url) && {
|
||||
iconClassName: 'i-ri-link',
|
||||
label: t('fileUploader.pasteFileLink', { ns: 'common' }),
|
||||
},
|
||||
].filter(Boolean) as Array<{ iconClassName: string, label: string }>
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="human-input-note-file-preview"
|
||||
className={cn(
|
||||
'my-3 grid gap-2',
|
||||
actions.length > 1 ? 'grid-cols-2' : 'grid-cols-1',
|
||||
)}
|
||||
>
|
||||
{actions.map(action => (
|
||||
<div
|
||||
key={action.label}
|
||||
className="flex h-10 items-center justify-center rounded-xl bg-components-input-bg-normal px-3"
|
||||
>
|
||||
<span className={cn('mr-2 size-5 shrink-0 text-text-tertiary', action.iconClassName)} />
|
||||
<span className="truncate system-sm-medium text-text-tertiary">
|
||||
{action.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Note: React.FC<{ input: FormInputItem, nodeName: (nodeId: string) => string }> = ({ input, nodeName }) => {
|
||||
const { t } = useTranslation()
|
||||
if (isSelectFormInput(input)) {
|
||||
const isVariable = input.option_source.type === 'variable'
|
||||
if (isVariable) {
|
||||
const variablePath = sourceToVariablePath(input.option_source, nodeName)
|
||||
return (
|
||||
<div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2">
|
||||
{variablePath
|
||||
? <Variable path={variablePath} />
|
||||
: <span>{t('nodes.humanInput.insertInputField.variable', { ns: 'workflow' })}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const label = input.option_source.value[0] || t('variableConfig.select', { ns: 'appDebug' })
|
||||
return <SelectPreview label={label} options={input.option_source.value} />
|
||||
}
|
||||
|
||||
if (isFileFormInput(input)) {
|
||||
return <FileUploadPreview methods={input.allowed_file_upload_methods} t={t} />
|
||||
}
|
||||
|
||||
if (isFileListFormInput(input)) {
|
||||
return <FileUploadPreview methods={input.allowed_file_upload_methods} t={t} />
|
||||
}
|
||||
|
||||
const isVariable = input.default.type === 'variable'
|
||||
const newPath = sourceToVariablePath(input.default, nodeName)
|
||||
return (
|
||||
<div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2">
|
||||
{isVariable ? <Variable path={newPath} /> : <span>{defaultInput.value}</span>}
|
||||
{isVariable ? <Variable path={newPath} /> : <span>{input.default.value}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { NodeDefault, Var } from '../../types'
|
||||
import type { HumanInputNodeType } from './types'
|
||||
import type { DeliveryMethod, EmailConfig, FormInputItem, HumanInputNodeType } from './types'
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
import { DeliveryMethodType } from './types'
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput.errorMsg'
|
||||
|
||||
@@ -12,15 +13,46 @@ const metaData = genNodeMetaData({
|
||||
type: BlockEnum.HumanInput,
|
||||
})
|
||||
|
||||
const buildOutputVars = (variables: string[]): Var[] => {
|
||||
return variables.map((variable) => {
|
||||
const getFormInputVarType = (input: FormInputItem): VarType => {
|
||||
if (input.type === 'file')
|
||||
return VarType.file
|
||||
|
||||
if (input.type === 'file-list')
|
||||
return VarType.arrayFile
|
||||
|
||||
return VarType.string
|
||||
}
|
||||
|
||||
const buildOutputVars = (inputs: FormInputItem[]): Var[] => {
|
||||
return inputs.map((input) => {
|
||||
return {
|
||||
variable,
|
||||
type: VarType.string,
|
||||
variable: input.output_variable_name,
|
||||
type: getFormInputVarType(input),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isEmailConfigComplete = (config?: EmailConfig): boolean => {
|
||||
if (!config)
|
||||
return false
|
||||
|
||||
if (!config.subject?.trim() || !config.body?.trim())
|
||||
return false
|
||||
|
||||
if (!/\{\{#url#\}\}/.test(config.body.trim()))
|
||||
return false
|
||||
|
||||
return !!config.recipients?.whole_workspace || !!config.recipients?.items?.length
|
||||
}
|
||||
|
||||
const hasIncompleteEnabledEmailConfig = (deliveryMethods: DeliveryMethod[]): boolean => {
|
||||
return deliveryMethods.some((method) => {
|
||||
return method.enabled
|
||||
&& method.type === DeliveryMethodType.Email
|
||||
&& !isEmailConfigComplete(method.config)
|
||||
})
|
||||
}
|
||||
|
||||
const nodeDefault: NodeDefault<HumanInputNodeType> = {
|
||||
metaData,
|
||||
defaultValue: {
|
||||
@@ -39,6 +71,9 @@ const nodeDefault: NodeDefault<HumanInputNodeType> = {
|
||||
if (!errorMessages && payload.delivery_methods.length > 0 && !payload.delivery_methods.some(method => method.enabled))
|
||||
errorMessages = t(`${i18nPrefix}.noDeliveryMethodEnabled`, { ns: 'workflow' })
|
||||
|
||||
if (!errorMessages && hasIncompleteEnabledEmailConfig(payload.delivery_methods))
|
||||
errorMessages = t(`${i18nPrefix}.emailConfigIncomplete`, { ns: 'workflow' })
|
||||
|
||||
if (!errorMessages && !payload.user_actions.length)
|
||||
errorMessages = t(`${i18nPrefix}.noUserActions`, { ns: 'workflow' })
|
||||
|
||||
@@ -67,8 +102,7 @@ const nodeDefault: NodeDefault<HumanInputNodeType> = {
|
||||
}
|
||||
},
|
||||
getOutputVars(payload, _allPluginInfoList, _ragVars) {
|
||||
const variables = payload.inputs.map(input => input.output_variable_name)
|
||||
return buildOutputVars(variables)
|
||||
return buildOutputVars(payload.inputs)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+23
-3
@@ -1,4 +1,4 @@
|
||||
import type { FormInputItem, HumanInputNodeType } from '../../types'
|
||||
import type { HumanInputNodeType, ParagraphFormInput } from '../../types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import useFormContent from '../use-form-content'
|
||||
@@ -15,8 +15,8 @@ vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
default: (...args: unknown[]) => mockUseNodeCrud(...args),
|
||||
}))
|
||||
|
||||
const createFormInput = (overrides: Partial<FormInputItem> = {}): FormInputItem => ({
|
||||
type: InputVarType.textInput,
|
||||
const createFormInput = (overrides: Partial<ParagraphFormInput> = {}): ParagraphFormInput => ({
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'old_name',
|
||||
default: {
|
||||
selector: [],
|
||||
@@ -96,6 +96,26 @@ describe('human-input/use-form-content', () => {
|
||||
expect(result.current.editorKey).toBe(1)
|
||||
})
|
||||
|
||||
it('should not rename an input to an existing variable name', () => {
|
||||
currentInputs = createPayload({
|
||||
inputs: [
|
||||
createFormInput(),
|
||||
createFormInput({ output_variable_name: 'existing_name' }),
|
||||
],
|
||||
})
|
||||
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
|
||||
|
||||
act(() => {
|
||||
result.current.handleFormInputItemRename(createFormInput({
|
||||
output_variable_name: 'existing_name',
|
||||
}), 'old_name')
|
||||
})
|
||||
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
expect(mockHandleOutVarRenameChange).not.toHaveBeenCalled()
|
||||
expect(result.current.editorKey).toBe(0)
|
||||
})
|
||||
|
||||
it('should remove an input placeholder and its form input metadata', () => {
|
||||
const { result } = renderHook(() => useFormContent('human-input-node', currentInputs))
|
||||
|
||||
|
||||
+137
-6
@@ -1,9 +1,10 @@
|
||||
import type { HumanInputNodeType } from '../../types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { AppModeEnum, TransferMethod } from '@/types/app'
|
||||
import useSingleRunFormParams from '../use-single-run-form-params'
|
||||
|
||||
const mockUseTranslation = vi.hoisted(() => vi.fn())
|
||||
@@ -37,7 +38,7 @@ const createPayload = (overrides: Partial<HumanInputNodeType> = {}): HumanInputN
|
||||
delivery_methods: [],
|
||||
form_content: 'Summary: {{#start.topic#}}',
|
||||
inputs: [{
|
||||
type: InputVarType.textInput,
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'summary',
|
||||
default: {
|
||||
type: 'variable',
|
||||
@@ -114,6 +115,28 @@ describe('human-input/hooks/use-single-run-form-params', () => {
|
||||
mockSubmitHumanInputNodeStepRunForm.mockResolvedValue({})
|
||||
})
|
||||
|
||||
const uploadedFile: FileEntity = {
|
||||
id: 'file-1',
|
||||
name: 'decision.pdf',
|
||||
size: 128,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
uploadedId: 'upload-file-1',
|
||||
}
|
||||
|
||||
const remoteFile: FileEntity = {
|
||||
id: 'file-2',
|
||||
name: 'reference.pdf',
|
||||
size: 256,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.remote_url,
|
||||
supportFileType: 'document',
|
||||
url: 'https://example.com/reference.pdf',
|
||||
}
|
||||
|
||||
it('should build a single before-run form, filter output vars, and expose dependent vars', () => {
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'node-1',
|
||||
@@ -147,7 +170,93 @@ describe('human-input/hooks/use-single-run-form-params', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should include variables referenced by dynamic select option sources', () => {
|
||||
currentInputs = createPayload({
|
||||
inputs: [
|
||||
{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'summary',
|
||||
default: {
|
||||
type: 'variable',
|
||||
selector: ['start', 'topic'],
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: InputVarType.select,
|
||||
output_variable_name: 'choice',
|
||||
option_source: {
|
||||
type: 'variable',
|
||||
selector: ['start', 'choices'],
|
||||
value: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
getInputVars.mockReturnValue([
|
||||
createInputVar(),
|
||||
createInputVar({
|
||||
label: 'Choices',
|
||||
variable: '#start.choices#',
|
||||
value_selector: ['start', 'choices'],
|
||||
}),
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'node-1',
|
||||
payload: currentInputs,
|
||||
runInputData: {},
|
||||
getInputVars,
|
||||
setRunInputData: mockSetRunInputData,
|
||||
}))
|
||||
|
||||
expect(getInputVars).toHaveBeenCalledWith([
|
||||
'{{#start.topic#}}',
|
||||
'{{#start.choices#}}',
|
||||
'Summary: {{#start.topic#}}',
|
||||
])
|
||||
expect(result.current.forms[0]!.inputs).toEqual([
|
||||
expect.objectContaining({ variable: '#start.topic#' }),
|
||||
expect.objectContaining({ variable: '#start.choices#' }),
|
||||
])
|
||||
expect(result.current.getDependentVars()).toEqual([
|
||||
['start', 'topic'],
|
||||
['start', 'choices'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should fetch and submit generated forms in workflow mode while keeping required inputs', async () => {
|
||||
const formDataWithFiles = {
|
||||
...mockFormData,
|
||||
inputs: [
|
||||
{
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: 'answer',
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name: 'attachment',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
},
|
||||
{
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name: 'references',
|
||||
allowed_file_extensions: ['.pdf'],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
number_limits: 3,
|
||||
},
|
||||
],
|
||||
} satisfies HumanInputFormData
|
||||
mockFetchHumanInputNodeStepRunForm.mockResolvedValue(formDataWithFiles)
|
||||
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'node-1',
|
||||
payload: currentInputs,
|
||||
@@ -170,11 +279,11 @@ describe('human-input/hooks/use-single-run-form-params', () => {
|
||||
inputs: { topic: 'AI' },
|
||||
},
|
||||
)
|
||||
expect(result.current.formData).toEqual(mockFormData)
|
||||
expect(result.current.formData).toEqual(formDataWithFiles)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSubmitHumanInputForm({
|
||||
inputs: { answer: 'approved' },
|
||||
inputs: { answer: 'approved', attachment: uploadedFile, references: [uploadedFile, remoteFile] },
|
||||
form_inputs: { ignored: 'value' },
|
||||
action: 'approve',
|
||||
})
|
||||
@@ -184,7 +293,29 @@ describe('human-input/hooks/use-single-run-form-params', () => {
|
||||
'/apps/app-1/workflows/draft/human-input/nodes/node-1/form',
|
||||
{
|
||||
inputs: { topic: 'AI' },
|
||||
form_inputs: { answer: 'approved' },
|
||||
form_inputs: {
|
||||
answer: 'approved',
|
||||
attachment: {
|
||||
type: 'document',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: '',
|
||||
upload_file_id: 'upload-file-1',
|
||||
},
|
||||
references: [
|
||||
{
|
||||
type: 'document',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: '',
|
||||
upload_file_id: 'upload-file-1',
|
||||
},
|
||||
{
|
||||
type: 'document',
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
url: 'https://example.com/reference.pdf',
|
||||
upload_file_id: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
action: 'approve',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -29,6 +29,13 @@ const useFormContent = (id: string, payload: HumanInputNodeType) => {
|
||||
|
||||
const handleFormInputItemRename = useCallback((payload: FormInputItem, oldName: string) => {
|
||||
const inputs = inputsRef.current
|
||||
if (
|
||||
oldName !== payload.output_variable_name
|
||||
&& inputs.inputs.some(item => item.output_variable_name === payload.output_variable_name)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.form_content = draft.form_content.replaceAll(`{{#$output.${oldName}#}}`, `{{#$output.${payload.output_variable_name}#}}`)
|
||||
draft.inputs = draft.inputs.map(item => item.output_variable_name === oldName ? payload : item)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { HumanInputNodeType } from '../types'
|
||||
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
|
||||
import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { getProcessedHumanInputFormInputs } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
|
||||
import { fetchHumanInputNodeStepRunForm, submitHumanInputNodeStepRunForm } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import useNodeCrud from '../../_base/hooks/use-node-crud'
|
||||
import { isOutput } from '../utils'
|
||||
import { getHumanInputFormDependencySelectors, isOutput } from '../utils'
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput'
|
||||
|
||||
@@ -19,6 +21,7 @@ type Params = {
|
||||
getInputVars: (textList: string[]) => InputVar[]
|
||||
setRunInputData: (data: Record<string, string>) => void
|
||||
}
|
||||
|
||||
const useSingleRunFormParams = ({
|
||||
id,
|
||||
payload,
|
||||
@@ -32,13 +35,9 @@ const useSingleRunFormParams = ({
|
||||
const [formData, setFormData] = useState<HumanInputFormData | null>(null)
|
||||
const [requiredInputs, setRequiredInputs] = useState<Record<string, string>>({})
|
||||
const generatedInputs = useMemo(() => {
|
||||
const defaultInputs = inputs.inputs.reduce((acc, input) => {
|
||||
if (input.default.type === 'variable') {
|
||||
acc.push(`{{#${input.default.selector.join('.')}#}}`)
|
||||
}
|
||||
return acc
|
||||
}, [] as string[])
|
||||
const allInputs = getInputVars([...defaultInputs, inputs.form_content || '']).filter(item => !isOutput(item.value_selector || []))
|
||||
const formInputDependencyInputs = getHumanInputFormDependencySelectors(inputs.inputs)
|
||||
.map(selector => `{{#${selector.join('.')}#}}`)
|
||||
const allInputs = getInputVars([...formInputDependencyInputs, inputs.form_content || '']).filter(item => !isOutput(item.value_selector || []))
|
||||
return allInputs
|
||||
}, [getInputVars, inputs.form_content, inputs.inputs])
|
||||
|
||||
@@ -92,17 +91,19 @@ const useSingleRunFormParams = ({
|
||||
return data
|
||||
}, [fetchURL])
|
||||
|
||||
const handleSubmitHumanInputForm = useCallback(async (formData: {
|
||||
inputs: Record<string, string> | undefined
|
||||
form_inputs: Record<string, string> | undefined
|
||||
const handleSubmitHumanInputForm = useCallback(async (submission: {
|
||||
inputs: Record<string, HumanInputFieldValue> | undefined
|
||||
form_inputs: Record<string, HumanInputFieldValue> | undefined
|
||||
action: string
|
||||
}) => {
|
||||
const formInputs = formData?.inputs?.length ? formData.inputs : inputs.inputs
|
||||
|
||||
await submitHumanInputNodeStepRunForm(fetchURL, {
|
||||
inputs: requiredInputs,
|
||||
form_inputs: formData.inputs,
|
||||
action: formData.action,
|
||||
form_inputs: getProcessedHumanInputFormInputs(formInputs, submission.inputs),
|
||||
action: submission.action,
|
||||
})
|
||||
}, [fetchURL, requiredInputs])
|
||||
}, [fetchURL, formData?.inputs, inputs.inputs, requiredInputs])
|
||||
|
||||
const handleShowGeneratedForm = async (formValue: Record<string, string>) => {
|
||||
setShowGeneratedForm(true)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { HumanInputNodeType } from './types'
|
||||
import type { FormInputItem, HumanInputNodeType } from './types'
|
||||
import type { NodePanelProps, Var } from '@/app/components/workflow/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -34,6 +34,16 @@ import { UserActionButtonType } from './types'
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput'
|
||||
|
||||
const getOutputVarType = (input: FormInputItem): VarType => {
|
||||
if (input.type === 'file')
|
||||
return VarType.file
|
||||
|
||||
if (input.type === 'file-list')
|
||||
return VarType.arrayFile
|
||||
|
||||
return VarType.string
|
||||
}
|
||||
|
||||
const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
@@ -59,7 +69,7 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({
|
||||
const { availableVars, availableNodesWithParent } = useAvailableVarList(id, {
|
||||
onlyLeafNodeVar: false,
|
||||
filterVar: (varPayload: Var) => {
|
||||
return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
|
||||
return [VarType.string, VarType.number, VarType.secret, VarType.arrayString].includes(varPayload.type)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -226,7 +236,7 @@ const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({
|
||||
<VarItem
|
||||
key={input.output_variable_name}
|
||||
name={input.output_variable_name}
|
||||
type={VarType.string}
|
||||
type={getOutputVarType(input)}
|
||||
description="Form input value"
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type {
|
||||
CommonNodeType,
|
||||
InputVarType,
|
||||
UploadFileSetting,
|
||||
ValueSelector,
|
||||
} from '@/app/components/workflow/types'
|
||||
import {
|
||||
InputVarType,
|
||||
SupportUploadFileTypes,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
export type HumanInputNodeType = CommonNodeType & {
|
||||
delivery_methods: DeliveryMethod[]
|
||||
@@ -59,14 +64,137 @@ export type UserAction = {
|
||||
button_style: UserActionButtonType
|
||||
}
|
||||
|
||||
export type FormInputItemDefault = {
|
||||
type StringDefault = {
|
||||
selector: ValueSelector
|
||||
type: 'variable' | 'constant'
|
||||
value: string
|
||||
}
|
||||
|
||||
export type FormInputItem = {
|
||||
type: InputVarType
|
||||
type StringListSource = {
|
||||
selector: ValueSelector
|
||||
type: 'variable' | 'constant'
|
||||
value: string[]
|
||||
}
|
||||
|
||||
// Preserve the old export during the transition to the new schema names.
|
||||
export type FormInputItemDefault = StringDefault
|
||||
|
||||
type BaseFormInputItem = {
|
||||
output_variable_name: string
|
||||
default: FormInputItemDefault
|
||||
}
|
||||
|
||||
export type ParagraphFormInput = BaseFormInputItem & {
|
||||
type: InputVarType.paragraph
|
||||
default: StringDefault
|
||||
}
|
||||
|
||||
export type SelectFormInput = BaseFormInputItem & {
|
||||
type: InputVarType.select
|
||||
option_source: StringListSource
|
||||
}
|
||||
|
||||
type SharedFileFormInput = Pick<
|
||||
UploadFileSetting,
|
||||
'allowed_file_extensions' | 'allowed_file_types' | 'allowed_file_upload_methods'
|
||||
>
|
||||
|
||||
export type FileFormInput = BaseFormInputItem & SharedFileFormInput & {
|
||||
type: InputVarType.singleFile
|
||||
}
|
||||
|
||||
export type FileListFormInput = BaseFormInputItem & SharedFileFormInput & {
|
||||
type: InputVarType.multiFiles
|
||||
number_limits?: UploadFileSetting['number_limits']
|
||||
}
|
||||
|
||||
export type FormInputItem
|
||||
= | ParagraphFormInput
|
||||
| SelectFormInput
|
||||
| FileFormInput
|
||||
| FileListFormInput
|
||||
|
||||
export const isParagraphFormInput = (
|
||||
input: FormInputItem,
|
||||
): input is ParagraphFormInput => {
|
||||
return input.type === InputVarType.paragraph
|
||||
}
|
||||
|
||||
export const isSelectFormInput = (
|
||||
input: FormInputItem,
|
||||
): input is SelectFormInput => {
|
||||
return input.type === InputVarType.select
|
||||
}
|
||||
|
||||
export const isFileFormInput = (
|
||||
input: FormInputItem,
|
||||
): input is FileFormInput => {
|
||||
return input.type === InputVarType.singleFile
|
||||
}
|
||||
|
||||
export const isFileListFormInput = (
|
||||
input: FormInputItem,
|
||||
): input is FileListFormInput => {
|
||||
return input.type === InputVarType.multiFiles
|
||||
}
|
||||
|
||||
export const createDefaultParagraphFormInput = (
|
||||
output_variable_name = '',
|
||||
): ParagraphFormInput => ({
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name,
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: '',
|
||||
},
|
||||
})
|
||||
|
||||
const createDefaultSelectFormInput = (
|
||||
output_variable_name = '',
|
||||
): SelectFormInput => ({
|
||||
type: InputVarType.select,
|
||||
output_variable_name,
|
||||
option_source: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: [],
|
||||
},
|
||||
})
|
||||
|
||||
const createDefaultFileFormInput = (
|
||||
output_variable_name = '',
|
||||
): FileFormInput => ({
|
||||
type: InputVarType.singleFile,
|
||||
output_variable_name,
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
})
|
||||
|
||||
const createDefaultFileListFormInput = (
|
||||
output_variable_name = '',
|
||||
): FileListFormInput => ({
|
||||
type: InputVarType.multiFiles,
|
||||
output_variable_name,
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
number_limits: 5,
|
||||
})
|
||||
|
||||
export const createDefaultFormInputByType = (
|
||||
type: FormInputItem['type'],
|
||||
output_variable_name = '',
|
||||
): FormInputItem => {
|
||||
switch (type) {
|
||||
case InputVarType.select:
|
||||
return createDefaultSelectFormInput(output_variable_name)
|
||||
case InputVarType.singleFile:
|
||||
return createDefaultFileFormInput(output_variable_name)
|
||||
case InputVarType.multiFiles:
|
||||
return createDefaultFileListFormInput(output_variable_name)
|
||||
case InputVarType.paragraph:
|
||||
default:
|
||||
return createDefaultParagraphFormInput(output_variable_name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
import type { FormInputItem } from './types'
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
import { isParagraphFormInput, isSelectFormInput } from './types'
|
||||
|
||||
export const isOutput = (valueSelector: string[]) => {
|
||||
return valueSelector[0] === '$output'
|
||||
}
|
||||
|
||||
export const getHumanInputFormDependencySelectors = (inputs: FormInputItem[]): ValueSelector[] => {
|
||||
return inputs.flatMap((input) => {
|
||||
if (isParagraphFormInput(input) && input.default.type === 'variable' && input.default.selector.length > 0)
|
||||
return [input.default.selector]
|
||||
|
||||
if (isSelectFormInput(input) && input.option_source.type === 'variable' && input.option_source.selector.length > 0)
|
||||
return [input.option_source.selector]
|
||||
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ const createFilledForm = (overrides: Partial<HumanInputFilledFormData> = {}): Hu
|
||||
rendered_content: 'Approved by Alice',
|
||||
action_id: 'approve',
|
||||
action_text: 'Approve',
|
||||
submitted_data: {
|
||||
summary: 'Approved by Alice',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -33,11 +36,13 @@ describe('HumanInputFilledFormList', () => {
|
||||
|
||||
expect(screen.getByText('Approval'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Review'))!.toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('submitted-content')).toHaveLength(2)
|
||||
expect(screen.getAllByTestId('submitted-field-values')).toHaveLength(2)
|
||||
expect(screen.getAllByTestId('executed-action')).toHaveLength(2)
|
||||
expect(screen.getAllByTestId('submitted-field-summary')).toHaveLength(2)
|
||||
expect(screen.getAllByTestId('submitted-field-summary')[0]).toHaveTextContent('Approved by Alice')
|
||||
|
||||
await user.click(screen.getAllByTestId('expand-icon')[0]!)
|
||||
|
||||
expect(screen.getAllByTestId('submitted-content')).toHaveLength(1)
|
||||
expect(screen.getAllByTestId('submitted-field-values')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Shape } from '../../store/workflow'
|
||||
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
@@ -84,11 +85,11 @@ vi.mock('@/app/components/workflow/panel/human-input-form-list', () => ({
|
||||
onHumanInputFormSubmit,
|
||||
}: {
|
||||
humanInputFormDataList: unknown[]
|
||||
onHumanInputFormSubmit?: (token: string, formData: Record<string, string>) => Promise<void>
|
||||
onHumanInputFormSubmit?: (token: string, formData: { inputs: Record<string, HumanInputFieldValue>, action: string }) => Promise<void>
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="human-form-list">{humanInputFormDataList.length}</div>
|
||||
<button type="button" onClick={() => onHumanInputFormSubmit?.('form-token', { answer: 'ok' })}>
|
||||
<button type="button" onClick={() => onHumanInputFormSubmit?.('form-token', { inputs: { answer: 'ok' }, action: 'approve' })}>
|
||||
submit-human-form
|
||||
</button>
|
||||
</div>
|
||||
@@ -236,7 +237,7 @@ describe('WorkflowPreview', () => {
|
||||
expect(screen.getByTestId('filled-form-list')).toHaveTextContent('1')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'submit-human-form' }))
|
||||
expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('form-token', { answer: 'ok' })
|
||||
expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('form-token', { inputs: { answer: 'ok' }, action: 'approve' })
|
||||
})
|
||||
|
||||
it('should copy successful string output and show a success toast', async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user