skills/pimcore-studio-ui-navigation/SKILL.md
Adding main navigation entries and perspective permissions in Pimcore Studio UI - frontend registration and backend permission management
npx skillsauth add pimcore/skills pimcore-studio-ui-navigationInstall 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 add navigation entries to Pimcore Studio UI with perspective permissions:
Use this when:
Navigation items use Title Case - each principal word is capitalized.
This applies to:
// ✅ CORRECT - Title Case for navigation
mainNavRegistry.registerMainNavItem({
path: 'Data Management/Custom Reports', // "Custom Reports"
label: 'custom-reports.title'
})
// Translation in studio.en.yaml:
// custom-reports.title: Custom Reports
// output-channels.title: Output Channels
// data-objects.title: Data Objects
// user-settings.title: User Settings
| Element Type | Case Style | Example | |--------------|------------|---------| | Navigation items | Title Case | "Custom Reports" | | Menu items | Title Case | "Output Channels" | | Tab titles | Title Case | "Data Objects" | | Buttons | Sentence case | "Export CSV" | | Actions | Sentence case | "Save draft" |
Remember: Navigation = Title Case, Buttons/Actions = Sentence case
For button and action labels, see pimcore-studio-ui-buttons 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.
Navigation items are registered in your module's initialization file using the MainNavRegistry.
// File: your-bundle/assets/studio/js/src/modules/your-feature/index.ts
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'
export const YourFeatureModule: AbstractModule = {
onInit: (): void => {
const mainNavRegistry = container.get<MainNavRegistry>(serviceIds.mainNavRegistry)
mainNavRegistry.registerMainNavItem({
path: 'ParentGroup/SubGroup/Your Feature',
label: 'your-feature.navigation.title',
order: 5,
perspectivePermission: 'dataManagement.yourFeature',
widgetConfig: {
name: 'Your Feature',
id: 'your-feature',
component: 'your-feature',
config: {
translationKey: 'your-feature.navigation.title',
icon: {
type: 'name',
value: 'your-icon'
}
}
}
})
}
}
interface IMainNavItem {
path: string // Hierarchical path 'Group/SubGroup/Item'
label?: string // Translation key for display text
order?: number // Display order (default: 1000)
perspectivePermission?: string // Permission key 'group.permission'
widgetConfig?: WidgetManagerTabConfig // Widget configuration for clickable items
// Optional properties
id?: string // Unique identifier
icon?: string // Icon name
groupIcon?: string // Group-level icon
group?: string // Group name
dividerBottom?: boolean // Show divider after item
children?: IMainNavItem[] // Nested items
permission?: string // User permission required
perspectivePermissionHide?: string // Permission to hide item
className?: string // Custom CSS class
hidden?: () => boolean // Dynamic visibility function
}
// File: personalization-bundle/.../target-groups/index.ts
export const TargetGroupsModule: AbstractModule = {
onInit: (): void => {
const mainNavRegistry = container.get<MainNavRegistry>(serviceIds.mainNavRegistry)
mainNavRegistry.registerMainNavItem({
path: 'ExperienceEcommerce/PersonalisationTargeting/Target Groups',
label: 'personalization.target-groups',
order: 10,
permission: 'targeting',
perspectivePermission: 'experienceEcommerce.personalizationTargetGroups',
widgetConfig: {
name: 'Target Groups',
id: 'target-groups',
component: 'target-groups-container',
config: {
translationKey: 'personalization.target-groups',
icon: {
type: 'name',
value: 'target-group'
}
}
}
})
}
}
The perspectivePermission field controls which perspectives can see the navigation item.
perspectivePermission: '{group}.{permissionKey}'
ContextPermissionGroups enum)// From: studio-backend-bundle/.../ContextPermissionGroups.php
enum ContextPermissionGroups: string
{
case QUICK_ACCESS = 'quickAccess';
case DATA_MANAGEMENT = 'dataManagement';
case EXPERIENCE_ECOMMERCE = 'experienceEcommerce';
case ASSET_MANAGEMENT = 'assetManagement';
case TRANSLATIONS = 'translations';
case REPORTING = 'reporting';
case SYSTEM = 'system';
case SEARCH = 'search';
}
// Experience & E-commerce group
perspectivePermission: 'experienceEcommerce.personalizationTargetingRules'
perspectivePermission: 'experienceEcommerce.personalizationTargetGroups'
perspectivePermission: 'experienceEcommerce.portalEngine'
perspectivePermission: 'experienceEcommerce.emails'
// Data Management group
perspectivePermission: 'dataManagement.portalEngineCollections'
perspectivePermission: 'dataManagement.bookmarkLists'
perspectivePermission: 'dataManagement.tagConfiguration'
// Reporting group
perspectivePermission: 'reporting.dashboards'
// Quick Access group
perspectivePermission: 'quickAccess.open_asset'
perspectivePermission: 'quickAccess.open_document'
// System group
perspectivePermission: 'system.users'
perspectivePermission: 'system.roles'
perspectivePermission against active perspective// Permission check (happens automatically)
const isAllowedInPerspective = (permission: string): boolean => {
const activePerspective = selectActivePerspective(store.getState())
if (!activePerspective) return false
// Walks nested object: activePerspective.contextPermissions.experienceEcommerce.personalizationTargetGroups
return isPathTrue(activePerspective.contextPermissions, permission)
}
The approach differs based on which bundle you're working in:
If you're adding permissions for studio-ui-bundle features, register them directly in studio-backend-bundle:
Primary Location (Most Permissions):
studio-backend-bundle/src/Perspective/Service/ContextPermissionService.phpAdditional Permissions via Subscribers:
ContextPermissionsServiceInterfaceIf you're creating a custom bundle (first-party or third-party), register permissions in your own bundle using a StudioContextPermissionsSubscriber as shown below.
Most core studio-ui-bundle permissions are defined directly in the ContextPermissionService:
<?php
// File: studio-backend-bundle/src/Perspective/Service/ContextPermissionService.php
namespace Pimcore\Bundle\StudioBackendBundle\Perspective\Service;
final readonly class ContextPermissionService implements ContextPermissionsServiceInterface
{
// ... existing code ...
private function getDefaultPermissions(): array
{
return [
// Quick Access permissions
new ContextPermissionData(
'open_asset',
ContextPermissionGroups::QUICK_ACCESS->value,
true
),
new ContextPermissionData(
'open_document',
ContextPermissionGroups::QUICK_ACCESS->value,
true
),
// Add your new studio-ui-bundle permission here
new ContextPermissionData(
'yourNewFeature',
ContextPermissionGroups::DATA_MANAGEMENT->value,
true
),
// ... more permissions ...
];
}
}
Permissions for custom bundles must be registered using a StudioContextPermissionsSubscriber in your bundle.
<?php
// File: your-bundle/src/EventSubscriber/StudioContextPermissionsSubscriber.php
namespace YourVendor\YourBundle\EventSubscriber;
use Pimcore\Bundle\StudioBackendBundle\Perspective\Model\ContextPermissionData;
use Pimcore\Bundle\StudioBackendBundle\Perspective\Service\ContextPermissionsServiceInterface;
use Pimcore\Bundle\StudioBackendBundle\Perspective\Util\Constant\ContextPermissionGroups;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;
final readonly class StudioContextPermissionsSubscriber implements EventSubscriberInterface
{
public function __construct(
private ContextPermissionsServiceInterface $permissionsService,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::CONTROLLER => 'addContextPermissions',
];
}
public function addContextPermissions(): void
{
// Register your permissions here
$this->permissionsService->add(
new ContextPermissionData(
'yourFeature', // Permission key
ContextPermissionGroups::DATA_MANAGEMENT->value, // Group
true // Default enabled
)
);
}
}
Most Symfony bundles auto-configure event subscribers. If not, add to services.yaml:
services:
YourVendor\YourBundle\EventSubscriber\StudioContextPermissionsSubscriber:
tags:
- { name: kernel.event_subscriber }
new ContextPermissionData(
string $key, // Permission key (e.g., 'yourFeature')
string $group, // Group from ContextPermissionGroups enum
bool $defaultValue = true // Default enabled state in new perspectives
)
<?php
// File: personalization-bundle/src/EventSubscriber/StudioContextPermissionsSubscriber.php
namespace Pimcore\Bundle\PersonalizationBundle\EventSubscriber;
use Pimcore\Bundle\StudioBackendBundle\Perspective\Model\ContextPermissionData;
use Pimcore\Bundle\StudioBackendBundle\Perspective\Service\ContextPermissionsServiceInterface;
use Pimcore\Bundle\StudioBackendBundle\Perspective\Util\Constant\ContextPermissionGroups;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;
final readonly class StudioContextPermissionsSubscriber implements EventSubscriberInterface
{
public function __construct(
private ContextPermissionsServiceInterface $permissionsService,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::CONTROLLER => 'addContextPermissions',
];
}
public function addContextPermissions(): void
{
// Register targeting rules permission
$this->permissionsService->add(
new ContextPermissionData(
'personalizationTargetingRules',
ContextPermissionGroups::EXPERIENCE_ECOMMERCE->value
)
);
// Register target groups permission
$this->permissionsService->add(
new ContextPermissionData(
'personalizationTargetGroups',
ContextPermissionGroups::EXPERIENCE_ECOMMERCE->value
)
);
}
}
You need two sets of translation keys:
Used in the main navigation menu.
# File: your-bundle/translations/studio.en.yaml
your-feature:
navigation:
title: Your Feature Name
Used in the Perspective Editor for enabling/disabling the feature.
# Pattern: perspective-editor.form.main-nav-permission.{group}.{permissionKey}
perspective-editor:
form:
main-nav-permission:
dataManagement:
yourFeature: Data Management > Your Feature Name
# Navigation label
personalization:
target-groups: Target Groups
# Perspective editor label
perspective-editor:
form:
main-nav-permission:
experienceEcommerce:
personalizationTargetGroups: Personalisation / Targeting > Target Groups
If adding a new group category label:
perspective-editor:
form:
main-nav-permission:
category:
experienceEcommerce: Experience & E-commerce
dataManagement: Data Management
reporting: Reporting
┌─────────────────────────────────────────────────────────────┐
│ 1. FRONTEND: Register Navigation Item │
│ │
│ mainNavRegistry.registerMainNavItem({ │
│ path: 'Group/SubGroup/Item', │
│ perspectivePermission: 'group.permissionKey' │
│ }) │
└────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 2. BACKEND: Register Permission (PHP) │
│ │
│ StudioContextPermissionsSubscriber::addContextPermissions()│
│ → permissionsService->add( │
│ new ContextPermissionData('permissionKey', 'group') │
│ ) │
└────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 3. TRANSLATIONS: Add Translation Keys │
│ │
│ - your-feature.navigation.title │
│ - perspective-editor.form.main-nav-permission.group.key │
└────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. PERSPECTIVE CONFIGURATION │
│ │
│ User enables/disables in Perspective Editor │
│ Settings stored per perspective │
└────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 5. RUNTIME: Navigation Filtering │
│ │
│ - Frontend checks perspectivePermission │
│ - Only shows nav items with enabled permissions │
└─────────────────────────────────────────────────────────────┘
Check browser console for your module initialization:
console.log('YourFeatureModule initialized')
// Single navigation item under existing group
mainNavRegistry.registerMainNavItem({
path: 'DataManagement/Your Feature',
label: 'your-feature.title',
perspectivePermission: 'dataManagement.yourFeature',
widgetConfig: {
name: 'Your Feature',
id: 'your-feature',
component: 'your-feature',
config: {
translationKey: 'your-feature.title',
icon: { type: 'name', value: 'your-icon' }
}
}
})
// Register multiple items in same group
mainNavRegistry.registerMainNavItem({
path: 'ExperienceEcommerce/YourBundle/Feature 1',
label: 'your-bundle.feature1',
order: 10,
perspectivePermission: 'experienceEcommerce.feature1',
widgetConfig: { /* ... */ }
})
mainNavRegistry.registerMainNavItem({
path: 'ExperienceEcommerce/YourBundle/Feature 2',
label: 'your-bundle.feature2',
order: 20,
perspectivePermission: 'experienceEcommerce.feature2',
widgetConfig: { /* ... */ }
})
// Combine perspectivePermission with user permission
mainNavRegistry.registerMainNavItem({
path: 'System/Your Admin Feature',
label: 'your-feature.admin',
permission: 'admin', // Requires admin user permission
perspectivePermission: 'system.yourAdminFeature', // AND perspective permission
widgetConfig: { /* ... */ }
})
// DON'T - Only register frontend
mainNavRegistry.registerMainNavItem({
perspectivePermission: 'dataManagement.yourFeature' // Won't work!
})
// DO - Register backend first
// Then frontend will work
// DON'T - Typo or wrong group
new ContextPermissionData(
'yourFeature',
'dataManagment' // Wrong! Typo
)
// DO - Use enum
new ContextPermissionData(
'yourFeature',
ContextPermissionGroups::DATA_MANAGEMENT->value // Correct
)
// Frontend
perspectivePermission: 'dataManagement.yourFeature' // 'yourFeature'
// Backend
new ContextPermissionData('your-feature', ...) // 'your-feature' - MISMATCH!
// DO - Keep them identical
// Frontend: 'dataManagement.yourFeature'
// Backend: 'yourFeature'
# DON'T - Forget perspective editor translation
your-feature:
navigation:
title: Your Feature
# DO - Add both
your-feature:
navigation:
title: Your Feature
perspective-editor:
form:
main-nav-permission:
dataManagement:
yourFeature: Data Management > Your Feature
To add a navigation item with perspective permissions:
onInit functionMainNavRegistry from containerperspectivePermission: 'group.key'your-feature.navigation.titleStudioContextPermissionsSubscriber classEventSubscriberInterfaceKernelEvents::CONTROLLER$permissionsService->add() in addContextPermissions()ContextPermissionGroups enum valueyour-feature.navigation.titleperspective-editor.form.main-nav-permission.{group}.{key}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