mirror of
https://mirror.skon.top/github.com/langgenius/dify.git
synced 2026-04-30 09:40:32 +08:00
fix(web): input fields form & graph publish
This commit is contained in:
@@ -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 }) => (
|
||||
<div data-testid="snippet-input-field-form">{isEditMode ? 'edit' : 'create'}</div>
|
||||
),
|
||||
|
||||
@@ -5,6 +5,8 @@ describe('PublishMenu', () => {
|
||||
it('should render the draft summary and publish shortcut', () => {
|
||||
const { container } = render(
|
||||
<PublishMenu
|
||||
draftUpdatedAt={0}
|
||||
publishedAt={0}
|
||||
uiMeta={{
|
||||
inputFieldCount: 1,
|
||||
checklistCount: 2,
|
||||
@@ -19,4 +21,22 @@ describe('PublishMenu', () => {
|
||||
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(
|
||||
<PublishMenu
|
||||
draftUpdatedAt={1_712_300_000_000}
|
||||
publishedAt={1_712_345_678_000}
|
||||
uiMeta={{
|
||||
inputFieldCount: 1,
|
||||
checklistCount: 2,
|
||||
autoSavedAt: 'Auto-saved · a few seconds ago',
|
||||
}}
|
||||
onPublish={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.common.latestPublished')).toBeInTheDocument()
|
||||
expect(screen.getByText(/workflow\.common\.publishedAt/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center pb-1 pl-4 pr-11 pt-3.5 text-text-primary system-xl-semibold">
|
||||
<div className="flex items-center pt-3.5 pr-11 pb-1 pl-4 system-xl-semibold text-text-primary">
|
||||
{field ? t('inputFieldPanel.editInputField', { ns: 'datasetPipeline' }) : t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center"
|
||||
className="absolute top-2.5 right-2.5 flex h-8 w-8 items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
<span aria-hidden className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
<InputFieldForm
|
||||
initialData={initialData}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useSnippetInputFieldConfigurations } from '../hooks'
|
||||
|
||||
const mockUseConfigurations = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/components/panel/input-field/editor/form/hooks', () => ({
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<FormData>) => unknown
|
||||
setFieldValue: (fieldName: DeepKeys<FormData>, 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])
|
||||
}
|
||||
@@ -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 (
|
||||
<form
|
||||
className="w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
inputFieldForm.handleSubmit()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4 px-4 py-2">
|
||||
<InitialFieldsComp form={inputFieldForm} />
|
||||
<Divider type="horizontal" />
|
||||
{!showAllSettings && (<ShowAllSettingComp form={inputFieldForm} />)}
|
||||
{showAllSettings && (<HiddenFieldsComp form={inputFieldForm} />)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2 p-4 pt-2">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<inputFieldForm.AppForm>
|
||||
<inputFieldForm.Actions />
|
||||
</inputFieldForm.AppForm>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetInputFieldForm
|
||||
@@ -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<string, unknown>
|
||||
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 <FieldComponent key={key} form={form} />
|
||||
})}
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export default InitialFields
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-3 px-4 pb-4 pt-3">
|
||||
<div className="flex flex-col gap-3 px-4 pt-3 pb-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="min-h-6 text-text-tertiary system-xs-medium-uppercase">
|
||||
{t('publishMenuCurrentDraft')}
|
||||
<div className="min-h-6 system-xs-medium-uppercase text-text-tertiary">
|
||||
{hasPublishedVersion
|
||||
? t('common.latestPublished', { ns: 'workflow' })
|
||||
: t('publishMenuCurrentDraft')}
|
||||
</div>
|
||||
<div className="text-text-secondary system-sm-medium">
|
||||
{uiMeta.autoSavedAt}
|
||||
<div className="system-sm-medium text-text-secondary">
|
||||
{hasPublishedVersion
|
||||
? `${t('common.publishedAt', { ns: 'workflow' })} ${formatTimeFromNow(publishedAt)}`
|
||||
: draftUpdatedAt
|
||||
? `${t('common.autoSaved', { ns: 'workflow' })} · ${formatTimeFromNow(draftUpdatedAt)}`
|
||||
: uiMeta.autoSavedAt}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import Header from '@/app/components/workflow/header'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import InputFieldButton from './input-field-button'
|
||||
import Publisher from './publisher'
|
||||
import RunMode from './run-mode'
|
||||
@@ -32,6 +33,8 @@ const SnippetHeader = ({
|
||||
onPublishMenuOpenChange,
|
||||
onPublish,
|
||||
}: SnippetHeaderProps) => {
|
||||
const draftUpdatedAt = useStore(state => state.draftUpdatedAt)
|
||||
const publishedAt = useStore(state => state.publishedAt)
|
||||
const viewHistoryProps = useMemo(() => {
|
||||
return {
|
||||
historyUrl: `/snippets/${snippetId}/workflow-runs`,
|
||||
@@ -46,10 +49,12 @@ const SnippetHeader = ({
|
||||
middle: (
|
||||
<Publisher
|
||||
uiMeta={uiMeta}
|
||||
draftUpdatedAt={draftUpdatedAt}
|
||||
open={isPublishMenuOpen}
|
||||
isPublishing={isPublishing}
|
||||
onOpenChange={onPublishMenuOpenChange}
|
||||
onPublish={onPublish}
|
||||
publishedAt={publishedAt}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -69,7 +74,7 @@ const SnippetHeader = ({
|
||||
viewHistoryProps,
|
||||
},
|
||||
}
|
||||
}, [inputFieldCount, isPublishMenuOpen, isPublishing, onPublish, onPublishMenuOpenChange, onToggleInputPanel, uiMeta, viewHistoryProps])
|
||||
}, [draftUpdatedAt, inputFieldCount, isPublishMenuOpen, isPublishing, onPublish, onPublishMenuOpenChange, onToggleInputPanel, publishedAt, uiMeta, viewHistoryProps])
|
||||
|
||||
return <Header {...headerProps} />
|
||||
}
|
||||
|
||||
@@ -12,25 +12,29 @@ import PublishMenu from '../publish-menu'
|
||||
|
||||
type PublisherProps = {
|
||||
uiMeta: SnippetDetailUIModel
|
||||
draftUpdatedAt: number
|
||||
open: boolean
|
||||
isPublishing: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onPublish: () => void
|
||||
publishedAt: number
|
||||
}
|
||||
|
||||
const Publisher = ({
|
||||
uiMeta,
|
||||
draftUpdatedAt,
|
||||
open,
|
||||
isPublishing,
|
||||
onOpenChange,
|
||||
onPublish,
|
||||
publishedAt,
|
||||
}: PublisherProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1 rounded-lg bg-components-button-primary-bg px-3 py-2 text-white shadow-[0px_2px_2px_-1px_rgba(0,0,0,0.12),0px_1px_1px_-1px_rgba(0,0,0,0.12),0px_0px_0px_0.5px_rgba(9,9,11,0.05)]">
|
||||
<span className="text-[13px] font-medium leading-4">{t('publishButton')}</span>
|
||||
<span className="text-[13px] leading-4 font-medium">{t('publishButton')}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -39,6 +43,8 @@ const Publisher = ({
|
||||
popupClassName="w-80 !rounded-2xl !bg-components-panel-bg !p-0 !shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]"
|
||||
>
|
||||
<PublishMenu
|
||||
draftUpdatedAt={draftUpdatedAt}
|
||||
publishedAt={publishedAt}
|
||||
uiMeta={uiMeta}
|
||||
isPublishing={isPublishing}
|
||||
onPublish={onPublish}
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('useSnippetInit', () => {
|
||||
onSuccess?.({
|
||||
created_at: 1_712_345_678,
|
||||
})
|
||||
return { data: undefined, isLoading: false }
|
||||
return { data: { created_at: 1_712_345_678 }, isLoading: false }
|
||||
})
|
||||
|
||||
renderHook(() => useSnippetInit('snippet-1'))
|
||||
@@ -210,6 +210,17 @@ describe('useSnippetInit', () => {
|
||||
expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
|
||||
})
|
||||
|
||||
it('should reset published metadata when the published workflow is unavailable', () => {
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(mockSetPublishedAt).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should stay loading while draft workflow is still fetching', () => {
|
||||
mockUseSnippetDraftWorkflow.mockReturnValue({
|
||||
data: undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
useSnippetDefaultBlockConfigs,
|
||||
@@ -54,10 +54,17 @@ export const useSnippetInit = (snippetId: string) => {
|
||||
nodesDefaultConfigs: normalizeNodesDefaultConfigs(nodesDefaultConfigs),
|
||||
})
|
||||
})
|
||||
useSnippetPublishedWorkflow(snippetId, (publishedWorkflow) => {
|
||||
const publishedWorkflowQuery = useSnippetPublishedWorkflow(snippetId, (publishedWorkflow) => {
|
||||
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (publishedWorkflowQuery.isLoading)
|
||||
return
|
||||
|
||||
workflowStore.getState().setPublishedAt(publishedWorkflowQuery.data?.created_at ?? 0)
|
||||
}, [publishedWorkflowQuery.data?.created_at, publishedWorkflowQuery.isLoading, workflowStore])
|
||||
|
||||
const mockData = useMemo(() => getSnippetDetailMock(snippetId), [snippetId])
|
||||
const shouldUseMockData = !snippetApiDetail.isLoading && !snippetApiDetail.data && !!mockData
|
||||
|
||||
|
||||
@@ -77,9 +77,17 @@ export const useSnippetPublishedWorkflow = (
|
||||
return useQuery({
|
||||
...queryOptions,
|
||||
queryFn: async (context) => {
|
||||
const publishedWorkflow = await queryOptions.queryFn(context)
|
||||
onSuccess?.(publishedWorkflow)
|
||||
return publishedWorkflow
|
||||
try {
|
||||
const publishedWorkflow = await queryOptions.queryFn(context)
|
||||
onSuccess?.(publishedWorkflow)
|
||||
return publishedWorkflow
|
||||
}
|
||||
catch (error) {
|
||||
if (isNotFoundError(error))
|
||||
return undefined
|
||||
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user