From a9dc57eeef6cb90f8fcced0f3eed1b304eff7923 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Thu, 16 Apr 2026 11:07:26 +0800 Subject: [PATCH] fix(web): input fields form & graph publish --- .../__tests__/input-field-editor.spec.tsx | 2 +- .../__tests__/publish-menu.spec.tsx | 20 ++++ .../__tests__/use-snippet-publish.spec.ts | 15 ++- .../components/hooks/use-snippet-publish.ts | 7 +- .../components/input-field-editor.tsx | 9 +- .../input-field-form/__tests__/hooks.spec.ts | 40 ++++++++ .../input-field-form/__tests__/schema.spec.ts | 51 ++++++++++ .../components/input-field-form/hooks.ts | 30 ++++++ .../components/input-field-form/index.tsx | 92 ++++++++++++++++++ .../input-field-form/initial-fields.tsx | 50 ++++++++++ .../components/input-field-form/schema.ts | 95 +++++++++++++++++++ .../snippets/components/publish-menu.tsx | 24 ++++- .../components/snippet-header/index.tsx | 7 +- .../components/snippet-header/publisher.tsx | 8 +- .../hooks/__tests__/use-snippet-init.spec.ts | 13 ++- .../snippets/hooks/use-snippet-init.ts | 11 ++- web/service/use-snippet-workflows.ts | 14 ++- 17 files changed, 465 insertions(+), 23 deletions(-) create mode 100644 web/app/components/snippets/components/input-field-form/__tests__/hooks.spec.ts create mode 100644 web/app/components/snippets/components/input-field-form/__tests__/schema.spec.ts create mode 100644 web/app/components/snippets/components/input-field-form/hooks.ts create mode 100644 web/app/components/snippets/components/input-field-form/index.tsx create mode 100644 web/app/components/snippets/components/input-field-form/initial-fields.tsx create mode 100644 web/app/components/snippets/components/input-field-form/schema.ts 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 ( +
{ + e.preventDefault() + e.stopPropagation() + inputFieldForm.handleSubmit() + }} + > +
+ + + {!showAllSettings && ()} + {showAllSettings && ()} +
+
+ + + + +
+
+ ) +} + +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}