| name | pimcore-studio-ui-forms-antd |
| description | Building forms with FormKit and Ant Design in Pimcore Studio - FormKit usage, validation, panels, field types |
| metadata | {"audience":"pimcore-developers","focus":"ui-forms"} |
What This Skill Covers
Building forms in Pimcore Studio using FormKit (the standard form wrapper):
- Why and when to use FormKit (always!)
- FormKit vs plain Ant Design forms
- FormKit.Panel for organized form sections
- Form.Item for fields and validation
- Creating custom form components (controlled mode with value/onChange)
- Common field types and validation patterns
- Form state management basics
- Integration with RTK Query mutations
When to Use This Skill
Use this when:
- Creating any form in Pimcore Studio
- Building edit/create dialogs
- Implementing form validation
- Organizing complex forms with panels
- Integrating forms with RTK Query
Note: This skill focuses on forms themselves. For user feedback (success/error messages), see the pimcore-studio-ui-notifications-toasts skill.
FormKit - The Standard Form Wrapper
🚨 Import Paths
BEFORE writing any import, read CRITICAL-IMPORT-PATHS.md.
All examples below use bundle imports (@pimcore/studio-ui-bundle/*). For core development (@sdk/*, @Pimcore/*), see the referenced file.
Never import from Ant Design directly! Always import form components from @pimcore/studio-ui-bundle/components.
Why FormKit?
ALWAYS use FormKit for Pimcore Studio forms. Do not use plain Ant Design <Form> directly.
FormKit provides:
- Consistent styling - Automatic vertical layout, proper spacing, unified theming
- Built-in panels - Easy form organization with
FormKit.Panel
- Theme integration - Wraps forms with ConfigProvider for consistent design
- Field width management - Automatic consistent field widths (small: 200px, medium: 300px, large: 900px)
- Required for dynamic types - Form definitions and dynamic type forms must use FormKit
Basic FormKit Usage
Simple Form
import { FormKit, Form, Input, Button } from '@pimcore/studio-ui-bundle/components'
const MyForm = () => {
const [form] = Form.useForm()
const handleSubmit = (values) => {
console.log('Form values:', values)
}
return (
<FormKit
formProps={{
form,
onFinish: handleSubmit
}}
>
<Form.Item name="username" label="Username">
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
Submit
</Button>
</Form.Item>
</FormKit>
)
}
Key differences from plain Form:
- Wrap everything with
<FormKit>
- Pass Ant Design Form props via
formProps prop
- FormKit automatically applies vertical layout
- No need to configure theme/styling manually
FormKit Props
interface FormKitProps {
formProps?: Omit<FormProps, 'children'>
children?: React.ReactNode
}
Common formProps:
form - Form instance from Form.useForm()
initialValues - Initial form values
onFinish - Submit handler (called when validation passes)
onValuesChange - Callback when any value changes
disabled - Disable all form fields
component - Set to false to remove form wrapper element
FormKit.Panel - Organizing Forms
Use FormKit.Panel to group related fields into sections with proper theming and styling.
Basic Panel
import { FormKit, Form, Input, Select } from '@pimcore/studio-ui-bundle/components'
<FormKit formProps={{ form }}>
<FormKit.Panel title="Basic Information">
<Form.Item name="name" label="Name">
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} />
</Form.Item>
</FormKit.Panel>
<FormKit.Panel title="Advanced Settings">
<Form.Item name="status" label="Status">
<Select>
<Select.Option value="active">Active</Select.Option>
<Select.Option value="inactive">Inactive</Select.Option>
</Select>
</Form.Item>
</FormKit.Panel>
</FormKit>
Panel Themes
FormKit.Panel supports multiple themes:
<FormKit.Panel title="Settings">
<FormKit.Panel title="Options" theme="fieldset">
// Border highlight theme
<FormKit.Panel title="Info" theme="border-highlight">
Common pattern: Use theme="fieldset" for nested panels inside a main panel.
Panel Props
interface FormKitPanelProps {
title?: string
tooltip?: ReactNode
border?: boolean
collapsible?: boolean
collapsed?: boolean
theme?: 'default' | 'fieldset' | 'card-with-highlight' | 'border-highlight'
extra?: ReactNode
extraPosition?: 'start' | 'end'
contentPadding?: BoxProps['padding']
}
Nested Panels Example
import { FormKit, Form, Input, InputNumber, Switch } from '@pimcore/studio-ui-bundle/components'
<FormKit formProps={{ form }}>
<FormKit.Panel title="Specific Settings">
<Form.Item name="width" label="Width">
<Input />
</Form.Item>
{/* Nested panel with fieldset theme */}
<FormKit.Panel
border
theme="fieldset"
title="Default Values"
>
<Form.Item name="defaultValue" label="Default Value">
<InputNumber />
</Form.Item>
<Form.Item name="defaultValueGenerator" label="Generator">
<Input />
</Form.Item>
</FormKit.Panel>
<Form.Item name="enforceValidation">
<Switch labelRight="Enforce Validation" />
</Form.Item>
</FormKit.Panel>
</FormKit>
Form.Item - Fields and Validation
All form fields must be wrapped in Form.Item which handles:
- Field value binding (via
name prop)
- Label display
- Validation
- Error messages
- Layout
Basic Form.Item
<Form.Item
name="email"
label="Email"
required
tooltip="Help text"
rules={[]}
>
<Input />
</Form.Item>
Nested Field Names
<Form.Item name={['user', 'email']} label="Email">
<Input />
</Form.Item>
<Form.Item name={['items', 0, 'name']} label="Item Name">
<Input />
</Form.Item>
Validation Rules
Built-in Rules
<Form.Item
name="email"
label="Email"
rules={[
{ required: true, message: 'Please enter email' },
{ type: 'email', message: 'Please enter valid email' },
{ min: 5, message: 'Minimum 5 characters' },
{ max: 50, message: 'Maximum 50 characters' },
{ pattern: /^[a-z]+$/, message: 'Lowercase letters only' },
]}
>
<Input />
</Form.Item>
Available rule types:
required - Field must have value
type - Validates type ('email', 'url', 'number', etc.)
min/max - Length or number range
pattern - Regex pattern
whitespace - Disallow only whitespace
Custom Validation
<Form.Item
name="password"
rules={[
{ required: true },
{
validator: (_, value) => {
if (!value || value.length >= 8) {
return Promise.resolve()
}
return Promise.reject('Password must be at least 8 characters')
}
}
]}
>
<Input.Password />
</Form.Item>
Dependent Field Validation
<Form.Item
name="confirmPassword"
label="Confirm Password"
dependencies={['password']}
rules={[
{ required: true },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve()
}
return Promise.reject('Passwords do not match')
},
}),
]}
>
<Input.Password />
</Form.Item>
Common Field Types
Text Input
<Form.Item name="username" label="Username">
<Input placeholder="Enter username" />
</Form.Item>
Text Area
<Form.Item name="description" label="Description">
<Input.TextArea rows={4} />
</Form.Item>
Number Input
<Form.Item name="age" label="Age">
<InputNumber min={0} max={120} />
</Form.Item>
Select
<Form.Item name="country" label="Country">
<Select
placeholder="Select country"
options={[
{ label: 'United States', value: 'us' },
{ label: 'United Kingdom', value: 'uk' },
{ label: 'Germany', value: 'de' }
]}
/>
</Form.Item>
Switch (Boolean)
<Form.Item name="active" valuePropName="checked">
<Switch labelRight="Active" />
</Form.Item>
IMPORTANT: Always use valuePropName="checked" for boolean fields (Switch, Checkbox)!
Checkbox
<Form.Item name="agree" valuePropName="checked">
<Checkbox>I agree to the terms</Checkbox>
</Form.Item>
Radio Group
<Form.Item name="gender" label="Gender">
<Radio.Group>
<Radio value="male">Male</Radio>
<Radio value="female">Female</Radio>
<Radio value="other">Other</Radio>
</Radio.Group>
</Form.Item>
Date Picker
<Form.Item name="birthdate" label="Birth Date">
<DatePicker />
</Form.Item>
Creating Custom Form Components
Form components in Ant Design/FormKit work in controlled mode using value and onChange props.
How Form.Item Works
When you wrap a component in Form.Item, the form automatically:
- Passes the current field
value as a prop
- Passes an
onChange handler to update the value
- Handles validation and error display
<Form.Item name="myField">
<MyComponent
value={currentValue} // Injected by Form.Item
onChange={handleChange} // Injected by Form.Item
/>
</Form.Item>
Basic Custom Component Pattern
interface MyCustomInputProps {
value?: string
onChange?: (value: string) => void
disabled?: boolean
placeholder?: string
}
export const MyCustomInput = ({
value = '',
onChange,
disabled,
placeholder
}: MyCustomInputProps) => {
const handleChange = (newValue: string) => {
onChange?.(newValue)
}
return (
<div>
<input
value={value}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled}
placeholder={placeholder}
/>
</div>
)
}
<Form.Item name="customField" label="Custom Field">
<MyCustomInput placeholder="Enter value" />
</Form.Item>
Complex Value Types
For non-string values (objects, arrays, numbers), just use the appropriate type:
interface ColorPickerProps {
value?: { r: number; g: number; b: number }
onChange?: (value: { r: number; g: number; b: number }) => void
}
export const ColorPicker = ({ value, onChange }: ColorPickerProps) => {
const handleColorChange = (channel: 'r' | 'g' | 'b', newValue: number) => {
onChange?.({
...value,
[channel]: newValue
})
}
return (
<div>
<input
type="range"
value={value?.r ?? 0}
onChange={(e) => handleColorChange('r', Number(e.target.value))}
/>
{/* ... more sliders */}
</div>
)
}
<Form.Item name="brandColor" label="Brand Color">
<ColorPicker />
</Form.Item>
Using valuePropName for Non-Standard Props
Some components use a different prop name instead of value (e.g., checked for checkboxes):
<Form.Item name="enabled" valuePropName="checked">
<Switch />
</Form.Item>
interface ToggleProps {
checked?: boolean
onChange?: (checked: boolean) => void
}
export const Toggle = ({ checked, onChange }: ToggleProps) => {
return (
<button onClick={() => onChange?.(!checked)}>
{checked ? 'ON' : 'OFF'}
</button>
)
}
<Form.Item name="isActive" valuePropName="checked">
<Toggle />
</Form.Item>
Real-World Example: Element Selector
interface ElementSelectorProps {
value?: { id: number; type: string; path: string }
onChange?: (value: { id: number; type: string; path: string }) => void
allowedTypes?: string[]
disabled?: boolean
}
export const ElementSelector = ({
value,
onChange,
allowedTypes = ['document', 'asset'],
disabled
}: ElementSelectorProps) => {
const [modalOpen, setModalOpen] = useState(false)
const handleSelect = (element: any) => {
onChange?.({
id: element.id,
type: element.type,
path: element.path
})
setModalOpen(false)
}
const handleClear = () => {
onChange?.(undefined)
}
return (
<div>
{value ? (
<div>
<span>{value.path}</span>
<Button onClick={handleClear} disabled={disabled}>
Clear
</Button>
</div>
) : (
<Button onClick={() => setModalOpen(true)} disabled={disabled}>
Select Element
</Button>
)}
<ElementPickerModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleSelect}
allowedTypes={allowedTypes}
/>
</div>
)
}
<Form.Item
name="linkedDocument"
label="Linked Document"
rules={[{ required: true, message: 'Please select a document' }]}
>
<ElementSelector allowedTypes={['document']} />
</Form.Item>
Wrapping Existing Components
You can create controlled wrappers around any component:
import SomeLibraryComponent from 'some-library'
interface WrappedComponentProps {
value?: any
onChange?: (value: any) => void
}
export const WrappedComponent = ({ value, onChange }: WrappedComponentProps) => {
return (
<SomeLibraryComponent
currentValue={value} // Library uses different prop name
onValueChange={onChange} // Library uses different callback name
/>
)
}
<Form.Item name="wrappedField">
<WrappedComponent />
</Form.Item>
Key Points
- Always accept
value and onChange props - Form.Item will inject these
- Make them optional with defaults - Component should work standalone too
- Call
onChange with the new value - Not an event object, just the value
- Use proper TypeScript types - Define interfaces for your props
- Handle undefined/null values - Forms may initialize with no value
- Use
valuePropName - When your component uses a different prop (e.g., checked)
Form State Management
Setting Initial Values
Method 1: initialValues in formProps
<FormKit
formProps={{
form,
initialValues: {
username: 'john',
active: true
}
}}
>
Method 2: form.setFieldsValue() in useEffect
const [form] = Form.useForm()
const { data } = useGetEntityQuery({ id })
useEffect(() => {
if (data) {
form.setFieldsValue({
username: data.username,
email: data.email
})
}
}, [data, form])
Use Method 2 when loading async data!
Getting Form Values
const [form] = Form.useForm()
const values = form.getFieldsValue()
const username = form.getFieldValue('username')
Resetting Form
form.resetFields()
form.resetFields(['username', 'email'])
Form Submission with RTK Query
Edit Form Pattern
import { useAssetUpdateMutation, useAssetGetByIdQuery } from '@pimcore/studio-ui-bundle/api/asset'
import { FormKit, Form, Input, Button, Skeleton, message } from '@pimcore/studio-ui-bundle/components'
import { trackError, ApiError } from '@pimcore/studio-ui-bundle/modules/app'
import { useEffect } from 'react'
import { isNil } from 'lodash'
const AssetEditForm = ({ assetId }: { assetId: number }) => {
const [form] = Form.useForm()
const { data, isLoading, error: queryError } = useAssetGetByIdQuery({ id: assetId })
const [updateAsset, { data: updateData, error: updateError, isLoading: isSaving }] = useAssetUpdateMutation()
useEffect(() => {
if (!isNil(queryError)) {
trackError(new ApiError(queryError))
}
}, [queryError])
useEffect(() => {
if (!isNil(updateError)) {
trackError(new ApiError(updateError))
}
}, [updateError])
useEffect(() => {
if (!isNil(updateData)) {
message.success('Saved successfully')
}
}, [updateData])
useEffect(() => {
if (data) {
form.setFieldsValue({
filename: data.filename,
description: data.description
})
}
}, [data, form])
const handleSubmit = (values) => {
updateAsset({
id: assetId,
body: values
})
}
if (isLoading) return <Skeleton />
return (
<FormKit formProps={{ form, onFinish: handleSubmit }}>
<FormKit.Panel title="Basic Information">
<Form.Item name="filename" label="Filename">
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} />
</Form.Item>
</FormKit.Panel>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={isSaving}
>
Save
</Button>
</Form.Item>
</FormKit>
)
}
Create Form Pattern
import { useCreateEntityMutation } from '@pimcore/studio-ui-bundle/api/entity'
import { FormKit, Form, Button, message } from '@pimcore/studio-ui-bundle/components'
import { trackError, ApiError } from '@pimcore/studio-ui-bundle/modules/app'
import { useEffect } from 'react'
import { isNil } from 'lodash'
const CreateForm = () => {
const [createEntity, { data, error, isLoading }] = useCreateEntityMutation()
useEffect(() => {
if (!isNil(error)) {
trackError(new ApiError(error))
}
}, [error])
useEffect(() => {
if (!isNil(data)) {
message.success('Created successfully')
}
}, [data])
const handleSubmit = (values) => {
createEntity({ body: values })
}
return (
<FormKit formProps={{ onFinish: handleSubmit }}>
<FormKit.Panel title="Create New Entity">
{/* fields */}
</FormKit.Panel>
<Button type="primary" htmlType="submit" loading={isLoading}>
Create
</Button>
</FormKit>
)
}
Advanced Patterns
Dynamic Fields (Form.List)
Add/remove fields dynamically:
<FormKit formProps={{ form }}>
<Form.List name="emails">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key}>
<Form.Item
{...field}
name={[field.name, 'email']}
rules={[{ required: true, type: 'email' }]}
>
<Input placeholder="Email address" />
</Form.Item>
<Button onClick={() => remove(field.name)}>
Remove
</Button>
</Space>
))}
<Button onClick={() => add()}>
Add Email
</Button>
</>
)}
</Form.List>
</FormKit>
Conditional Fields
Show fields based on other values:
const MyForm = () => {
const [form] = Form.useForm()
const type = Form.useWatch('type', form)
return (
<FormKit formProps={{ form }}>
<Form.Item name="type" label="Type">
<Select>
<Select.Option value="basic">Basic</Select.Option>
<Select.Option value="advanced">Advanced</Select.Option>
</Select>
</Form.Item>
{type === 'advanced' && (
<Form.Item name="advancedSettings" label="Advanced Settings">
<Input />
</Form.Item>
)}
</FormKit>
)
}
Watching Form Values
Monitor field changes:
const [form] = Form.useForm()
const username = Form.useWatch('username', form)
const email = Form.useWatch(['user', 'email'], form)
const values = Form.useWatch([], form)
Handling Value Changes
<FormKit
formProps={{
form,
onValuesChange: (changedValues, allValues) => {
console.log('Changed:', changedValues)
console.log('All:', allValues)
}
}}
>
Common Mistakes to Avoid
❌ Using plain Form instead of FormKit
<Form form={form}>
✅ Always use FormKit
<FormKit formProps={{ form }}>
❌ Forgetting valuePropName="checked" for boolean fields
<Form.Item name="active">
<Switch />
</Form.Item>
✅ Use valuePropName for non-value props
<Form.Item name="active" valuePropName="checked">
<Switch />
</Form.Item>
❌ Not handling undefined data
form.setFieldsValue({
name: data.name
})
✅ Check data exists first
if (data) {
form.setFieldsValue({
name: data.name
})
}
❌ Not disabling submit during save
<Button htmlType="submit">Save</Button>
✅ Disable while saving
<Button htmlType="submit" loading={isSaving} disabled={isSaving}>
Save
</Button>
TypeScript Tips
Type your form values:
interface FormValues {
username: string
email: string
active: boolean
}
const MyForm = () => {
const [form] = Form.useForm<FormValues>()
const handleSubmit = (values: FormValues) => {
}
return (
<FormKit formProps={{ form, onFinish: handleSubmit }}>
{/* fields */}
</FormKit>
)
}
Common Mistakes
❌ Mistake 1: Using Plain Ant Design Form
import { Form } from 'antd'
const [form] = Form.useForm()
import { FormKit, Form } from '@pimcore/studio-ui-bundle/components'
const [form] = Form.useForm()
return <FormKit formProps={{ form }}>{/* fields */}</FormKit>
❌ Mistake 2: Wrong Import Paths
import { Form, Input } from 'antd'
import { FormKit, Form, Input } from '@pimcore/studio-ui-bundle/components'
❌ Mistake 3: Not Using FormKit.Panel
<div className="form-section">
<h3>User Details</h3>
<Form.Item name="name">...</Form.Item>
</div>
<FormKit.Panel title="User details">
<Form.Item name="name">...</Form.Item>
</FormKit.Panel>
❌ Mistake 4: Missing value/onChange in Custom Components
export const MyField = (props: Props): React.JSX.Element => {
const [value, setValue] = useState(props.initialValue)
return <input value={value} onChange={(e) => setValue(e.target.value)} />
}
interface Props {
value?: string
onChange?: (value: string) => void
}
export const MyField = ({ value, onChange }: Props): React.JSX.Element => {
return <input value={value ?? ''} onChange={(e) => onChange?.(e.target.value)} />
}
❌ Mistake 5: Not Integrating with RTK Query Mutations
const handleSubmit = async (values) => {
await fetch('/api/save', { body: JSON.stringify(values) })
}
const [updateUser] = useUserUpdateMutation()
const handleSubmit = (values: FormValues): void => {
updateUser({ id: userId, body: values })
}
Next Steps