TAB Extensions
Overview
Section titled “Overview”TAB extensions add custom tabs to detail views for work units, customers, and other entities. They’re perfect for displaying related data, custom workflows, and rich visualizations within the context of an entity.
Use Cases
Section titled “Use Cases”- Display related data (time entries, attachments, notes)
- Custom workflows within entity context
- Rich data visualizations and dashboards
- Integration with external services
- Custom forms and data entry
Basic Example
Section titled “Basic Example”{ point: 'work-unit.detail.tabs', plugin: 'my-plugin', priority: 50, extension: { type: ExtensionType.TAB, id: 'my-tab', label: 'My Tab', icon: IconExample, component: MyTabComponent, }}TAB Extension Properties
Section titled “TAB Extension Properties”Required Properties
Section titled “Required Properties”- Value:
ExtensionType.TAB - Description: Identifies this as a tab extension
- Type:
string - Description: Unique identifier for the tab
- Example:
'time-entries','custom-fields'
- Type:
string - Description: Display text shown in tab header
- Example:
'Time Tracking','Documents'
component
Section titled “component”- Type:
React.ComponentType<TabComponentProps> - Description: The React component to render
Optional Properties
Section titled “Optional Properties”- Type:
React.ComponentType<{ size?: number }> - Description: Icon component from
@aether/ui/icons - Example:
IconClock,IconFiles
- Type:
(context: any) => string | number | undefined - Description: Function returning badge value (count, status, etc)
- Example:
(context) => context?.timeEntryCount
isDefault
Section titled “isDefault”- Type:
boolean - Description: Whether this tab should be selected by default
- Default:
false
isDisabled
Section titled “isDisabled”- Type:
(context: any) => boolean - Description: Function determining if tab should be disabled
- Example:
(context) => !context.organization?.premiumTier
Component Props Interface
Section titled “Component Props Interface”interface TabComponentProps { context: { // Entity data workUnit?: any; workUnitId?: string; customer?: any; customerId?: string;
// Organization context organizationId: string; organization?: { id: string; name: string; businessType: string; premiumTier: string; premiumFeatures: string[]; };
// User context userId: string; user?: { id: string; name: string; email: string; };
// Additional context businessType?: string; status?: string; entityType?: string; entityId?: string; };
config: any; // Plugin configuration isCondensed?: boolean; // Condensed view mode}Real-World Example: Timer Tab
Section titled “Real-World Example: Timer Tab”import { Stack, Card, Text, Button, Group, Loader } from '@aether/ui';import { IconClock, IconPlayerPlay, IconPlayerStop } from '@aether/ui/icons';import { trpc } from '@/lib/trpc/client';
interface TimerTabProps { context: { workUnit: any; workUnitId: string; organizationId: string; }; config: any;}
export function TimerTab({ context }: TimerTabProps) { const { workUnit, workUnitId, organizationId } = context;
// Fetch time entries using tRPC const { data: timeEntries = [], isLoading, refetch } = trpc.timer.getTimeEntries.useQuery({ workUnitId, organizationId, });
// Start timer mutation const startTimer = trpc.timer.start.useMutation({ onSuccess: () => refetch(), });
// Stop timer mutation const stopTimer = trpc.timer.stop.useMutation({ onSuccess: () => refetch(), });
const activeEntry = timeEntries.find(e => !e.endTime);
return ( <Stack gap="md"> {/* Timer Controls */} <Card shadow="sm"> <Group justify="space-between"> <div> <Text size="lg" fw={600}>{workUnit.name}</Text> <Text size="sm" c="dimmed"> {activeEntry ? 'Timer running' : 'No active timer'} </Text> </div> {activeEntry ? ( <Button leftSection={<IconPlayerStop size={16} />} color="error" onClick={() => stopTimer.mutate({ entryId: activeEntry.id })} > Stop Timer </Button> ) : ( <Button leftSection={<IconPlayerPlay size={16} />} onClick={() => startTimer.mutate({ workUnitId })} > Start Timer </Button> )} </Group> </Card>
{/* Time Entries List */} {isLoading ? ( <Loader /> ) : ( <Stack gap="sm"> {timeEntries.map(entry => ( <TimeEntryCard key={entry.id} entry={entry} /> ))} </Stack> )} </Stack> );}Using the Badge Property
Section titled “Using the Badge Property”Badges display counts or status indicators in the tab header:
{ point: 'work-unit.detail.tabs', plugin: 'tasks', priority: 60, extension: { type: ExtensionType.TAB, id: 'tasks-tab', label: 'Tasks', icon: IconChecklist, component: TasksTab, // Show count of incomplete tasks badge: (context) => { const incompleteTasks = context?.tasks?.filter(t => !t.completed).length; return incompleteTasks > 0 ? incompleteTasks : undefined; } }}Conditional Rendering with Match
Section titled “Conditional Rendering with Match”Only show tabs for specific business types or premium tiers:
{ point: 'work-unit.detail.tabs', plugin: 'advanced-analytics', priority: 40, // Only show for SERVICE business type and PREMIUM tier match: { businessType: ['SERVICE'], premiumTier: ['PREMIUM', 'ENTERPRISE'] }, extension: { type: ExtensionType.TAB, id: 'analytics-tab', label: 'Analytics', icon: IconChartBar, component: AnalyticsTab, }}Condensed Mode
Section titled “Condensed Mode”Support condensed view for smaller screens:
export function MyTab({ context, isCondensed }: TabComponentProps) { return ( <Stack gap={isCondensed ? 'xs' : 'md'}> <Title order={isCondensed ? 4 : 3}> {context.workUnit.name} </Title>
{!isCondensed && ( <Text c="dimmed"> Additional details only shown in full mode </Text> )}
<Card padding={isCondensed ? 'sm' : 'md'}> {/* Content */} </Card> </Stack> );}Best Practices
Section titled “Best Practices”1. Handle Loading States
Section titled “1. Handle Loading States”export function MyTab({ context }: TabComponentProps) { const { data, isLoading, error } = trpc.myPlugin.getData.useQuery({ entityId: context.workUnitId, });
if (isLoading) { return <Loader />; }
if (error) { return <ErrorDisplay error={error} />; }
return <DataDisplay data={data} />;}2. Use Context Appropriately
Section titled “2. Use Context Appropriately”// ✅ GOOD: Use IDs for queriesconst { data } = trpc.getData.useQuery({ workUnitId: context.workUnitId, organizationId: context.organizationId,});
// ❌ BAD: Pass entire objectsconst { data } = trpc.getData.useQuery({ workUnit: context.workUnit, // Objects may not serialize well});3. Respect Configuration
Section titled “3. Respect Configuration”export function MyTab({ context, config }: TabComponentProps) { // Use plugin configuration const refreshInterval = config.refreshInterval ?? 30; const showNotifications = config.notifications ?? true;
useEffect(() => { const interval = setInterval(refetch, refreshInterval * 1000); return () => clearInterval(interval); }, [refreshInterval]);
// ...}4. Clean Up Resources
Section titled “4. Clean Up Resources”export function MyTab({ context }: TabComponentProps) { useEffect(() => { const handler = () => console.log('Update'); window.addEventListener('storage', handler);
return () => { window.removeEventListener('storage', handler); }; }, []);
// ...}Common Patterns
Section titled “Common Patterns”Fetching Related Data
Section titled “Fetching Related Data”export function RelatedDataTab({ context }: TabComponentProps) { const { data: items } = trpc.items.list.useQuery({ workUnitId: context.workUnitId, organizationId: context.organizationId, });
const createItem = trpc.items.create.useMutation({ onSuccess: () => refetch(), });
return ( <Stack> <Button onClick={() => createItem.mutate({ ...newItem })}> Add Item </Button> {items?.map(item => <ItemCard key={item.id} item={item} />)} </Stack> );}Empty States
Section titled “Empty States”if (!data || data.length === 0) { return ( <Card> <Stack align="center" gap="md"> <IconInbox size={48} color="gray" /> <Text c="dimmed">No entries yet</Text> <Button onClick={handleCreate}>Create First Entry</Button> </Stack> </Card> );}