dify/web/app/components/base/form/index.stories.tsx
Yeuoly b76e17b25d
feat: introduce trigger functionality (#27644)
Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: Stream <Stream_2@qq.com>
Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: zhsama <torvalds@linux.do>
Co-authored-by: Harry <xh001x@hotmail.com>
Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: yessenia <yessenia.contact@gmail.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WTW0313 <twwu@dify.ai>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-12 17:59:37 +08:00

560 lines
16 KiB
TypeScript

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<typeof FormStoryHost>
export default meta
type Story = StoryObj<typeof meta>
type AppFormInstance = Parameters<FormStoryRender>[0]
type ContactFieldsProps = React.ComponentProps<typeof ContactFields>
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 (
<form
className="flex w-full max-w-xl flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="name"
children={field => (
<field.TextField
label="Name"
placeholder="Start with a capital letter"
/>
)}
/>
<form.AppField
name="surname"
children={field => (
<field.TextField
label="Surname"
placeholder="Surname must be at least 3 characters"
/>
)}
/>
<form.AppField
name="isAcceptingTerms"
children={field => (
<field.CheckboxField
label="I accept the terms and conditions"
/>
)}
/>
{!!name && <ContactFields form={contactFormApi} />}
<form.AppForm>
<form.Actions />
</form.AppForm>
<p className="text-xs text-text-tertiary">{status}</p>
</form>
)
}
const FormPlayground = () => {
const [status, setStatus] = useState('Fill in the form and submit to see results.')
return (
<FormStoryWrapper
title="Customer onboarding form"
subtitle="Validates with zod and conditionally reveals contact preferences."
options={{
...demoFormOpts,
validators: {
onSubmit: ({ value: formValue }) => {
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 => <PlaygroundFormFields form={form} status={status} />}
</FormStoryWrapper>
)
}
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 (
<FormStoryWrapper
title="Field gallery"
subtitle="Preview the most common field primitives exposed through `form.AppField` helpers."
options={{
defaultValues: mockFieldDefaults,
}}
>
{form => (
<form
className="grid w-full max-w-4xl grid-cols-1 gap-4 lg:grid-cols-2"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="headline"
children={field => (
<field.TextField
label="Headline"
placeholder="Name your experience"
/>
)}
/>
<form.AppField
name="description"
children={field => (
<field.TextAreaField
label="Description"
placeholder="Describe what this configuration does"
/>
)}
/>
<form.AppField
name="category"
children={field => (
<field.SelectField
label="Category"
options={selectOptions}
/>
)}
/>
<form.AppField
name="allowNotifications"
children={field => (
<field.CheckboxField label="Enable usage notifications" />
)}
/>
<form.AppField
name="dailyLimit"
children={field => (
<field.NumberSliderField
label="Daily session limit"
description="Control the maximum number of runs per user each day."
min={10}
max={100}
/>
)}
/>
<form.AppField
name="attachment"
children={field => (
<field.FileUploaderField
label="Reference materials"
fileConfig={mockFileUploadConfig}
/>
)}
/>
<div className="lg:col-span-2">
<form.AppForm>
<form.Actions />
</form.AppForm>
</div>
</form>
)}
</FormStoryWrapper>
)
}
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<Record<string, unknown>>({
channel: 'email',
optIn: false,
})
return (
<div className="flex flex-col gap-6 px-6 md:flex-row md:px-10">
<div className="flex-1 rounded-xl border border-divider-subtle bg-components-panel-bg p-5 shadow-sm">
<BaseForm
formSchemas={conditionalSchemas}
defaultValues={values}
formClassName="flex flex-col gap-4"
onChange={(field, value) => {
setValues(prev => ({
...prev,
[field]: value,
}))
}}
/>
</div>
<aside className="w-full max-w-sm rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary shadow-sm">
<h3 className="text-sm font-semibold text-text-primary">Live values</h3>
<p className="mb-2 text-[11px] text-text-tertiary">`show_on` rules hide or reveal inputs without losing track of the form state.</p>
<pre className="max-h-48 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-[11px] leading-tight text-text-primary">
{JSON.stringify(values, null, 2)}
</pre>
</aside>
</div>
)
}
const CustomActionsStory = () => {
return (
<FormStoryWrapper
title="Custom footer actions"
subtitle="Override the default submit button to add reset or secondary operations."
options={{
defaultValues: {
datasetName: 'Support FAQ',
datasetDescription: 'Knowledge base snippets sourced from Zendesk exports.',
},
validators: {
onChange: ({ value }) => {
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 => (
<form
className="flex w-full max-w-xl flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="datasetName"
children={field => (
<field.TextField
label="Dataset name"
placeholder="Support knowledge base"
/>
)}
/>
<form.AppField
name="datasetDescription"
children={field => (
<field.TextAreaField
label="Description"
placeholder="Add a helpful summary for collaborators"
/>
)}
/>
<form.AppForm>
<form.Actions
CustomActions={({ form: appForm, isSubmitting, canSubmit }) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={() => appForm.reset()}
disabled={isSubmitting}
>
Reset
</Button>
<Button
variant="tertiary"
onClick={() => {
appForm.handleSubmit()
}}
disabled={!canSubmit}
loading={isSubmitting}
>
Save draft
</Button>
<Button
variant="primary"
onClick={() => appForm.handleSubmit()}
disabled={!canSubmit}
loading={isSubmitting}
>
Publish
</Button>
</div>
)}
/>
</form.AppForm>
</form>
)}
</FormStoryWrapper>
)
}
export const Playground: Story = {
render: () => <FormPlayground />,
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 (
<form onSubmit={handleSubmit}>
<form.AppField name="name">
{field => <field.TextField label="Name" placeholder="Start with a capital letter" />}
</form.AppField>
<form.AppField name="surname">
{field => <field.TextField label="Surname" />}
</form.AppField>
<form.AppField name="isAcceptingTerms">
{field => <field.CheckboxField label="I accept the terms and conditions" />}
</form.AppField>
{!!form.store.state.values.name && <ContactFields form={form} />}
<form.AppForm>
<form.Actions />
</form.AppForm>
</form>
)
`.trim(),
},
},
},
}
export const FieldExplorer: Story = {
render: () => <FieldGallery />,
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 (
<form className="grid grid-cols-1 gap-4 lg:grid-cols-2" onSubmit={handleSubmit}>
<form.AppField name="headline">
{field => <field.TextField label="Headline" />}
</form.AppField>
<form.AppField name="description">
{field => <field.TextAreaField label="Description" />}
</form.AppField>
<form.AppField name="category">
{field => <field.SelectField label="Category" options={selectOptions} />}
</form.AppField>
<form.AppField name="allowNotifications">
{field => <field.CheckboxField label="Enable usage notifications" />}
</form.AppField>
<form.AppField name="dailyLimit">
{field => <field.NumberSliderField label="Daily session limit" min={10} max={100} step={10} />}
</form.AppField>
<form.AppField name="attachment">
{field => <field.FileUploaderField label="Reference materials" fileConfig={mockFileUploadConfig} />}
</form.AppField>
<form.AppForm>
<form.Actions />
</form.AppForm>
</form>
)
`.trim(),
},
},
},
}
export const ConditionalVisibility: Story = {
render: () => <ConditionalFieldsStory />,
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 (
<BaseForm
formSchemas={conditionalSchemas}
defaultValues={{ channel: 'email', optIn: false }}
formClassName="flex flex-col gap-4"
onChange={(field, value) => setValues(prev => ({ ...prev, [field]: value }))}
/>
)
`.trim(),
},
},
},
}
export const CustomActions: Story = {
render: () => <CustomActionsStory />,
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 (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<form.AppField name="datasetName">
{field => <field.TextField label="Dataset name" />}
</form.AppField>
<form.AppField name="datasetDescription">
{field => <field.TextAreaField label="Description" />}
</form.AppField>
<form.AppForm>
<form.Actions
CustomActions={({ form: appForm, isSubmitting, canSubmit }) => (
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={() => appForm.reset()} disabled={isSubmitting}>
Reset
</Button>
<Button variant="tertiary" onClick={() => appForm.handleSubmit()} disabled={!canSubmit} loading={isSubmitting}>
Save draft
</Button>
<Button variant="primary" onClick={() => appForm.handleSubmit()} disabled={!canSubmit} loading={isSubmitting}>
Publish
</Button>
</div>
)}
/>
</form.AppForm>
</form>
)
`.trim(),
},
},
},
}