UI Extensions
Customize pages, list views, dashboards and inline actions across HotCRM.
UI Extensions
This page covers the front-end layer — how to customise list views, detail pages, dashboards, and add inline actions to records.
Pages — the detail screen
Every object has a default detail page. Override it with a *.page.ts file:
// packages/acme-extension/src/opportunity.page.ts
import { definePage, PageSchema } from '@objectstack/spec/ui';
export default PageSchema.parse(definePage({
object: 'crm_opportunity',
layout: 'two_column',
header: {
title: '{{name}}',
subtitle: '{{account.name}} · {{stage}}',
badges: [
{ field: 'stage' },
{ field: 'amount', format: 'currency' },
{ field: 'close_date', format: 'date' },
],
},
sections: [
{
label: 'Deal Overview',
columns: ['amount', 'close_date', 'probability', 'stage'],
},
{
label: 'Account & Contacts',
columns: ['crm_account', 'primary_contact', 'opportunity_contact_roles'],
},
{
label: 'Health',
columns: ['custom_deal_health', 'custom_competitor', 'last_activity_date'],
},
{
label: 'Line Items',
type: 'related_list',
related: 'crm_opportunity_line_item',
columns: ['crm_product', 'quantity', 'unit_price', 'total_price'],
},
{
label: 'Activity Timeline',
type: 'timeline',
sources: ['crm_task', 'event', 'email_message', 'note'],
},
],
inline_actions: [
{ type: 'standard', action: 'edit' },
{ type: 'standard', action: 'clone' },
{ type: 'ai_skill', skill: 'customer_360', label: '🔭 Customer 360' },
{ type: 'ai_skill', skill: 'email_drafting', label: '✍️ Draft Email' },
{ type: 'submit_for_approval', label: 'Submit for Discount Approval', condition: 'amount > 500000' },
],
}));List views
Customise the columns, filters, and default sort on list views:
// packages/acme-extension/src/opportunity_my_team.view.ts
import { defineView, ViewSchema } from '@objectstack/spec/ui';
export default ViewSchema.parse(defineView({
object: 'crm_opportunity',
name: 'my_team_open_deals',
label: 'My Team Open Deals',
columns: [
'name',
'account.name',
'owner.name',
'stage',
'amount',
'close_date',
'custom_deal_health',
'last_activity_date',
],
filters: [
['owner', 'in_role_subordinates', 'current_user'],
['stage', 'not_in', ['closed_won', 'closed_lost', 'closed_without_decision']],
],
sort: [{ field: 'amount', direction: 'desc' }],
scope: { profile: ['sales_user'], min_role: 'sales_manager' },
}));Dashboards
// packages/acme-extension/src/team_health.dashboard.ts
import { defineDashboard, DashboardSchema } from '@objectstack/spec/ui';
export default DashboardSchema.parse(defineDashboard({
name: 'team_health',
label: 'Team Health',
audience: { profile: ['sales_user'], min_role: 'sales_manager' },
layout: 'grid_12',
widgets: [
{
type: 'kpi',
title: 'Open Pipeline',
cube: 'pipeline_cube',
measure: 'pipeline_value',
filter: { owner: 'in_role_subordinates(current_user)' },
width: 4,
},
{
type: 'chart',
title: 'Pipeline by Stage',
chart: 'funnel',
cube: 'pipeline_cube',
measure: 'pipeline_value',
dimension: 'stage',
width: 8,
},
{
type: 'list',
title: 'Slipping Deals',
source_view: 'opportunity.slipping_this_week',
width: 12,
},
],
}));Inline actions and bulk actions
Inline = single-record buttons on detail pages. Bulk = multi-record buttons on list views.
// packages/acme-extension/src/opportunity.actions.ts
import { defineAction } from '@objectstack/spec/ui';
export const summariseOpportunity = defineAction({
object: 'crm_opportunity',
surface: 'bulk', // shows in list-view actions menu
label: 'Summarise selected',
icon: '✨',
handler: {
type: 'ai_skill',
skill: 'opportunity_batch_summary',
input: { opportunity_ids: '{{selected_ids}}' },
},
});Custom widgets
For UI that doesn't fit a standard widget, build a React component:
// packages/acme-extension/src/widgets/health_score.widget.tsx
import { defineWidget } from '@objectstack/runtime/ui';
import { useRecord } from '@objectstack/runtime/ui/hooks';
export default defineWidget({
name: 'health_score',
surface: 'detail_sidebar',
object: 'crm_account',
component: ({ recordId }) => {
const { record } = useRecord('crm_account', recordId);
const score = computeHealth(record);
return (
<div className={`health-score health-${score.tier}`}>
<h4>Account Health</h4>
<div className="score">{score.value}/100</div>
<ul>
{score.signals.map(s => (
<li key={s.label}>{s.label}: {s.value}</li>
))}
</ul>
</div>
);
},
});
function computeHealth(account) {
// domain logic
}Drop it into a page:
sidebar: [
{ type: 'widget', widget: 'health_score' },
],Theming
Each tenant can theme HotCRM:
// packages/acme-extension/src/theme.ts
export default {
name: 'acme',
brand: {
primary: '#0E4DA4',
accent: '#FFB000',
logo: '/branding/acme-logo.svg',
favicon: '/branding/acme.ico',
},
typography: {
sans: 'Inter, system-ui, sans-serif',
display: 'Söhne, Inter, sans-serif',
},
density: 'comfortable', // 'comfortable' | 'compact'
};Internationalisation
All UI strings should be wrapped:
import { t } from '@objectstack/runtime/i18n';
label: t('opportunity.field.amount', 'Amount'),Translations live in packages/{pkg}/i18n/{locale}.json.
Testing UI extensions
Snapshot-test page layouts with @objectstack/spec validation:
import opportunityPage from './opportunity.page';
import { PageSchema } from '@objectstack/spec/ui';
it('opportunity.page.ts is valid', () => {
expect(() => PageSchema.parse(opportunityPage)).not.toThrow();
});Component-test custom widgets with React Testing Library.
E2E-test critical flows with Playwright in the dev sandbox.
Tips
- ✅ Prefer config over code — page/view/dashboard definitions cover 90% of needs.
- ✅ Build custom widgets only when standard widgets don't fit — they have lifecycle cost.
- ✅ Audience-scope every list view and dashboard so users only see what's relevant.
- ✅ Inline actions are the cheapest way to expose new functionality — surface every new skill as a button.
- ✅ Validate every
*.page.ts/*.view.ts/*.dashboard.tswith@objectstack/specschemas in CI.
Related
- Extending Objects — the data layer your UI shows.
- AI Skills — to wire AI actions into pages.