skills/pimcore-studio-ui-modals/SKILL.md
Using modal dialogs in Pimcore Studio UI - declarative Modal and WindowModal components, plus imperative useFormModal, useAlertModal, useStudioModal hooks
npx skillsauth add pimcore/skills pimcore-studio-ui-modalsInstall 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.
How to use modal dialogs in Pimcore Studio UI:
Use this when:
<Modal>)<WindowModal>)useFormModal.confirm())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.
<Modal>, <WindowModal>)Use declarative components when:
Example use cases:
useFormModal, useAlertModal)Use imperative hooks when:
Example use cases:
The <Modal> component provides full control over modal behavior using React state.
import { useState } from 'react'
import { Modal, Button } from '@pimcore/studio-ui-bundle/components'
export const MyComponent = (): React.JSX.Element => {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(true)}>
Open modal
</Button>
<Modal
open={isOpen}
onCancel={() => setIsOpen(false)}
title="My Modal"
footer={[
<Button key="cancel" onClick={() => setIsOpen(false)}>
Cancel
</Button>,
<Button
key="submit"
type="primary"
onClick={() => {
// Handle submit
setIsOpen(false)
}}
>
Submit
</Button>
]}
>
<p>Modal content goes here</p>
</Modal>
</>
)
}
Common pattern: Edit modal with data fetching and mutation
import { useState, useEffect } from 'react'
import { Modal, Button, FormKit, Form, Input, Skeleton } from '@pimcore/studio-ui-bundle/components'
import { useAssetGetByIdQuery, useAssetUpdateMutation } from '@pimcore/studio-ui-bundle/api/asset'
import { trackError, ApiError } from '@pimcore/studio-ui-bundle/modules/app'
import { isNil } from 'lodash'
export const EditAssetModal = ({ assetId, onClose }: Props): React.JSX.Element => {
const [isOpen, setIsOpen] = useState(true)
const [form] = Form.useForm()
// Fetch data
const { data, isLoading, error: fetchError } = useAssetGetByIdQuery({ id: assetId })
// Mutation
const [updateAsset, { isLoading: isUpdating, data: updateData, error: updateError }] = useAssetUpdateMutation()
// Track errors
useEffect(() => {
if (!isNil(fetchError)) {
trackError(new ApiError(fetchError))
}
}, [fetchError])
useEffect(() => {
if (!isNil(updateError)) {
trackError(new ApiError(updateError))
}
}, [updateError])
// Close modal on successful update
useEffect(() => {
if (!isNil(updateData)) {
setIsOpen(false)
onClose()
}
}, [updateData])
// Set form values when data loads
useEffect(() => {
if (!isNil(data)) {
form.setFieldsValue({
filename: data.filename,
title: data.metadata?.title ?? ''
})
}
}, [data, form])
const handleSubmit = (values: FormValues): void => {
updateAsset({
id: assetId,
body: {
filename: values.filename,
metadata: { title: values.title }
}
})
}
const handleCancel = (): void => {
setIsOpen(false)
onClose()
}
return (
<Modal
open={isOpen}
onCancel={handleCancel}
title="Edit asset"
footer={[
<Button key="cancel" onClick={handleCancel}>
Cancel
</Button>,
<Button
key="submit"
type="primary"
loading={isUpdating}
onClick={() => form.submit()}
>
Save
</Button>
]}
>
{isLoading ? (
<Skeleton />
) : (
<FormKit formProps={{ form, onFinish: handleSubmit }}>
<Form.Item name="filename" label="Filename" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="title" label="Title">
<Input />
</Form.Item>
</FormKit>
)}
</Modal>
)
}
interface ModalProps {
open: boolean // Controls modal visibility
onCancel?: () => void // Called when user closes modal (X button, Esc, backdrop click)
onOk?: () => void // Called when OK button clicked (if using default footer)
title?: React.ReactNode // Modal title
width?: number | string // Modal width (default: 520)
footer?: React.ReactNode // Custom footer (null = no footer)
closable?: boolean // Show close X button (default: true)
maskClosable?: boolean // Click backdrop to close (default: true)
destroyOnClose?: boolean // Destroy children when closed (default: false)
centered?: boolean // Vertically center modal (default: false)
}
The <WindowModal> component provides draggable, resizable window-style modals.
import { useState } from 'react'
import { WindowModal, Button } from '@pimcore/studio-ui-bundle/components'
export const MyComponent = (): React.JSX.Element => {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button onClick={() => setIsOpen(true)}>
Open window
</Button>
<WindowModal
open={isOpen}
onClose={() => setIsOpen(false)}
title="Draggable Window"
width={800}
height={600}
>
<div>
<h3>Window content</h3>
<p>This modal can be dragged and resized!</p>
</div>
</WindowModal>
</>
)
}
Common pattern: Multi-section window
import { useState } from 'react'
import { WindowModal, Tabs } from '@pimcore/studio-ui-bundle/components'
import type { TabsProps } from '@pimcore/studio-ui-bundle/components'
export const SettingsWindow = ({ onClose }: Props): React.JSX.Element => {
const [isOpen, setIsOpen] = useState(true)
const items: TabsProps['items'] = [
{
key: 'general',
label: 'General',
children: <GeneralSettings />
},
{
key: 'advanced',
label: 'Advanced',
children: <AdvancedSettings />
}
]
return (
<WindowModal
open={isOpen}
onClose={() => {
setIsOpen(false)
onClose()
}}
title="Settings"
width={900}
height={700}
resizable
draggable
>
<Tabs items={items} />
</WindowModal>
)
}
interface WindowModalProps {
open: boolean // Controls visibility
onClose?: () => void // Called when window closes
title?: React.ReactNode // Window title
width?: number // Window width (default: 520)
height?: number // Window height (default: auto)
resizable?: boolean // Enable resize handles (default: false)
draggable?: boolean // Enable dragging by title bar (default: true)
centered?: boolean // Center window initially (default: true)
destroyOnClose?: boolean // Destroy children when closed (default: false)
}
For simple interactions, use imperative hooks instead of declarative components.
The useFormModal hook provides modal dialogs with form functionality. The most commonly used is the confirm modal for user confirmations.
The confirm() method is the most frequently used modal type! Use it for:
interface UseFormModalHookResponse {
confirm: (props) => { destroy: () => void, update: (config) => void } // ⭐ MOST COMMON
input: (props) => { destroy: () => void, update: (config) => void }
textarea: (props) => { destroy: () => void, update: (config) => void }
upload: (props) => { destroy: () => void, update: (config) => void }
}
This is the most common pattern in Pimcore Studio UI:
import { useFormModal } from '@pimcore/studio-ui-bundle/components'
import { useTranslation } from 'react-i18next'
export const MyComponent = () => {
const modal = useFormModal()
const { t } = useTranslation()
const handleDelete = () => {
// ⭐ Most common modal pattern - confirmation dialog
modal.confirm({
title: t('delete.confirmation.title'),
content: t('delete.confirmation.text'),
okText: t('delete.confirmation.ok'),
cancelText: t('cancel'),
onOk: async () => {
await deleteItem()
}
})
}
return (
<Button
color="danger"
onClick={ handleDelete }
>
{t('delete')}
</Button>
)
}
interface ConfirmFormModalProps {
title?: string | React.ReactNode // Modal title
content?: string | React.ReactNode // Modal content/message
okText?: string // OK button text (default: "Yes")
cancelText?: string // Cancel button text (default: "No")
onOk?: () => void | Promise<void> // OK callback (⭐ REQUIRED)
onCancel?: () => void // Cancel callback (optional)
dontAskAgainKey?: string // Enable "Don't ask again" checkbox
type?: 'info' | 'success' | 'error' | 'warning' | 'confirm'
icon?: React.ReactNode // Custom icon
okButtonProps?: ButtonProps // OK button properties (e.g., { color: 'danger' })
cancelButtonProps?: ButtonProps // Cancel button properties
width?: number | string // Modal width
}
// File: widget-editor/hooks/use-widget-editor.tsx
import { useFormModal } from '@pimcore/studio-ui-bundle/components'
import { useTranslation } from 'react-i18next'
export const useWidgetEditor = () => {
const modal = useFormModal()
const { t } = useTranslation()
const [deleteWidget] = useWidgetDeleteMutation()
// ⭐ Standard delete confirmation pattern
const removeWithConfirmation = (widgetId: string, onFinish?: () => void) => {
modal.confirm({
title: t('element.delete.confirmation.title'),
content: <span>{t('element.delete.confirmation.text')}</span>,
okText: t('element.delete.confirmation.ok'),
onOk: async () => {
await deleteWidget({ widgetId })
onFinish?.()
}
})
}
return { removeWithConfirmation }
}
Use dontAskAgainKey to add a "Don't ask again" checkbox that stores the user's preference:
modal.confirm({
title: t('close.confirmation.title'),
content: t('close.confirmation.text'),
okText: t('yes'),
cancelText: t('no'),
dontAskAgainKey: 'close-without-saving-confirmation', // Unique key for localStorage
onOk: async () => {
await closeWithoutSaving()
}
})
// On subsequent calls, if user checked "Don't ask again",
// the modal won't show and onOk() is called immediately
// ⭐ Most common pattern - delete with danger styling
modal.confirm({
title: t('delete.confirmation.title'),
content: t('delete.confirmation.text'),
okText: t('delete'),
okButtonProps: { color: 'danger' }, // Red button for destructive action
onOk: async () => {
await deleteItem()
await messageApi.success(t('delete.success'))
}
})
// ⭐ Common pattern - warn about losing changes
modal.confirm({
title: t('unsaved-changes.title'),
content: t('unsaved-changes.text'),
okText: t('discard-changes'),
cancelText: t('keep-editing'),
dontAskAgainKey: 'close-without-saving', // Optional: remember choice
onOk: () => {
closeWithoutSaving()
}
})
// ⭐ Common pattern - state change confirmation
modal.confirm({
title: t('publish.confirmation.title'),
content: t('publish.confirmation.text'),
okText: t('publish'),
onOk: async () => {
await publishItem()
await messageApi.success(t('publish.success'))
}
})
// ⭐ Simple yes/no confirmation
modal.confirm({
title: t('confirm.title'),
content: t('confirm.text'),
okText: t('yes'), // Default
cancelText: t('no'), // Default
onOk: async () => {
await performAction()
}
})
Displays a modal with a single text input field.
const modal = useFormModal()
const { t } = useTranslation()
const handleRename = () => {
modal.input({
title: t('rename.title'),
label: t('rename.label'),
initialValue: currentName,
okText: t('save'),
cancelText: t('cancel'),
onOk: async (newName: string) => {
await updateName(newName)
}
})
}
interface InputFormModalProps {
title?: string // Modal title
label?: string // Input field label
initialValue?: string // Pre-filled value
rule?: Rule // Validation rule
okText?: string
cancelText?: string
onOk?: (value: string) => void | Promise<void>
}
modal.input({
title: t('create-folder.title'),
label: t('create-folder.label'),
rule: {
required: true,
message: t('create-folder.validation.required')
},
okText: t('create'),
onOk: async (folderName: string) => {
await createFolder(folderName)
}
})
// File: open-element/context/open-element-data-context.tsx
const { input } = useFormModal()
const { t } = useTranslation()
const handleRenameWidget = (widgetId: string, currentName: string) => {
input({
title: t('widget.rename.title'),
label: t('widget.rename.label'),
initialValue: currentName,
rule: {
required: true,
message: t('widget.rename.validation.required')
},
onOk: async (newName: string) => {
await updateWidget(widgetId, { name: newName })
}
})
}
Displays a modal with a multi-line textarea input.
const modal = useFormModal()
const { t } = useTranslation()
const handleEditDescription = () => {
modal.textarea({
title: t('edit-description.title'),
label: t('edit-description.label'),
initialValue: currentDescription,
placeholder: t('edit-description.placeholder'),
okText: t('save'),
onOk: async (newDescription: string) => {
await updateDescription(newDescription)
}
})
}
interface TextareaFormModalProps {
title?: string // Modal title
label?: string // Textarea label
initialValue?: string // Pre-filled value
placeholder?: string // Placeholder text
okText?: string
cancelText?: string
onOk?: (value: string) => void | Promise<void>
}
const modal = useFormModal()
const { t } = useTranslation()
const handleEditNotes = (currentNotes: string) => {
modal.textarea({
title: t('notes.edit.title'),
label: t('notes.edit.label'),
initialValue: currentNotes,
placeholder: t('notes.edit.placeholder'),
onOk: async (newNotes: string) => {
await saveNotes(newNotes)
await messageApi.success(t('notes.save.success'))
}
})
}
Displays a modal with a file upload input.
const modal = useFormModal()
const { t } = useTranslation()
const handleUploadFile = () => {
modal.upload({
title: t('upload.title'),
label: t('upload.label'),
accept: '.csv,.xlsx', // Accepted file types
okText: t('upload'),
onOk: async (files: FileList) => {
await uploadFiles(files)
}
})
}
interface UploadFormModalProps {
title?: string // Modal title
label?: string // Upload field label
accept?: string // Accepted file types (e.g., '.csv,.pdf')
rule?: Rule // Validation rule
okText?: string
cancelText?: string
onOk?: (files: FileList) => void | Promise<void>
}
modal.upload({
title: t('import.title'),
label: t('import.file-label'),
accept: '.csv',
rule: {
required: true,
message: t('import.validation.file-required')
},
onOk: async (files: FileList) => {
if (files.length > 0) {
await importData(files[0])
await messageApi.success(t('import.success'))
}
}
})
The useAlertModal hook provides simple alert-style modals for info, success, error, and warning messages.
interface UseAlertModalResponse {
info: (props) => { destroy: () => void, update: (config) => void }
success: (props) => { destroy: () => void, update: (config) => void }
error: (props) => { destroy: () => void, update: (config) => void }
warn: (props) => { destroy: () => void, update: (config) => void }
}
import { useAlertModal } from '@pimcore/studio-ui-bundle/components'
import { useTranslation } from 'react-i18next'
export const MyComponent = () => {
const modal = useAlertModal()
const { t } = useTranslation()
const showInfo = () => {
modal.info({
title: 'information', // Translation key (auto-translated)
content: t('app.loading-info')
})
}
const showError = () => {
modal.error({
title: 'error', // Translation key (auto-translated)
content: t('app.initialization-failed')
})
}
const showSuccess = () => {
modal.success({
content: t('operation.completed')
// title defaults to t('success')
})
}
const showWarning = () => {
modal.warn({
content: t('unsaved-changes.warning')
// title defaults to t('warning')
})
}
return (
// Your component
)
}
interface IAlertModalProps {
title?: string // Title (translation key, auto-translated)
content: string | React.ReactNode // Content message
okText?: string // OK button text
onOk?: () => void // OK callback
width?: number | string // Modal width
}
// File: app/app-loader/app-loader.tsx
import { useAlertModal } from '@pimcore/studio-ui-bundle/components'
import { useTranslation } from 'react-i18next'
export const AppLoader = () => {
const modal = useAlertModal()
const { t } = useTranslation()
useEffect(() => {
const initializeApp = async () => {
try {
await loadAppData()
} catch (error) {
modal.error({
title: 'error', // Auto-translated to t('error')
content: t('app.initialization-failed'),
onOk: () => {
window.location.reload()
}
})
}
}
initializeApp()
}, [])
return <div>Loading...</div>
}
The useStudioModal hook provides the base modal functionality. It works seamlessly across iframe boundaries.
import { useStudioModal } from '@pimcore/studio-ui-bundle/components'
export const MyComponent = () => {
const { modal, localModal } = useStudioModal()
const showConfirmation = () => {
// `modal` works across iframes (uses parent window's modal if in iframe)
modal.confirm({
title: 'Confirm Action',
content: 'Are you sure?',
onOk: () => {
// Handle confirmation
}
})
}
return <Button onClick={ showConfirmation }>Show Modal</Button>
}
interface StudioModalResponse {
modal: ModalStaticFunctions // Modal instance (parent if in iframe)
localModal: ModalStaticFunctions // Always local modal instance
}
modal for most cases (works across iframes automatically)localModal when you specifically need the modal in the current window onlyAll modal hooks return methods to control the modal:
const modalInstance = modal.confirm({
title: t('confirm.title'),
content: t('confirm.text'),
onOk: handleConfirm
})
// Later, close the modal programmatically
modalInstance.destroy()
const modalInstance = modal.confirm({
title: t('processing.title'),
content: t('processing.text'),
okButtonProps: { loading: false }
})
// Update the modal (e.g., show loading state)
modalInstance.update({
okButtonProps: { loading: true }
})
// Update again
setTimeout(() => {
modalInstance.update({
content: t('processing.complete'),
okButtonProps: { loading: false }
})
}, 2000)
const modal = useFormModal()
const { t } = useTranslation()
const [deleteItem, { isLoading }] = useDeleteMutation()
const handleDelete = (itemId: number) => {
modal.confirm({
title: t('delete.confirmation.title'),
content: t('delete.confirmation.text'),
okText: t('delete'),
okButtonProps: { color: 'danger' },
onOk: async () => {
await deleteItem({ id: itemId })
}
})
}
const modal = useFormModal()
const { t } = useTranslation()
const handleClose = (hasUnsavedChanges: boolean) => {
if (!hasUnsavedChanges) {
closeEditor()
return
}
modal.confirm({
title: t('unsaved-changes.title'),
content: t('unsaved-changes.text'),
okText: t('discard-changes'),
cancelText: t('keep-editing'),
dontAskAgainKey: 'close-without-saving',
onOk: () => {
closeEditor()
}
})
}
const modal = useFormModal()
const { t } = useTranslation()
const [createItem] = useCreateMutation()
const handleCreate = () => {
modal.input({
title: t('create-item.title'),
label: t('create-item.name-label'),
rule: {
required: true,
message: t('create-item.validation.name-required'),
pattern: /^[a-zA-Z0-9-_]+$/,
message: t('create-item.validation.name-pattern')
},
onOk: async (name: string) => {
await createItem({ name })
await messageApi.success(t('create-item.success'))
}
})
}
const modal = useFormModal()
const { t } = useTranslation()
const handleComplexOperation = async () => {
const modalInstance = modal.confirm({
title: t('operation.title'),
content: t('operation.step-1'),
okText: t('continue'),
cancelText: t('cancel'),
onOk: async () => {
// Show loading
modalInstance.update({
content: t('operation.processing'),
okButtonProps: { loading: true },
cancelButtonProps: { disabled: true }
})
try {
await performStep1()
// Update to step 2
modalInstance.update({
content: t('operation.step-2'),
okButtonProps: { loading: false },
cancelButtonProps: { disabled: false }
})
await performStep2()
// Close and show success
modalInstance.destroy()
await messageApi.success(t('operation.success'))
} catch (error) {
modalInstance.destroy()
await messageApi.error(t('operation.failed'))
}
}
})
}
const modal = useAlertModal()
const { t } = useTranslation()
const handleLoadDataWithRetry = async () => {
try {
await loadData()
} catch (error) {
modal.error({
title: 'error',
content: t('data.load-failed'),
okText: t('retry'),
onOk: () => {
handleLoadDataWithRetry() // Retry
}
})
}
}
This is the most frequently used modal in Pimcore Studio UI!
Use for:
// ⭐ The pattern you'll use most often:
modal.confirm({
title: t('action.confirmation.title'),
content: t('action.confirmation.text'),
okText: t('confirm'),
onOk: async () => {
await performAction()
}
})
Use for:
Use for:
Use for:
Use for:
Use for:
Use for:
Use for:
// DON'T - Hardcoded text
modal.confirm({
title: 'Delete Item',
content: 'Are you sure?'
})
// DO - Always use translations
modal.confirm({
title: t('delete.confirmation.title'),
content: t('delete.confirmation.text')
})
// DON'T - Not awaiting
modal.confirm({
onOk: () => {
deleteItem() // Fire and forget
}
})
// DO - Await async operations
modal.confirm({
onOk: async () => {
await deleteItem()
await messageApi.success(t('deleted'))
}
})
// DON'T - Modal for simple success message
modal.success({
content: t('saved')
})
// DO - Use toast message instead
messageApi.success(t('saved'))
// DON'T - No feedback after action
modal.confirm({
title: t('delete.title'),
content: t('delete.text'),
onOk: async () => {
await deleteItem()
// No feedback!
}
})
// DO - Always provide feedback
modal.confirm({
title: t('delete.title'),
content: t('delete.text'),
onOk: async () => {
await deleteItem()
await messageApi.success(t('delete.success')) // Feedback!
}
})
// DON'T - No validation
modal.input({
title: t('create.title'),
onOk: async (name: string) => {
await create(name) // Could be empty or invalid!
}
})
// DO - Add validation rules
modal.input({
title: t('create.title'),
rule: {
required: true,
message: t('validation.name-required')
},
onOk: async (name: string) => {
await create(name)
}
})
| Need | Use |
|------|-----|
| Confirm action | useFormModal().confirm() |
| Get single-line input | useFormModal().input() |
| Get multi-line input | useFormModal().textarea() |
| Upload file | useFormModal().upload() |
| Show info | useAlertModal().info() or toast |
| Show error | useAlertModal().error() for critical |
| Show success | Toast (not modal) |
| Show warning | useAlertModal().warn() or toast |
// Delete confirmation
modal.confirm({
title: t('delete.title'),
content: t('delete.text'),
okText: t('delete'),
okButtonProps: { color: 'danger' },
onOk: async () => await delete()
})
// Create with input
modal.input({
title: t('create.title'),
label: t('name.label'),
rule: { required: true, message: t('validation.required') },
onOk: async (name) => await create(name)
})
// Don't ask again
modal.confirm({
title: t('close.title'),
content: t('close.text'),
dontAskAgainKey: 'close-confirmation',
onOk: () => close()
})
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