diff --git a/web/app/components/snippets/components/__tests__/input-field-editor.spec.tsx b/web/app/components/snippets/components/__tests__/input-field-editor.spec.tsx
index de46e23164..dd89fcf2e3 100644
--- a/web/app/components/snippets/components/__tests__/input-field-editor.spec.tsx
+++ b/web/app/components/snippets/components/__tests__/input-field-editor.spec.tsx
@@ -9,7 +9,7 @@ vi.mock('@/app/components/rag-pipeline/components/panel/input-field/hooks', () =
useFloatingRight: (...args: unknown[]) => mockUseFloatingRight(...args),
}))
-vi.mock('@/app/components/rag-pipeline/components/panel/input-field/editor/form', () => ({
+vi.mock('../input-field-form', () => ({
default: ({ isEditMode }: { isEditMode: boolean }) => (
{isEditMode ? 'edit' : 'create'}
),
diff --git a/web/app/components/snippets/components/__tests__/publish-menu.spec.tsx b/web/app/components/snippets/components/__tests__/publish-menu.spec.tsx
index 5b5a61343d..c3c97047d9 100644
--- a/web/app/components/snippets/components/__tests__/publish-menu.spec.tsx
+++ b/web/app/components/snippets/components/__tests__/publish-menu.spec.tsx
@@ -5,6 +5,8 @@ describe('PublishMenu', () => {
it('should render the draft summary and publish shortcut', () => {
const { container } = render(
{
expect(screen.getByRole('button', { name: 'snippet.publishButton' })).toBeInTheDocument()
expect(container.querySelectorAll('.system-kbd')).toHaveLength(3)
})
+
+ it('should render published summary when a published version exists', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('workflow.common.latestPublished')).toBeInTheDocument()
+ expect(screen.getByText(/workflow\.common\.publishedAt/)).toBeInTheDocument()
+ })
})
diff --git a/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts b/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts
index 9d29d80906..d3d46e89cb 100644
--- a/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts
+++ b/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts
@@ -5,6 +5,7 @@ import { useSnippetPublish } from '../use-snippet-publish'
const mockMutateAsync = vi.fn()
const mockSetPublishMenuOpen = vi.fn()
const mockUseKeyPress = vi.fn()
+const mockSetPublishedAt = vi.fn()
let isPublishMenuOpen = false
let isPending = false
@@ -28,6 +29,14 @@ vi.mock('@/service/use-snippet-workflows', () => ({
}),
}))
+vi.mock('@/app/components/workflow/store', () => ({
+ useWorkflowStore: () => ({
+ getState: () => ({
+ setPublishedAt: mockSetPublishedAt,
+ }),
+ }),
+}))
+
vi.mock('../../../store', () => ({
useSnippetDetailStore: (selector: (state: {
isPublishMenuOpen: boolean
@@ -44,7 +53,7 @@ describe('useSnippetPublish', () => {
isPublishMenuOpen = false
isPending = false
shortcutHandler = undefined
- mockMutateAsync.mockResolvedValue(undefined)
+ mockMutateAsync.mockResolvedValue({ created_at: 1_712_345_678 })
mockUseKeyPress.mockImplementation((_key, handler) => {
shortcutHandler = handler
})
@@ -63,6 +72,7 @@ describe('useSnippetPublish', () => {
expect(mockMutateAsync).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
})
+ expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
expect(mockSetPublishMenuOpen).toHaveBeenCalledWith(false)
expect(toast.success).toHaveBeenCalledWith('snippet.publishSuccess')
})
@@ -104,7 +114,8 @@ describe('useSnippetPublish', () => {
expect(preventDefault).toHaveBeenCalledTimes(1)
})
- it('should ignore the shortcut outside the orchestrate section', () => {
+ it('should ignore the shortcut while publishing is pending', () => {
+ isPending = true
renderHook(() => useSnippetPublish({
snippetId: 'snippet-1',
}))
diff --git a/web/app/components/snippets/components/hooks/use-snippet-publish.ts b/web/app/components/snippets/components/hooks/use-snippet-publish.ts
index 29dbfb03d2..fc3db4d8f4 100644
--- a/web/app/components/snippets/components/hooks/use-snippet-publish.ts
+++ b/web/app/components/snippets/components/hooks/use-snippet-publish.ts
@@ -3,6 +3,7 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { toast } from '@/app/components/base/ui/toast'
+import { useWorkflowStore } from '@/app/components/workflow/store'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import { usePublishSnippetWorkflowMutation } from '@/service/use-snippet-workflows'
import { useSnippetDetailStore } from '../../store'
@@ -15,6 +16,7 @@ export const useSnippetPublish = ({
snippetId,
}: UseSnippetPublishOptions) => {
const { t } = useTranslation('snippet')
+ const workflowStore = useWorkflowStore()
const publishSnippetMutation = usePublishSnippetWorkflowMutation(snippetId)
const {
isPublishMenuOpen,
@@ -26,16 +28,17 @@ export const useSnippetPublish = ({
const handlePublish = useCallback(async () => {
try {
- await publishSnippetMutation.mutateAsync({
+ const publishedWorkflow = await publishSnippetMutation.mutateAsync({
params: { snippetId },
})
+ workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
setPublishMenuOpen(false)
toast.success(t('publishSuccess'))
}
catch (error) {
toast.error(error instanceof Error ? error.message : t('publishFailed'))
}
- }, [publishSnippetMutation, setPublishMenuOpen, snippetId, t])
+ }, [publishSnippetMutation, setPublishMenuOpen, snippetId, t, workflowStore])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (event) => {
if (publishSnippetMutation.isPending)
diff --git a/web/app/components/snippets/components/input-field-editor.tsx b/web/app/components/snippets/components/input-field-editor.tsx
index dcc6b8b72c..82ae65d67b 100644
--- a/web/app/components/snippets/components/input-field-editor.tsx
+++ b/web/app/components/snippets/components/input-field-editor.tsx
@@ -2,13 +2,12 @@
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
import type { SnippetInputField } from '@/models/snippet'
-import { RiCloseLine } from '@remixicon/react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
-import InputFieldForm from '@/app/components/rag-pipeline/components/panel/input-field/editor/form'
import { convertFormDataToINputField, convertToInputFieldFormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/utils'
import { useFloatingRight } from '@/app/components/rag-pipeline/components/panel/input-field/hooks'
import { cn } from '@/utils/classnames'
+import InputFieldForm from './input-field-form'
type SnippetInputFieldEditorProps = {
field?: SnippetInputField | null
@@ -43,15 +42,15 @@ const SnippetInputFieldEditor = ({
width: `min(${floatingRightWidth}px, calc(100vw - 24px))`,
}}
>
-
+
{field ? t('inputFieldPanel.editInputField', { ns: 'datasetPipeline' }) : t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
({
+ useConfigurations: (...args: unknown[]) => mockUseConfigurations(...args),
+}))
+
+describe('useSnippetInputFieldConfigurations', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should make text maxLength configuration optional for snippets only', () => {
+ mockUseConfigurations.mockReturnValue([
+ {
+ variable: 'maxLength',
+ required: true,
+ showConditions: [{ variable: 'type', value: PipelineInputVarType.textInput }],
+ },
+ {
+ variable: 'required',
+ required: true,
+ showConditions: [],
+ },
+ ])
+
+ const { result } = renderHook(() => useSnippetInputFieldConfigurations({
+ getFieldValue: vi.fn(),
+ setFieldValue: vi.fn(),
+ supportFile: true,
+ }))
+
+ expect(result.current[0].required).toBe(false)
+ expect(result.current[1].required).toBe(true)
+ })
+})
diff --git a/web/app/components/snippets/components/input-field-form/__tests__/schema.spec.ts b/web/app/components/snippets/components/input-field-form/__tests__/schema.spec.ts
new file mode 100644
index 0000000000..63a531a9d6
--- /dev/null
+++ b/web/app/components/snippets/components/input-field-form/__tests__/schema.spec.ts
@@ -0,0 +1,51 @@
+import type { TFunction } from 'i18next'
+import { describe, expect, it, vi } from 'vitest'
+import { PipelineInputVarType } from '@/models/pipeline'
+import { createSnippetInputFieldSchema, TEXT_MAX_LENGTH } from '../schema'
+
+vi.mock('@/config', () => ({
+ MAX_VAR_KEY_LENGTH: 30,
+}))
+
+const t: TFunction = ((key: string) => key) as unknown as TFunction
+
+describe('createSnippetInputFieldSchema', () => {
+ const defaultOptions = { maxFileUploadLimit: 10 }
+
+ it('should allow text-input maxLength to be omitted', () => {
+ const schema = createSnippetInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
+ const result = schema.safeParse({
+ type: 'text-input',
+ variable: 'text_var',
+ label: 'Text',
+ required: false,
+ })
+
+ expect(result.success).toBe(true)
+ })
+
+ it('should still reject text-input maxLength above the limit', () => {
+ const schema = createSnippetInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
+ const result = schema.safeParse({
+ type: 'text-input',
+ variable: 'text_var',
+ label: 'Text',
+ required: false,
+ maxLength: TEXT_MAX_LENGTH + 1,
+ })
+
+ expect(result.success).toBe(false)
+ })
+
+ it('should allow paragraph maxLength to be omitted', () => {
+ const schema = createSnippetInputFieldSchema(PipelineInputVarType.paragraph, t, defaultOptions)
+ const result = schema.safeParse({
+ type: 'paragraph',
+ variable: 'paragraph_var',
+ label: 'Paragraph',
+ required: false,
+ })
+
+ expect(result.success).toBe(true)
+ })
+})
diff --git a/web/app/components/snippets/components/input-field-form/hooks.ts b/web/app/components/snippets/components/input-field-form/hooks.ts
new file mode 100644
index 0000000000..d3a98198b1
--- /dev/null
+++ b/web/app/components/snippets/components/input-field-form/hooks.ts
@@ -0,0 +1,30 @@
+import type { DeepKeys } from '@tanstack/react-form'
+import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
+import { useMemo } from 'react'
+import { useConfigurations } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks'
+import { PipelineInputVarType } from '@/models/pipeline'
+
+type UseSnippetInputFieldConfigurationsProps = {
+ getFieldValue: (fieldName: DeepKeys) => unknown
+ setFieldValue: (fieldName: DeepKeys, value: unknown) => void
+ supportFile: boolean
+}
+
+export const useSnippetInputFieldConfigurations = (props: UseSnippetInputFieldConfigurationsProps) => {
+ const configurations = useConfigurations(props)
+
+ return useMemo(() => {
+ return configurations.map((configuration) => {
+ const isTextMaxLengthField = configuration.variable === 'maxLength'
+ && configuration.showConditions?.some(condition => condition.value === PipelineInputVarType.textInput)
+
+ if (!isTextMaxLengthField)
+ return configuration
+
+ return {
+ ...configuration,
+ required: false,
+ }
+ })
+ }, [configurations])
+}
diff --git a/web/app/components/snippets/components/input-field-form/index.tsx b/web/app/components/snippets/components/input-field-form/index.tsx
new file mode 100644
index 0000000000..e5620c9299
--- /dev/null
+++ b/web/app/components/snippets/components/input-field-form/index.tsx
@@ -0,0 +1,92 @@
+import type { FormData, InputFieldFormProps } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
+import type { MoreInfo } from '@/app/components/workflow/types'
+import { useCallback, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Divider from '@/app/components/base/divider'
+import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks'
+import { useAppForm } from '@/app/components/base/form'
+import { Button } from '@/app/components/base/ui/button'
+import { toast } from '@/app/components/base/ui/toast'
+import HiddenFields from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/hidden-fields'
+import ShowAllSettings from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/show-all-settings'
+import { ChangeType } from '@/app/components/workflow/types'
+import { useFileUploadConfig } from '@/service/use-common'
+import InitialFields from './initial-fields'
+import { createSnippetInputFieldSchema } from './schema'
+
+const SnippetInputFieldForm = ({ initialData, supportFile = false, onCancel, onSubmit, isEditMode = true }: InputFieldFormProps) => {
+ const { t } = useTranslation()
+ const { data: fileUploadConfigResponse } = useFileUploadConfig()
+ const { maxFileUploadLimit } = useFileSizeLimit(fileUploadConfigResponse)
+ const inputFieldForm = useAppForm({
+ defaultValues: initialData,
+ validators: {
+ onSubmit: ({ value }) => {
+ const { type } = value
+ const schema = createSnippetInputFieldSchema(type, t, { maxFileUploadLimit })
+ const result = schema.safeParse(value)
+ if (!result.success) {
+ const issues = result.error.issues
+ const firstIssue = issues[0]
+ const errorMessage = `"${firstIssue.path.join('.')}" ${firstIssue.message}`
+ toast.error(errorMessage)
+ return errorMessage
+ }
+ return undefined
+ },
+ },
+ onSubmit: ({ value }) => {
+ let moreInfo: MoreInfo | undefined
+ if (isEditMode && value.variable !== initialData?.variable) {
+ moreInfo = {
+ type: ChangeType.changeVarName,
+ payload: { beforeKey: initialData?.variable || '', afterKey: value.variable },
+ }
+ }
+ onSubmit(value as FormData, moreInfo)
+ },
+ })
+ const [showAllSettings, setShowAllSettings] = useState(false)
+ const InitialFieldsComp = InitialFields({
+ initialData,
+ supportFile,
+ })
+ const HiddenFieldsComp = HiddenFields({
+ initialData,
+ })
+ const handleShowAllSettings = useCallback(() => {
+ setShowAllSettings(true)
+ }, [])
+ const ShowAllSettingComp = ShowAllSettings({
+ initialData,
+ handleShowAllSettings,
+ })
+
+ return (
+
+ )
+}
+
+export default SnippetInputFieldForm
diff --git a/web/app/components/snippets/components/input-field-form/initial-fields.tsx b/web/app/components/snippets/components/input-field-form/initial-fields.tsx
new file mode 100644
index 0000000000..0028995d76
--- /dev/null
+++ b/web/app/components/snippets/components/input-field-form/initial-fields.tsx
@@ -0,0 +1,50 @@
+import * as React from 'react'
+import { useCallback } from 'react'
+import { withForm } from '@/app/components/base/form'
+import InputField from '@/app/components/base/form/form-scenarios/input-field/field'
+import { useSnippetInputFieldConfigurations } from './hooks'
+
+type InitialFieldsProps = {
+ initialData?: Record
+ supportFile: boolean
+}
+
+const InitialFields = ({
+ initialData,
+ supportFile,
+}: InitialFieldsProps) => withForm({
+ defaultValues: initialData,
+ // eslint-disable-next-line react/component-hook-factories
+ render: function Render({
+ form,
+ }) {
+ const getFieldValue = useCallback((fieldName: string) => {
+ return form.getFieldValue(fieldName)
+ }, [form])
+
+ const setFieldValue = useCallback((fieldName: string, value: unknown) => {
+ form.setFieldValue(fieldName, value)
+ }, [form])
+
+ const initialConfigurations = useSnippetInputFieldConfigurations({
+ getFieldValue,
+ setFieldValue,
+ supportFile,
+ })
+
+ return (
+ <>
+ {initialConfigurations.map((config) => {
+ const FieldComponent = InputField({
+ initialData,
+ config,
+ })
+ const key = `${config.variable}-${config.label}-${config.showConditions.map(condition => String(condition.value)).join('-') || 'default'}`
+ return
+ })}
+ >
+ )
+ },
+})
+
+export default InitialFields
diff --git a/web/app/components/snippets/components/input-field-form/schema.ts b/web/app/components/snippets/components/input-field-form/schema.ts
new file mode 100644
index 0000000000..563e6ec5e9
--- /dev/null
+++ b/web/app/components/snippets/components/input-field-form/schema.ts
@@ -0,0 +1,95 @@
+import type { TFunction } from 'i18next'
+import type { SchemaOptions } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
+import * as z from 'zod'
+import { InputTypeEnum } from '@/app/components/base/form/components/field/input-type-select/types'
+import { MAX_VAR_KEY_LENGTH } from '@/config'
+import { PipelineInputVarType } from '@/models/pipeline'
+
+export const TEXT_MAX_LENGTH = 256
+
+const TransferMethod = z.enum([
+ 'all',
+ 'local_file',
+ 'remote_url',
+])
+
+const SupportedFileTypes = z.enum([
+ 'image',
+ 'document',
+ 'video',
+ 'audio',
+ 'custom',
+])
+
+export const createSnippetInputFieldSchema = (type: PipelineInputVarType, t: TFunction, options: SchemaOptions) => {
+ const { maxFileUploadLimit } = options
+ const commonSchema = z.object({
+ type: InputTypeEnum,
+ variable: z.string().nonempty({
+ message: t('varKeyError.canNoBeEmpty', { ns: 'appDebug', key: t('variableConfig.varName', { ns: 'appDebug' }) }),
+ }).max(MAX_VAR_KEY_LENGTH, {
+ message: t('varKeyError.tooLong', { ns: 'appDebug', key: t('variableConfig.varName', { ns: 'appDebug' }) }),
+ }).regex(/^(?!\d)\w+/, {
+ message: t('varKeyError.notStartWithNumber', { ns: 'appDebug', key: t('variableConfig.varName', { ns: 'appDebug' }) }),
+ }).regex(/^[a-z_]\w{0,29}$/i, {
+ message: t('varKeyError.notValid', { ns: 'appDebug', key: t('variableConfig.varName', { ns: 'appDebug' }) }),
+ }),
+ label: z.string().nonempty({
+ message: t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }),
+ }),
+ required: z.boolean(),
+ tooltips: z.string().optional(),
+ })
+
+ if (type === PipelineInputVarType.textInput || type === PipelineInputVarType.paragraph) {
+ return z.object({
+ maxLength: z.number().min(1).max(TEXT_MAX_LENGTH).optional(),
+ default: z.string().optional(),
+ }).merge(commonSchema).passthrough()
+ }
+
+ if (type === PipelineInputVarType.number) {
+ return z.object({
+ default: z.number().optional(),
+ unit: z.string().optional(),
+ placeholder: z.string().optional(),
+ }).merge(commonSchema).passthrough()
+ }
+
+ if (type === PipelineInputVarType.select) {
+ return z.object({
+ options: z.array(z.string()).nonempty({
+ message: t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }),
+ }).refine(
+ arr => new Set(arr).size === arr.length,
+ {
+ message: t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }),
+ },
+ ),
+ default: z.string().optional(),
+ }).merge(commonSchema).passthrough()
+ }
+
+ if (type === PipelineInputVarType.singleFile) {
+ return z.object({
+ allowedFileUploadMethods: z.array(TransferMethod),
+ allowedTypesAndExtensions: z.object({
+ allowedFileExtensions: z.array(z.string()).optional(),
+ allowedFileTypes: z.array(SupportedFileTypes),
+ }),
+ }).merge(commonSchema).passthrough()
+ }
+
+ if (type === PipelineInputVarType.multiFiles) {
+ return z.object({
+ allowedFileUploadMethods: z.array(TransferMethod),
+ allowedTypesAndExtensions: z.object({
+ allowedFileExtensions: z.array(z.string()).optional(),
+ allowedFileTypes: z.array(SupportedFileTypes),
+ }),
+ maxLength: z.number().min(1).max(maxFileUploadLimit),
+ }).merge(commonSchema).passthrough()
+ }
+
+ return commonSchema.passthrough()
+}
diff --git a/web/app/components/snippets/components/publish-menu.tsx b/web/app/components/snippets/components/publish-menu.tsx
index 0a10f7606c..d5af23f935 100644
--- a/web/app/components/snippets/components/publish-menu.tsx
+++ b/web/app/components/snippets/components/publish-menu.tsx
@@ -4,26 +4,40 @@ import type { SnippetDetailUIModel } from '@/models/snippet'
import { useTranslation } from 'react-i18next'
import { Button } from '@/app/components/base/ui/button'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
+import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
const PublishMenu = ({
+ draftUpdatedAt,
+ publishedAt,
uiMeta,
onPublish,
isPublishing = false,
}: {
+ draftUpdatedAt: number
+ publishedAt: number
uiMeta: SnippetDetailUIModel
onPublish: () => void
isPublishing?: boolean
}) => {
const { t } = useTranslation('snippet')
+ const { formatTimeFromNow } = useFormatTimeFromNow()
+
+ const hasPublishedVersion = Boolean(publishedAt)
return (
-
+
-
- {t('publishMenuCurrentDraft')}
+
+ {hasPublishedVersion
+ ? t('common.latestPublished', { ns: 'workflow' })
+ : t('publishMenuCurrentDraft')}
-
- {uiMeta.autoSavedAt}
+
+ {hasPublishedVersion
+ ? `${t('common.publishedAt', { ns: 'workflow' })} ${formatTimeFromNow(publishedAt)}`
+ : draftUpdatedAt
+ ? `${t('common.autoSaved', { ns: 'workflow' })} · ${formatTimeFromNow(draftUpdatedAt)}`
+ : uiMeta.autoSavedAt}