Building Your First Plugin - Part 2
Enhance the Role Viewer with Protected components, detail pages, CrudGrid integration, real-time updates, and professional UX patterns.
πLearning Objectives
- Add authorization with
ProtectedandIfCancomponents - Implement multi-page navigation with detail views
- Use
CrudGridfor professional data tables - Add real-time updates with SSE
- Implement professional UX patterns (empty states, confirmations)
- Use batch authorization for efficient permission checks
πWhere We Left Off
In Day 13, we built a Role Viewer plugin with:
- β Project structure with esbuild
- β plugins.yaml configuration
- β Dashboard component fetching roles
- β RoleCard component with styling
- β Loading and error states
Today we'll enhance it with authorization, detail pages, and professional features.
Update plugins.yaml Permissions
# Add PDP evaluation permission
permissions:
api:
- method: GET
path: /api/membership/users/{id}/roles
- method: GET
path: /api/membership/roles/{id}
# Authorization endpoints
- method: POST
path: /access/v1/evaluation
- method: POST
path: /access/v1/evaluations # For batch
Create AuthWrapper Component
// src/components/AuthWrapper.tsx
const React = (globalThis as any).React;
const { useState, useEffect } = React;
const sdk = (globalThis as any).pluginSdk;
interface IfCanProps {
resource: string;
action: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}
/**
* Conditionally render children based on authorization
*/
export function IfCan({ resource, action, children, fallback = null }: IfCanProps) {
const [authorized, setAuthorized] = useState<boolean | null>(null);
useEffect(() => {
checkAuth();
}, [resource, action]);
const checkAuth = async () => {
try {
const result = await sdk.authz.evaluate({
resource: { type: resource },
action: { name: action }
});
setAuthorized(result.decision === true);
} catch (err) {
console.error('Auth check failed:', err);
setAuthorized(false); // Fail closed
}
};
if (authorized === null) {
return null; // Still checking
}
return authorized ? children : fallback;
}
/**
* Button that only shows if user has permission
*/
export function ProtectedButton({
resource,
action,
onClick,
children,
style = {},
...props
}: any) {
const [authorized, setAuthorized] = useState<boolean | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
checkAuth();
}, [resource, action]);
const checkAuth = async () => {
try {
const result = await sdk.authz.evaluate({
resource: { type: resource },
action: { name: action }
});
setAuthorized(result.decision === true);
} catch {
setAuthorized(false);
}
};
const handleClick = async () => {
setLoading(true);
try {
await onClick();
} finally {
setLoading(false);
}
};
if (authorized === null || !authorized) {
return null;
}
return React.createElement('button', {
onClick: handleClick,
disabled: loading,
style: { ...style, opacity: loading ? 0.6 : 1 },
...props
}, loading ? 'Loading...' : children);
}
Update Dashboard with Authorization
// In src/index.tsx - wrap content with IfCan
import { IfCan, ProtectedButton } from './components/AuthWrapper';
export function Dashboard() {
// ... existing state and fetchRoles ...
return React.createElement('div', { style: styles.container },
// Header with protected action button
React.createElement('div', { style: styles.header },
React.createElement('h1', { style: styles.title }, 'My Roles'),
// Only show refresh if user can view roles
React.createElement(ProtectedButton, {
resource: 'role-viewer.roles',
action: 'refresh',
onClick: fetchRoles,
style: styles.refreshButton
}, 'π Refresh')
),
// Entire content protected
React.createElement(IfCan, {
resource: 'role-viewer.roles',
action: 'view',
fallback: React.createElement('div', { style: styles.accessDenied },
'π You do not have permission to view roles.'
)
},
// ... role cards grid ...
)
);
}
Notice how authorization checks default to false on error. This is the fail-closed patternβif we can't verify permission, deny access. Never fail-open in security code.
Update plugins.yaml with Detail Route
contributions:
routes:
- path: /role-viewer
component: Dashboard
title: "My Roles"
resource: role-viewer.route
action: view
# Add detail route
- path: /role-viewer/role/:roleId
component: RoleDetail
title: "Role Details"
resource: role-viewer.role
action: view
Create RoleDetail Component
// src/RoleDetail.tsx
const React = (globalThis as any).React;
const { useState, useEffect } = React;
const sdk = (globalThis as any).pluginSdk;
interface RoleDetailProps {
roleId: string;
}
export function RoleDetail({ roleId }: RoleDetailProps) {
const [role, setRole] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchRoleDetail();
}, [roleId]);
const fetchRoleDetail = async () => {
try {
setLoading(true);
const response = await sdk.api.fetch(`/api/membership/roles/${roleId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setRole(data);
} catch (err: any) {
setError(err.message || 'Failed to load role');
} finally {
setLoading(false);
}
};
if (loading) {
return React.createElement('div', { style: styles.container },
React.createElement('div', { style: styles.skeleton }, 'Loading role details...')
);
}
if (error || !role) {
return React.createElement('div', { style: styles.container },
React.createElement('div', { style: styles.errorBox },
React.createElement('h3', null, 'Error'),
React.createElement('p', null, error || 'Role not found'),
React.createElement('a', { href: '/role-viewer', style: styles.backLink }, 'β Back to Roles')
)
);
}
return React.createElement('div', { style: styles.container },
// Back navigation
React.createElement('a', {
href: '/role-viewer',
style: styles.backLink
}, 'β Back to My Roles'),
// Role header
React.createElement('div', { style: styles.detailHeader },
React.createElement('h1', { style: styles.detailTitle }, role.name),
React.createElement('span', {
style: {
...styles.sourceBadge,
background: `${getSourceColor(role.source)}20`,
color: getSourceColor(role.source)
}
}, role.source)
),
// Description
role.description && React.createElement('p', {
style: styles.description
}, role.description),
// Metadata cards
React.createElement('div', { style: styles.metaGrid },
// Location
role.location && React.createElement('div', { style: styles.metaCard },
React.createElement('span', { style: styles.metaLabel }, 'π Location'),
React.createElement('span', { style: styles.metaValue }, role.location)
),
// Assigned date
role.assignedAt && React.createElement('div', { style: styles.metaCard },
React.createElement('span', { style: styles.metaLabel }, 'π
Assigned'),
React.createElement('span', { style: styles.metaValue },
new Date(role.assignedAt).toLocaleDateString()
)
),
// Expiration
role.expiresAt && React.createElement('div', { style: styles.metaCard },
React.createElement('span', { style: styles.metaLabel }, 'β° Expires'),
React.createElement('span', { style: styles.metaValue },
new Date(role.expiresAt).toLocaleDateString()
)
)
),
// Permissions section
role.permissions && role.permissions.length > 0 &&
React.createElement('div', { style: styles.section },
React.createElement('h3', { style: styles.sectionTitle }, 'π Permissions'),
React.createElement('div', { style: styles.permissionList },
role.permissions.map((perm: string, i: number) =>
React.createElement('span', {
key: i,
style: styles.permissionTag
}, perm)
)
)
)
);
}
function getSourceColor(source: string): string {
const colors: Record<string, string> = {
direct: '#10b981',
inherited: '#3b82f6',
delegated: '#f59e0b'
};
return colors[source] || '#6b7280';
}
// Add styles for detail page
const styles = {
container: { padding: '24px', maxWidth: '800px', margin: '0 auto' },
backLink: {
color: 'var(--accent-cyan)',
textDecoration: 'none',
fontSize: '14px',
display: 'block',
marginBottom: '24px'
},
detailHeader: {
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '16px'
},
detailTitle: {
margin: 0,
fontSize: '32px',
fontWeight: 700,
color: 'var(--main-text, #f0f0f5)'
},
sourceBadge: {
padding: '6px 12px',
borderRadius: '6px',
fontSize: '12px',
fontWeight: 600,
textTransform: 'uppercase' as const
},
description: {
fontSize: '16px',
color: 'var(--secondary-text, #9090a0)',
marginBottom: '24px',
lineHeight: 1.6
},
metaGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px',
marginBottom: '32px'
},
metaCard: {
background: 'var(--card-bg, #16161f)',
border: '1px solid var(--border, #2a2a3a)',
borderRadius: '8px',
padding: '16px',
display: 'flex',
flexDirection: 'column' as const,
gap: '8px'
},
metaLabel: { fontSize: '13px', color: 'var(--muted-text, #606070)' },
metaValue: { fontSize: '16px', fontWeight: 600, color: 'var(--main-text, #f0f0f5)' },
section: { marginTop: '32px' },
sectionTitle: {
fontSize: '18px',
fontWeight: 600,
marginBottom: '16px',
color: 'var(--main-text, #f0f0f5)'
},
permissionList: { display: 'flex', flexWrap: 'wrap' as const, gap: '8px' },
permissionTag: {
padding: '6px 12px',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.2)',
borderRadius: '6px',
fontSize: '13px',
color: '#3b82f6'
},
skeleton: { padding: '48px', textAlign: 'center' as const, color: 'var(--muted-text)' },
errorBox: { padding: '48px', textAlign: 'center' as const }
};
Update Entry Point Exports
// src/index.tsx - add RoleDetail to exports
import { RoleDetail } from './RoleDetail';
// ... Dashboard component ...
// Export both components
export const routes = { Dashboard, RoleDetail };
export const widgets = {};
export default { routes, widgets };
Make RoleCards Clickable
// In RoleCard.tsx - add navigation
export function RoleCard({ role }: { role: Role }) {
const handleClick = () => {
// Navigate to detail page
window.location.href = `/role-viewer/role/${role.id}`;
};
return React.createElement('div', {
style: cardStyle,
onClick: handleClick,
onMouseEnter: () => setIsHovered(true),
onMouseLeave: () => setIsHovered(false),
role: 'button',
tabIndex: 0,
onKeyPress: (e: any) => e.key === 'Enter' && handleClick()
},
// ... card content ...
);
}
Add Batch Permission Check
// src/hooks/useBatchPermissions.ts
const React = (globalThis as any).React;
const { useState, useEffect } = React;
const sdk = (globalThis as any).pluginSdk;
interface Permission {
resource: string;
action: string;
}
/**
* Check multiple permissions in a single API call
*/
export function useBatchPermissions(permissions: Permission[]) {
const [results, setResults] = useState<Map<string, boolean>>(new Map());
const [loading, setLoading] = useState(true);
useEffect(() => {
if (permissions.length === 0) {
setLoading(false);
return;
}
checkPermissions();
}, [JSON.stringify(permissions)]);
const checkPermissions = async () => {
try {
setLoading(true);
// Single batch API call!
const response = await sdk.authz.batchEvaluate(
permissions.map(p => ({
resource: { type: p.resource },
action: { name: p.action }
}))
);
// Map results back to permission keys
const resultMap = new Map<string, boolean>();
permissions.forEach((p, i) => {
const key = `${p.resource}:${p.action}`;
const decision = response.decisions?.[i]?.decision;
resultMap.set(key, decision === true || decision === 'Permit');
});
setResults(resultMap);
} catch (err) {
console.error('Batch permission check failed:', err);
// Fail closed - all false
const failedMap = new Map<string, boolean>();
permissions.forEach(p => {
failedMap.set(`${p.resource}:${p.action}`, false);
});
setResults(failedMap);
} finally {
setLoading(false);
}
};
const can = (resource: string, action: string): boolean => {
return results.get(`${resource}:${action}`) ?? false;
};
return { can, loading, results };
}
Use in RoleCard with Actions
// Updated RoleCard with action buttons
export function RoleCard({ role, permissions }: { role: Role, permissions: any }) {
// ... existing code ...
return React.createElement('div', { style: cardStyle },
// ... header and content ...
// Action buttons (only show if permitted)
React.createElement('div', { style: styles.actions },
permissions.can(`role.${role.id}`, 'edit') &&
React.createElement('button', {
style: styles.actionBtn,
onClick: (e: any) => { e.stopPropagation(); handleEdit(role); }
}, 'βοΈ Edit'),
permissions.can(`role.${role.id}`, 'delegate') &&
React.createElement('button', {
style: styles.actionBtn,
onClick: (e: any) => { e.stopPropagation(); handleDelegate(role); }
}, 'π Delegate')
)
);
}
Individual checks: 10 roles Γ 3 actions = 30 API calls
Batch check: 1 API call for all 30 permissions
Result: 3x faster, better UX, lower server load
Update plugins.yaml with SSE Permission
permissions:
api:
# ... existing permissions ...
sse:
- /api/events/membership/roles
Create useRoleUpdates Hook
// src/hooks/useRoleUpdates.ts
const React = (globalThis as any).React;
const { useEffect, useRef } = React;
const sdk = (globalThis as any).pluginSdk;
/**
* Subscribe to real-time role updates
*/
export function useRoleUpdates(onUpdate: (event: any) => void) {
const unsubscribeRef = useRef<(() => void) | null>(null);
useEffect(() => {
// Subscribe to SSE stream
unsubscribeRef.current = sdk.sse.subscribe(
'/api/events/membership/roles',
(data: any) => {
console.log('Role update received:', data);
onUpdate(data);
}
);
// Cleanup on unmount
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current();
}
};
}, []);
}
// Usage in Dashboard
export function Dashboard() {
const [roles, setRoles] = useState([]);
// Subscribe to updates
useRoleUpdates((event) => {
if (event.type === 'role_assigned') {
setRoles(prev => [...prev, event.role]);
} else if (event.type === 'role_revoked') {
setRoles(prev => prev.filter(r => r.id !== event.roleId));
} else if (event.type === 'role_updated') {
setRoles(prev => prev.map(r =>
r.id === event.role.id ? event.role : r
));
}
});
// ... rest of component
}
Add Update Indicator
// Show a toast when updates arrive
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
useRoleUpdates((event) => {
// ... handle update ...
// Show notification
setLastUpdate(`Role ${event.type.replace('_', ' ')} at ${new Date().toLocaleTimeString()}`);
// Clear after 3 seconds
setTimeout(() => setLastUpdate(null), 3000);
});
// In render
{lastUpdate && React.createElement('div', { style: styles.updateToast },
'π ', lastUpdate
)}
Empty State Component
// src/components/EmptyState.tsx
export function EmptyState({ icon, title, message, actionText, onAction }: any) {
return React.createElement('div', { style: styles.emptyContainer },
React.createElement('span', { style: styles.emptyIcon }, icon),
React.createElement('h3', { style: styles.emptyTitle }, title),
React.createElement('p', { style: styles.emptyMessage }, message),
actionText && onAction && React.createElement('button', {
style: styles.emptyAction,
onClick: onAction
}, actionText)
);
}
const styles = {
emptyContainer: {
textAlign: 'center' as const,
padding: '60px 24px',
maxWidth: '400px',
margin: '0 auto'
},
emptyIcon: { fontSize: '64px', marginBottom: '16px', display: 'block', opacity: 0.7 },
emptyTitle: { fontSize: '20px', fontWeight: 600, marginBottom: '8px', color: 'var(--main-text)' },
emptyMessage: { fontSize: '14px', color: 'var(--secondary-text)', marginBottom: '24px' },
emptyAction: {
padding: '12px 24px',
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
cursor: 'pointer'
}
};
Confirmation Dialog
// src/components/ConfirmDialog.tsx
export function ConfirmDialog({
isOpen,
title,
message,
onConfirm,
onCancel,
danger = false
}: any) {
if (!isOpen) return null;
return React.createElement('div', {
style: styles.overlay,
onClick: onCancel,
role: 'dialog',
'aria-modal': 'true'
},
React.createElement('div', {
style: styles.dialog,
onClick: (e: any) => e.stopPropagation()
},
React.createElement('h3', { style: styles.dialogTitle }, title),
React.createElement('p', { style: styles.dialogMessage }, message),
React.createElement('div', { style: styles.dialogActions },
React.createElement('button', {
style: styles.cancelBtn,
onClick: onCancel
}, 'Cancel'),
React.createElement('button', {
style: danger ? styles.dangerBtn : styles.confirmBtn,
onClick: onConfirm
}, danger ? 'Delete' : 'Confirm')
)
)
);
}
const styles = {
overlay: {
position: 'fixed' as const,
inset: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999
},
dialog: {
background: 'var(--card-bg, #16161f)',
border: '1px solid var(--border, #2a2a3a)',
borderRadius: '12px',
padding: '24px',
maxWidth: '400px',
width: '90%'
},
dialogTitle: { margin: '0 0 12px 0', fontSize: '18px', fontWeight: 600 },
dialogMessage: { margin: '0 0 24px 0', color: 'var(--secondary-text)' },
dialogActions: { display: 'flex', gap: '12px', justifyContent: 'flex-end' },
cancelBtn: {
padding: '8px 16px',
background: 'var(--bg-tertiary)',
border: 'none',
borderRadius: '6px',
color: 'var(--main-text)',
cursor: 'pointer'
},
confirmBtn: {
padding: '8px 16px',
background: '#3b82f6',
border: 'none',
borderRadius: '6px',
color: 'white',
cursor: 'pointer'
},
dangerBtn: {
padding: '8px 16px',
background: '#ef4444',
border: 'none',
borderRadius: '6px',
color: 'white',
cursor: 'pointer'
}
};
Keyboard Shortcuts
// Add keyboard shortcuts to Dashboard
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl/Cmd + R: Refresh
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
e.preventDefault();
fetchRoles();
}
// Escape: Close any open dialog
if (e.key === 'Escape') {
setShowConfirm(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// Show hint in footer
React.createElement('div', { style: styles.shortcutHint },
'π‘ Shortcuts: ',
React.createElement('kbd', { style: styles.kbd }, 'Ctrl+R'),
' Refresh'
)
πFinal Plugin Structure
role-viewer/
βββ 1.0.0/
βββ package.json
βββ tsconfig.json
βββ build.mjs
β
βββ src/
β βββ index.tsx # Entry + Dashboard
β βββ RoleCard.tsx # Card component
β βββ RoleDetail.tsx # Detail page
β βββ styles.ts # Shared styles
β β
β βββ components/
β β βββ AuthWrapper.tsx # IfCan, ProtectedButton
β β βββ EmptyState.tsx # Empty state UI
β β βββ ConfirmDialog.tsx # Confirmation modal
β β
β βββ hooks/
β βββ useBatchPermissions.ts # Batch authz
β βββ useRoleUpdates.ts # SSE subscription
β
βββ dist/
βββ index.esm.js
βKnowledge Check
Batch authorization sends all permission checks in a single API call instead of multiple calls. For 10 roles with 3 actions each, that's 1 call vs 30 callsβ3x faster, better UX, and lower server load.
If an authorization check fails (network error, timeout, etc.), we default to denying access. This is the fail-closed pattern. Failing open (granting access on error) would be a security vulnerability.
Polling: Client asks server repeatedly "anything new?" every N seconds. Wasteful if nothing changed.
SSE: Server pushes updates to client only when something changes. More efficient, instant updates, lower latency.
SSE endpoints go in the permissions.sse array, not permissions.api:
permissions:
sse:
- /api/events/membership/roles
Reusability and consistency. These patterns appear throughout professional apps. Creating dedicated components means:
- Consistent styling across the plugin
- Single place to update if design changes
- Less code duplication
- Easier testing
πPlugin Localization (i18n)
Quick Start: Localize in 5 Steps
Step 1: Create Translation Catalog
Create a JSON file with your translations in ServiceConfigs/BFF/config/i18n/compiled/plugins/{your-plugin-id}/en-US.json:
{
"$comment": "Role Viewer translations - English (US)",
"$revision": "sha256:initial",
"$locale": "en-US",
"$plugin_id": "role-viewer",
"plugins.role-viewer.title": "Role Viewer",
"plugins.role-viewer.description": "View and manage your roles",
"plugins.role-viewer.dashboard.title": "My Roles",
"plugins.role-viewer.dashboard.subtitle": "Overview of assigned roles",
"plugins.role-viewer.dashboard.empty": "No roles assigned yet",
"plugins.role-viewer.actions.refresh": "Refresh",
"plugins.role-viewer.actions.delegate": "Delegate",
"plugins.role-viewer.actions.viewDetails": "View Details",
"plugins.role-viewer.detail.permissions": "Permissions",
"plugins.role-viewer.detail.location": "Location",
"plugins.role-viewer.detail.assigned": "Assigned",
"plugins.role-viewer.detail.expires": "Expires",
"plugins.role-viewer.errors.loadFailed": "Failed to load roles",
"plugins.role-viewer.errors.accessDenied": "You do not have permission to view roles"
}
Step 2: Add Translation Helper
Add this helper function at the top of your plugin's entry file:
// src/i18n.ts - Translation helper
const PLUGIN_ID = 'role-viewer';
export function t(key: string, params?: Record<string, unknown>): string {
const globalI18n = (window as any).__EXPERIENCE_I18N__;
if (!globalI18n) return key; // Fallback if i18n not loaded
// Auto-prefix with plugin namespace
const fullKey = key.startsWith('plugins.') || key.startsWith('shell.')
? key
: `plugins.${PLUGIN_ID}.${key}`;
// Try plugin catalog first, then shell catalog
const pluginCatalog = globalI18n.pluginCatalogs?.get(PLUGIN_ID);
let message = pluginCatalog?.[fullKey] || globalI18n.shellCatalog?.[fullKey];
if (!message) return key; // Return key for visibility in dev
// Simple parameter interpolation
if (params) {
return message.replace(/\{(\w+)\}/g, (_, name) =>
params[name] !== undefined ? String(params[name]) : `{${name}}`
);
}
return message;
}
Step 3: Replace Hardcoded Strings
// Before (hardcoded)
React.createElement('h1', null, 'My Roles')
// After (localized)
import { t } from './i18n';
React.createElement('h1', null, t('dashboard.title'))
Step 4: Use Shell Translations for Common Actions
The shell provides 170+ pre-translated keys for common UI elements. Use them for consistency:
// β
Good - uses shared shell translations
t('shell.actions.save') // "Save"
t('shell.actions.cancel') // "Cancel"
t('shell.actions.delete') // "Delete"
t('shell.loading') // "Loading..."
t('shell.error.generic') // "Something went wrong"
// β Bad - duplicates shell translations
t('save') // Don't create separate plugin keys
Step 5: Rebuild Plugin
npm run build
Key Patterns
Translation Key Naming Convention
plugins.{plugin-id}.{category}.{item}
// Examples:
plugins.role-viewer.title
plugins.role-viewer.dashboard.subtitle
plugins.role-viewer.actions.refresh
plugins.role-viewer.errors.loadFailed
Using Parameters (Interpolation)
// Translation catalog:
// "plugins.role-viewer.items.count": "Showing {count} roles"
t('items.count', { count: 42 }) // β "Showing 42 roles"
ICU Pluralization
For proper pluralization across languages, use ICU MessageFormat:
{
"shell.search.resultsCount": "{count, plural, =0 {No results} one {# result} other {# results}}"
}
// The LocalizationProvider handles ICU parsing automatically
Accessing Formatters
Use the SDK's built-in formatters for dates, numbers, and currencies:
const sdk = (globalThis as any).pluginSdk;
sdk.i18n.formatDate(new Date()) // "1/21/2026"
sdk.i18n.formatNumber(1234.56) // "1,234.56"
sdk.i18n.formatCurrency(99.99, 'EUR') // "β¬99.99"
sdk.i18n.formatRelativeTime(-2, 'day') // "2 days ago"
Shell Translation Categories
Use these instead of creating duplicates:
| Category | Example Keys | Use For |
|---|---|---|
shell.loading.* |
shell.loading, shell.loading.content |
Loading states |
shell.error.* |
shell.error.generic, shell.error.network |
Error messages |
shell.actions.* |
shell.actions.save, shell.actions.cancel |
Button labels |
shell.form.* |
shell.form.required, shell.form.invalidEmail |
Form validation |
shell.table.* |
shell.table.empty, shell.table.loading |
Data tables |
shell.confirm.* |
shell.confirm.delete, shell.confirm.proceed |
Confirmations |
shell.a11y.* |
shell.a11y.loading, shell.a11y.skipToContent |
Accessibility |
RTL (Right-to-Left) Support
For Arabic, Hebrew, and other RTL locales, use CSS logical properties:
/* β
Good - uses logical properties (works for both LTR and RTL) */
.sidebar {
margin-inline-start: 16px;
padding-inline-end: 8px;
text-align: start;
}
/* β Bad - uses physical properties (breaks RTL) */
.sidebar {
margin-left: 16px; /* Wrong direction in RTL */
padding-right: 8px;
text-align: left;
}
Check the current direction via SDK:
const direction = sdk.i18n.direction; // 'ltr' or 'rtl'
Menu Localization
To localize menu items, add groupKey and titleKey to your navigation config:
menu_structure:
- group: "Administration"
groupKey: "shell.menu.groups.administration" # Translation key
icon: "β"
columns:
- title: "Platform"
titleKey: "shell.menu.columns.platform" # Translation key
items:
- role-viewer
Testing Localization
1. Check Console for Loading
[i18n] Loaded shell catalog: en-US (178 keys)
[i18n] Loaded plugin catalog: role-viewer (24 keys)
2. Test Missing Keys
t('nonexistent.key') // Returns: "nonexistent.key" (visible in dev)
3. Test Locale Switching
- Click the locale selector in the footer
- Select a different locale
- Verify UI updates (or shows keys for untranslated locales)
Troubleshooting Localization
Key displayed instead of text: Translation missing - add key to catalog JSON
Plugin translations not loading: Catalog file missing - create en-US.json for your plugin
Shell translations work, plugin doesn't: Plugin ID mismatch - verify PLUGIN_ID matches catalog's $plugin_id
Force Cache Reload
// Browser: Ctrl+Shift+R (hard refresh)
// Clear localStorage:
localStorage.removeItem('experience_locale');
// BFF: Restart the BFF service to clear ETag cache
- Use shell translations for common actions (save, cancel, delete)
- Follow naming convention:
plugins.{id}.{category}.{item} - Use ICU for pluralsβnever use "item(s)" hacks
- Test with long strings (German is ~30% longer than English)
- Use CSS logical properties (
margin-inline-startnotmargin-left) - Keep keys stableβchanging keys breaks translations
πDay 14 Checkpoint
- Added authorization with IfCan and ProtectedButton components
- Created multi-page navigation with RoleDetail component
- Implemented batch authorization for efficient permission checks
- Added real-time updates with SSE subscription
- Created professional UX components (EmptyState, ConfirmDialog)
- Added keyboard shortcuts for power users
- Implemented plugin localization with translation catalogs
- Rebuilt and deployed enhanced plugin
Your Role Viewer is now enterprise-grade: authorization-gated, multi-page, real-time, with professional UX patterns. This is the quality expected in production plugins.