import type { Meta, StoryObj } from '@storybook/nextjs' import { useMemo, useState } from 'react' import { useStore } from '@tanstack/react-form' import ContactFields from './form-scenarios/demo/contact-fields' import { demoFormOpts } from './form-scenarios/demo/shared-options' import { ContactMethods, UserSchema } from './form-scenarios/demo/types' import BaseForm from './components/base/base-form' import type { FormSchema } from './types' import { FormTypeEnum } from './types' import { type FormStoryRender, FormStoryWrapper } from '../../../../.storybook/utils/form-story-wrapper' import Button from '../button' import { TransferMethod } from '@/types/app' import { PreviewMode } from '@/app/components/base/features/types' const FormStoryHost = () => null const meta = { title: 'Base/Data Entry/AppForm', component: FormStoryHost, parameters: { layout: 'fullscreen', docs: { description: { component: 'Helper utilities built on top of `@tanstack/react-form` that power form rendering across Dify. These stories demonstrate the `useAppForm` hook, field primitives, conditional visibility, and custom actions.', }, }, }, tags: ['autodocs'], } satisfies Meta export default meta type Story = StoryObj type AppFormInstance = Parameters[0] type ContactFieldsProps = React.ComponentProps type ContactFieldsFormApi = ContactFieldsProps['form'] type PlaygroundFormFieldsProps = { form: AppFormInstance status: string } const PlaygroundFormFields = ({ form, status }: PlaygroundFormFieldsProps) => { type PlaygroundFormValues = typeof demoFormOpts.defaultValues const name = useStore(form.store, state => (state.values as PlaygroundFormValues).name) const contactFormApi = form as ContactFieldsFormApi return (
{ event.preventDefault() event.stopPropagation() form.handleSubmit() }} > ( )} /> ( )} /> ( )} /> {!!name && }

{status}

) } const FormPlayground = () => { const [status, setStatus] = useState('Fill in the form and submit to see results.') return ( { const result = UserSchema.safeParse(formValue as typeof demoFormOpts.defaultValues) if (!result.success) return result.error.issues[0].message return undefined }, }, onSubmit: () => { setStatus('Successfully saved profile.') }, }} > {form => } ) } const mockFileUploadConfig = { enabled: true, allowed_file_extensions: ['pdf', 'png'], allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], number_limits: 3, preview_config: { mode: PreviewMode.CurrentPage, file_type_list: ['pdf', 'png'], }, } const mockFieldDefaults = { headline: 'Dify App', description: 'Streamline your AI workflows with configurable building blocks.', category: 'workbench', allowNotifications: true, dailyLimit: 40, attachment: [], } const FieldGallery = () => { const selectOptions = useMemo(() => [ { value: 'workbench', label: 'Workbench' }, { value: 'playground', label: 'Playground' }, { value: 'production', label: 'Production' }, ], []) return ( {form => (
{ event.preventDefault() event.stopPropagation() form.handleSubmit() }} > ( )} /> ( )} /> ( )} /> ( )} /> ( )} /> ( )} />
)}
) } const conditionalSchemas: FormSchema[] = [ { type: FormTypeEnum.select, name: 'channel', label: 'Preferred channel', required: true, default: 'email', options: ContactMethods, }, { type: FormTypeEnum.textInput, name: 'contactEmail', label: 'Email address', required: true, placeholder: 'user@example.com', show_on: [{ variable: 'channel', value: 'email' }], }, { type: FormTypeEnum.textInput, name: 'contactPhone', label: 'Phone number', required: true, placeholder: '+1 555 123 4567', show_on: [{ variable: 'channel', value: 'phone' }], }, { type: FormTypeEnum.boolean, name: 'optIn', label: 'Opt in to marketing messages', required: false, }, ] const ConditionalFieldsStory = () => { const [values, setValues] = useState>({ channel: 'email', optIn: false, }) return (
{ setValues(prev => ({ ...prev, [field]: value, })) }} />
) } const CustomActionsStory = () => { return ( { const nextValues = value as { datasetName?: string } if (!nextValues.datasetName || nextValues.datasetName.length < 3) return 'Dataset name must contain at least 3 characters.' return undefined }, }, }} > {form => (
{ event.preventDefault() event.stopPropagation() form.handleSubmit() }} > ( )} /> ( )} /> (
)} />
)}
) } export const Playground: Story = { render: () => , parameters: { docs: { source: { language: 'tsx', code: ` const form = useAppForm({ ...demoFormOpts, validators: { onSubmit: ({ value }) => UserSchema.safeParse(value).success ? undefined : 'Validation failed', }, onSubmit: ({ value }) => { setStatus(\`Successfully saved profile for \${value.name}\`) }, }) return (
{field => } {field => } {field => } {!!form.store.state.values.name && } ) `.trim(), }, }, }, } export const FieldExplorer: Story = { render: () => , parameters: { nextjs: { appDirectory: true, navigation: { pathname: '/apps/demo-app/form', params: { appId: 'demo-app' }, }, }, docs: { source: { language: 'tsx', code: ` const form = useAppForm({ defaultValues: { headline: 'Dify App', description: 'Streamline your AI workflows', category: 'workbench', allowNotifications: true, dailyLimit: 40, attachment: [], }, }) return (
{field => } {field => } {field => } {field => } {field => } {field => }
) `.trim(), }, }, }, } export const ConditionalVisibility: Story = { render: () => , parameters: { docs: { description: { story: 'Demonstrates schema-driven visibility using `show_on` conditions rendered through the reusable `BaseForm` component.', }, source: { language: 'tsx', code: ` const conditionalSchemas: FormSchema[] = [ { type: FormTypeEnum.select, name: 'channel', label: 'Preferred channel', options: ContactMethods }, { type: FormTypeEnum.textInput, name: 'contactEmail', label: 'Email', show_on: [{ variable: 'channel', value: 'email' }] }, { type: FormTypeEnum.textInput, name: 'contactPhone', label: 'Phone', show_on: [{ variable: 'channel', value: 'phone' }] }, { type: FormTypeEnum.boolean, name: 'optIn', label: 'Opt in to marketing messages' }, ] return ( setValues(prev => ({ ...prev, [field]: value }))} /> ) `.trim(), }, }, }, } export const CustomActions: Story = { render: () => , parameters: { docs: { description: { story: 'Shows how to replace the default submit button with a fully custom footer leveraging contextual form state.', }, source: { language: 'tsx', code: ` const form = useAppForm({ defaultValues: { datasetName: 'Support FAQ', datasetDescription: 'Knowledge base snippets sourced from Zendesk exports.', }, validators: { onChange: ({ value }) => value.datasetName?.length >= 3 ? undefined : 'Dataset name must contain at least 3 characters.', }, }) return (
{field => } {field => } (
)} />
) `.trim(), }, }, }, }