skills/pimcore-studio-ui-forms-antd/SKILL.md
Building forms with FormKit and Ant Design in Pimcore Studio - FormKit usage, validation, panels, field types
npx skillsauth add pimcore/skills pimcore-studio-ui-forms-antdInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Building forms in Pimcore Studio using FormKit (the standard form wrapper):
Use this when:
Note: This skill focuses on forms themselves. For user feedback (success/error messages), see the pimcore-studio-ui-notifications-toasts skill.
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.
ALWAYS use FormKit for Pimcore Studio forms. Do not use plain Ant Design <Form> directly.
FormKit provides:
FormKit.Panelimport { 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:
<FormKit>formProps propinterface FormKitProps {
formProps?: Omit<FormProps, 'children'> // Ant Design Form props
children?: React.ReactNode
}
Common formProps:
form - Form instance from Form.useForm()initialValues - Initial form valuesonFinish - Submit handler (called when validation passes)onValuesChange - Callback when any value changesdisabled - Disable all form fieldscomponent - Set to false to remove form wrapper elementUse FormKit.Panel to group related fields into sections with proper theming and styling.
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>
FormKit.Panel supports multiple themes:
// Default theme (card with highlight)
<FormKit.Panel title="Settings">
// Fieldset theme (border with inset title)
<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.
interface FormKitPanelProps {
title?: string // Panel title
tooltip?: ReactNode // Tooltip for the title
border?: boolean // Show border
collapsible?: boolean // Make panel collapsible
collapsed?: boolean // Initial collapsed state
theme?: 'default' | 'fieldset' | 'card-with-highlight' | 'border-highlight'
extra?: ReactNode // Extra content in header
extraPosition?: 'start' | 'end' // Position of extra content
contentPadding?: BoxProps['padding'] // Custom padding
}
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>
All form fields must be wrapped in Form.Item which handles:
name prop)<Form.Item
name="email" // Field name in form values
label="Email" // Label text
required // Shows required indicator
tooltip="Help text" // Help tooltip
rules={[/* validation */]}
>
<Input />
</Form.Item>
// Nested object: { user: { email: "..." } }
<Form.Item name={['user', 'email']} label="Email">
<Input />
</Form.Item>
// Array item: { items: [{ name: "..." }] }
<Form.Item name={['items', 0, 'name']} label="Item Name">
<Input />
</Form.Item>
<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 valuetype - Validates type ('email', 'url', 'number', etc.)min/max - Length or number rangepattern - Regex patternwhitespace - Disallow only whitespace<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>
<Form.Item
name="confirmPassword"
label="Confirm Password"
dependencies={['password']} // Re-validate when password changes
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>
<Form.Item name="username" label="Username">
<Input placeholder="Enter username" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item name="age" label="Age">
<InputNumber min={0} max={120} />
</Form.Item>
<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>
<Form.Item name="active" valuePropName="checked">
<Switch labelRight="Active" />
</Form.Item>
IMPORTANT: Always use valuePropName="checked" for boolean fields (Switch, Checkbox)!
<Form.Item name="agree" valuePropName="checked">
<Checkbox>I agree to the terms</Checkbox>
</Form.Item>
<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>
<Form.Item name="birthdate" label="Birth Date">
<DatePicker />
</Form.Item>
Form components in Ant Design/FormKit work in controlled mode using value and onChange props.
When you wrap a component in Form.Item, the form automatically:
value as a proponChange handler to update the value// Form.Item automatically manages:
<Form.Item name="myField">
<MyComponent
value={currentValue} // Injected by Form.Item
onChange={handleChange} // Injected by Form.Item
/>
</Form.Item>
interface MyCustomInputProps {
value?: string // Provided by Form.Item
onChange?: (value: string) => void // Provided by Form.Item
disabled?: boolean // Your custom props
placeholder?: string
}
export const MyCustomInput = ({
value = '',
onChange,
disabled,
placeholder
}: MyCustomInputProps) => {
const handleChange = (newValue: string) => {
// Call onChange with the new value
onChange?.(newValue)
}
return (
<div>
<input
value={value}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled}
placeholder={placeholder}
/>
</div>
)
}
// Usage in form
<Form.Item name="customField" label="Custom Field">
<MyCustomInput placeholder="Enter value" />
</Form.Item>
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>
)
}
// Usage
<Form.Item name="brandColor" label="Brand Color">
<ColorPicker />
</Form.Item>
Some components use a different prop name instead of value (e.g., checked for checkboxes):
// For components that use 'checked' instead of 'value'
<Form.Item name="enabled" valuePropName="checked">
<Switch />
</Form.Item>
// Custom component with 'checked'
interface ToggleProps {
checked?: boolean
onChange?: (checked: boolean) => void
}
export const Toggle = ({ checked, onChange }: ToggleProps) => {
return (
<button onClick={() => onChange?.(!checked)}>
{checked ? 'ON' : 'OFF'}
</button>
)
}
// Usage
<Form.Item name="isActive" valuePropName="checked">
<Toggle />
</Form.Item>
// Custom element selector component
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>
)
}
// Usage in form
<Form.Item
name="linkedDocument"
label="Linked Document"
rules={[{ required: true, message: 'Please select a document' }]}
>
<ElementSelector allowedTypes={['document']} />
</Form.Item>
You can create controlled wrappers around any component:
// Wrap a third-party library 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
/>
)
}
// Usage
<Form.Item name="wrappedField">
<WrappedComponent />
</Form.Item>
value and onChange props - Form.Item will inject theseonChange with the new value - Not an event object, just the valuevaluePropName - When your component uses a different prop (e.g., checked)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!
const [form] = Form.useForm()
// Get all values
const values = form.getFieldsValue()
// Get specific field
const username = form.getFieldValue('username')
// Reset to initial values
form.resetFields()
// Reset specific fields
form.resetFields(['username', 'email'])
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()
// Track query error
useEffect(() => {
if (!isNil(queryError)) {
trackError(new ApiError(queryError))
}
}, [queryError])
// Track mutation error
useEffect(() => {
if (!isNil(updateError)) {
trackError(new ApiError(updateError))
}
}, [updateError])
// Handle success
useEffect(() => {
if (!isNil(updateData)) {
message.success('Saved successfully')
}
}, [updateData])
// Load data into form
useEffect(() => {
if (data) {
form.setFieldsValue({
filename: data.filename,
description: data.description
})
}
}, [data, form])
const handleSubmit = (values) => {
// No try/catch needed - errors tracked via useEffect
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>
)
}
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()
// Track errors
useEffect(() => {
if (!isNil(error)) {
trackError(new ApiError(error))
}
}, [error])
// Handle success
useEffect(() => {
if (!isNil(data)) {
message.success('Created successfully')
// Navigate, close dialog, etc.
}
}, [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>
)
}
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>
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>
)
}
Monitor field changes:
const [form] = Form.useForm()
// Watch single field
const username = Form.useWatch('username', form)
// Watch nested field
const email = Form.useWatch(['user', 'email'], form)
// Watch all values
const values = Form.useWatch([], form)
<FormKit
formProps={{
form,
onValuesChange: (changedValues, allValues) => {
console.log('Changed:', changedValues)
console.log('All:', allValues)
}
}}
>
❌ Using plain Form instead of FormKit
// BAD - don't use Ant Design Form directly
<Form form={form}>
✅ Always use FormKit
// GOOD
<FormKit formProps={{ form }}>
❌ Forgetting valuePropName="checked" for boolean fields
// BAD - won't work correctly
<Form.Item name="active">
<Switch />
</Form.Item>
✅ Use valuePropName for non-value props
// GOOD
<Form.Item name="active" valuePropName="checked">
<Switch />
</Form.Item>
❌ Not handling undefined data
// BAD - crashes if data is undefined
form.setFieldsValue({
name: data.name // Error if data is undefined!
})
✅ Check data exists first
// GOOD
if (data) {
form.setFieldsValue({
name: data.name
})
}
❌ Not disabling submit during save
// BAD - can submit multiple times
<Button htmlType="submit">Save</Button>
✅ Disable while saving
// GOOD
<Button htmlType="submit" loading={isSaving} disabled={isSaving}>
Save
</Button>
Type your form values:
interface FormValues {
username: string
email: string
active: boolean
}
const MyForm = () => {
const [form] = Form.useForm<FormValues>()
const handleSubmit = (values: FormValues) => {
// values is typed!
}
return (
<FormKit formProps={{ form, onFinish: handleSubmit }}>
{/* fields */}
</FormKit>
)
}
// ❌ WRONG - Don't use Ant Design Form directly
import { Form } from 'antd'
const [form] = Form.useForm()
// ✅ CORRECT - Always use FormKit
import { FormKit, Form } from '@pimcore/studio-ui-bundle/components'
const [form] = Form.useForm()
return <FormKit formProps={{ form }}>{/* fields */}</FormKit>
// ❌ WRONG - Importing from antd
import { Form, Input } from 'antd'
// ✅ CORRECT - Import from SDK
import { FormKit, Form, Input } from '@pimcore/studio-ui-bundle/components'
// ❌ WRONG - Manual grouping
<div className="form-section">
<h3>User Details</h3>
<Form.Item name="name">...</Form.Item>
</div>
// ✅ CORRECT - Use FormKit.Panel
<FormKit.Panel title="User details">
<Form.Item name="name">...</Form.Item>
</FormKit.Panel>
// ❌ WRONG - Missing controlled props
export const MyField = (props: Props): React.JSX.Element => {
const [value, setValue] = useState(props.initialValue)
return <input value={value} onChange={(e) => setValue(e.target.value)} />
}
// ✅ CORRECT - Controlled with value/onChange
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)} />
}
// ❌ WRONG - Manual API calls
const handleSubmit = async (values) => {
await fetch('/api/save', { body: JSON.stringify(values) })
}
// ✅ CORRECT - Use RTK Query mutation
const [updateUser] = useUserUpdateMutation()
const handleSubmit = (values: FormValues): void => {
updateUser({ id: userId, body: values })
}
tools
UX and UI design conventions for Pimcore Studio - layout, spacing, action labels, writing style, and design principles for consistent extensions
tools
Widget system in Pimcore Studio UI - registering widgets, opening them in layout areas, WidgetManagerTabConfig, and connecting widgets to navigation
tools
How bundles consume the Pimcore Studio UI SDK - plugins, modules, DI, registries, and imports
development
TypeScript coding standards and best practices for Pimcore Studio UI - type safety, null checks, and code quality