skills/pimcore-studio-ui-listings/SKILL.md
Building listings in Pimcore Studio UI using the ListingBuilder decorator pattern - sorting, paging, filtering, inline editing, and custom decorators
npx skillsauth add pimcore/skills pimcore-studio-ui-listingsInstall 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 build and customize listings in Pimcore Studio UI:
BaseListingUse this when:
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.
The listing system uses a decorator-based builder pattern. Each decorator wraps the listing props to add functionality (sorting, paging, filtering, row selection, etc.). The ListingBuilder manages the chain of decorators and produces final props for BaseListing.
Retrieve the builder from the DI container, then always copy() before modifying — the instance from the container is shared and mutating it breaks every other listing.
import { container } from '@pimcore/studio-ui-bundle/app'
const listingBuilder = container.get<ObjectListingBuilder>('DataObject/Listing/Builder')
const customBuilder = listingBuilder.copy()
// Add a decorator (lower priority = outermost wrapper, higher = closer to base)
customBuilder.addDecorator({
name: 'myDecorator',
decorator: MyDecorator,
priority: 50
})
// Override an existing decorator by name
customBuilder.overrideDecorator({
name: 'sorting',
decorator: CustomSortingDecorator
})
// Remove a decorator entirely
customBuilder.removeDecorator('tagFilter')
Priority guideline: 10-30 infrastructure, 30-50 core (sort/page), 50-70 features (filter/select), 70-90 UI (actions/menus), 90+ custom overrides. Use distinct values — equal priorities have undefined order.
The listing system ships with these built-in decorators:
Adds column sorting functionality. Clicking column headers toggles sort direction, and the sort state is sent to the API.
Adds pagination controls (page size selector, page navigation). Manages page state and injects paging parameters into API queries.
Enables single or multi-row selection. Configure the mode via the builder config:
// Single selection - only one row at a time
config: {
rowSelection: {
config: { rowSelectionMode: 'single' }
}
}
// Multiple selection - checkboxes, select many rows
config: {
rowSelection: {
config: { rowSelectionMode: 'multiple' }
}
}
Enables inline cell editing. Cells become editable on interaction, and changes are tracked for batch saving.
Adds search and filter UI above the listing. Provides text search, column-specific filters, and filter persistence.
Appends an actions column with configurable action buttons per row (edit, delete, open, etc.).
Adds right-click context menus on rows. Context menu items can be registered and configured per listing.
Adds column visibility toggling. Users can show/hide columns via a configuration popover.
Adds tag-based filtering. Users can filter listing rows by assigned tags.
Enables dynamic type cell rendering. Cells render differently based on the data type of each field (text, number, date, select, etc.).
Call build() on the builder to produce props, then spread them onto BaseListing. The whole tree must be wrapped in DataObjectProvider (see next section). Pass a config object to build() to configure individual decorators at build time.
import React from 'react'
import { container } from '@pimcore/studio-ui-bundle/app'
import { BaseListing, DataObjectProvider } from '@pimcore/studio-ui-bundle/modules/data-object'
export const MyListing = (): React.JSX.Element => {
const listingBuilder = container.get<ObjectListingBuilder>('DataObject/Listing/Builder')
const customBuilder = listingBuilder.copy()
// Customize the decorator chain
customBuilder.addDecorator({
name: 'statusHighlight',
decorator: StatusHighlightDecorator,
priority: 90
})
customBuilder.removeDecorator('tagFilter')
return (
<DataObjectProvider id={1}>
<BaseListing {
...customBuilder.build({
props: { ...listingDefaultProps },
config: {
// Disable a decorator without removing it from the chain
inlineEdit: { enabled: false },
// Configure a decorator
rowSelection: {
config: { rowSelectionMode: 'multiple' }
}
}
})
} />
</DataObjectProvider>
)
}
removeDecorator vs config: { enabled: false }:
removeDecorator: permanently removes the decorator from the builder.config: { enabled: false }: leaves the decorator in the chain but skips it for this build call — toggleable per render.Every data object listing must be wrapped in a DataObjectProvider. This provider supplies the element context (ID, type, permissions) that the listing and its decorators depend on. The id prop specifies the parent folder ID for the listing.
import { DataObjectProvider } from '@pimcore/studio-ui-bundle/modules/data-object'
<DataObjectProvider id={1}>
<BaseListing { ...props } />
</DataObjectProvider>
Without DataObjectProvider, decorators that depend on element context (permissions, tag filters, etc.) will fail silently or throw errors.
Listings expose toolbar slots where extra components (buttons, filters, etc.) can be registered via the ComponentRegistry. Slot naming convention:
{listingName}.toolbar.left - Left-aligned
{listingName}.toolbar.center - Center
{listingName}.toolbar.right - Right-aligned
import { container, serviceIds } from '@pimcore/studio-ui-bundle/app'
import { type ComponentRegistry } from '@pimcore/studio-ui-bundle/modules/app'
const componentRegistry = container.get<ComponentRegistry>(serviceIds.componentRegistry)
componentRegistry.registerToSlot('carsListing.toolbar.right', {
name: 'exportButton',
component: ExportButton
})
The registered component is a regular React component — use standard hooks (e.g. useTranslation) and Studio UI components (e.g. IconTextButton) inside it.
To make a listing reachable from the main navigation, register it as a widget and add a navigation entry in your module's onInit. See pimcore-studio-ui-widgets and pimcore-studio-ui-navigation for details.
import { type AbstractModule } from '@pimcore/studio-ui-bundle'
import { container, serviceIds } from '@pimcore/studio-ui-bundle/app'
import { type MainNavRegistry } from '@pimcore/studio-ui-bundle/modules/app'
import { type WidgetRegistry } from '@pimcore/studio-ui-bundle/modules/widget-manager'
export const CarsListingModule: AbstractModule = {
onInit: (): void => {
const widgetRegistry = container.get<WidgetRegistry>(serviceIds.widgetManager)
const mainNavRegistry = container.get<MainNavRegistry>(serviceIds.mainNavRegistry)
widgetRegistry.registerWidget({
name: 'cars-listing',
component: CarsListing
})
mainNavRegistry.registerMainNavItem({
path: 'Tools/Cars Listing',
label: 'cars-listing.navigation.title',
widgetConfig: {
name: 'Cars Listing',
id: 'cars-listing',
component: 'cars-listing',
config: {
translationKey: 'cars-listing.navigation.title',
icon: { type: 'name', value: 'car' }
}
}
})
}
}
Decorators are functions that receive listing props and return modified listing props. A decorator can modify up to three layers:
useGridOptions to mutate the API query (filters, sort, paging parameters).ToolbarComponent (or column renderers, overlays) to add UI.import React, { createContext, useContext, useState } from 'react'
import { type AbstractDecorator } from '@pimcore/studio-ui-bundle/modules/element'
// --- Context layer: state via React context ---
interface StatusFilterState {
status: string
setStatus: (value: string) => void
}
const StatusFilterContext = createContext<StatusFilterState | null>(null)
export const useStatusFilter = (): StatusFilterState => {
const ctx = useContext(StatusFilterContext)
if (ctx === null) throw new Error('useStatusFilter must be used within provider')
return ctx
}
const withStatusFilterContext = (Wrapped: React.ComponentType<React.PropsWithChildren>) => {
const Provider: React.FC<React.PropsWithChildren> = ({ children }) => {
const [status, setStatus] = useState('')
return (
<StatusFilterContext.Provider value={{ status, setStatus }}>
<Wrapped>{children}</Wrapped>
</StatusFilterContext.Provider>
)
}
return Provider
}
// --- Data layer: inject filter into API query ---
const withStatusFilterQuery = (useOriginal: UseGridOptionsHook): UseGridOptionsHook => {
return (options) => {
const original = useOriginal(options)
const { status } = useStatusFilter()
return {
...original,
queryArg: {
...original.queryArg,
filters: [
...(original.queryArg.filters ?? []),
{ field: 'status', value: status }
]
}
}
}
}
// --- View layer: add toolbar input ---
const withStatusFilterToolbar = (Toolbar: React.ComponentType): React.FC => {
return () => {
const { status, setStatus } = useStatusFilter()
return (
<>
<Toolbar />
<input
onChange={(e) => { setStatus(e.target.value) }}
placeholder="Status..."
value={status}
/>
</>
)
}
}
// --- The decorator itself combines all three layers ---
export const StatusFilterDecorator: AbstractDecorator = (props) => {
const { useGridOptions, ContextComponent, ToolbarComponent, ...baseProps } = props
return {
...baseProps,
ContextComponent: withStatusFilterContext(ContextComponent),
useGridOptions: withStatusFilterQuery(useGridOptions),
ToolbarComponent: withStatusFilterToolbar(ToolbarComponent)
}
}
// --- Register it on a copied builder ---
const customBuilder = listingBuilder.copy()
customBuilder.addDecorator({
name: 'statusFilter',
decorator: StatusFilterDecorator,
priority: 60
})
A decorator doesn't need to implement all three layers — touch only the ones you need and pass the rest through in baseProps.
// WRONG - Missing DataObjectProvider
export const BrokenListing = (): React.JSX.Element => {
return (
<BaseListing { ...listingBuilder.build({ props: { ...listingDefaultProps } }) } />
)
}
// CORRECT - Always wrap with DataObjectProvider
export const WorkingListing = (): React.JSX.Element => {
return (
<DataObjectProvider id={1}>
<BaseListing { ...listingBuilder.build({ props: { ...listingDefaultProps } }) } />
</DataObjectProvider>
)
}
// WRONG - incorrect service ID
const listingBuilder = container.get<ObjectListingBuilder>('Listing/Builder')
// CORRECT - use the full service ID
const listingBuilder = container.get<ObjectListingBuilder>('DataObject/Listing/Builder')
// WRONG - mutates the shared global builder, breaks other listings
const listingBuilder = container.get<ObjectListingBuilder>('DataObject/Listing/Builder')
listingBuilder.removeDecorator('tagFilter')
// CORRECT - copy first
const customBuilder = listingBuilder.copy()
customBuilder.removeDecorator('tagFilter')
// WRONG - same priority causes undefined order
customBuilder.addDecorator({ name: 'decoratorA', decorator: DecA, priority: 50 })
customBuilder.addDecorator({ name: 'decoratorB', decorator: DecB, priority: 50 })
// CORRECT - distinct priorities for deterministic order
customBuilder.addDecorator({ name: 'decoratorA', decorator: DecA, priority: 50 })
customBuilder.addDecorator({ name: 'decoratorB', decorator: DecB, priority: 55 })
// WRONG - decorator name does not match any built-in
customBuilder.overrideDecorator({
name: 'sort', // Wrong name! The built-in is 'sorting'
decorator: CustomSortingDecorator
})
// CORRECT - use the exact built-in decorator name
customBuilder.overrideDecorator({
name: 'sorting',
decorator: CustomSortingDecorator
})
// WRONG - missing spread operator
<BaseListing
listingBuilder.build({ props: { ...listingDefaultProps } })
/>
// CORRECT - spread the build result
<BaseListing {
...listingBuilder.build({
props: { ...listingDefaultProps }
})
} />
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