Skip to content

Commit 4b413a8

Browse files
Copilothotlong
andcommitted
feat: unify Navigation protocol with Area support, new nav types, and enhanced BaseNavItem fields
- Add order, badge, requiredPermissions to BaseNavItemSchema - Add ReportNavItemSchema (type: 'report') - Add ActionNavItemSchema (type: 'action') - Add NavigationAreaSchema for business domain partitioning - Add areas field to AppSchema - Update NavigationItemSchema union with new types - Export new types: NavigationArea, ReportNavItem, ActionNavItem - Add comprehensive tests (20 new test cases) - Update ROADMAP.md with navigation unification milestone Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent c3a6c1f commit 4b413a8

3 files changed

Lines changed: 428 additions & 2 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ The following renames are planned for packages that implement core service contr
148148

149149
- [x] **Data Protocol** — Object, Field (35+ types), Query, Filter, Validation, Hook, Datasource, Dataset, Analytics, Document, Storage Name Mapping (`tableName`/`columnName`), Feed & Activity Timeline (FeedItem, Comment, Mention, Reaction, FieldChange), Record Subscription (notification channels)
150150
- [x] **Driver Specifications** — Memory, PostgreSQL, MongoDB driver schemas + SQL/NoSQL abstractions
151-
- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page (16 types), Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n, Content Elements, Enhanced Activity Timeline (`RecordActivityProps` unified timeline, `RecordChatterProps` sidebar/drawer)
151+
- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page (16 types), Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n, Content Elements, Enhanced Activity Timeline (`RecordActivityProps` unified timeline, `RecordChatterProps` sidebar/drawer), Unified Navigation Protocol (`NavigationItem` as single source of truth with 7 types: object/dashboard/page/url/report/action/group; `NavigationArea` for business domain partitioning; `order`/`badge`/`requiredPermissions` on all nav items)
152152
- [x] **System Protocol** — Manifest, Auth Config, Cache, Logging, Metrics, Tracing, Audit, Encryption, Masking, Migration, Tenant, Translation, Search Engine, HTTP Server, Worker, Job, Object Storage, Notification, Message Queue, Registry Config, Collaboration, Compliance, Change Management, Disaster Recovery, License, Security Context, Core Services, SystemObjectName/SystemFieldName Constants, StorageNameMapping Utilities
153153
- [x] **Automation Protocol** — Flow (autolaunched/screen/schedule), Workflow, State Machine, Trigger Registry, Approval, ETL, Sync, Webhook
154154
- [x] **AI Protocol** — Agent, Agent Action, Conversation, Cost, MCP, Model Registry, NLQ, Orchestration, Predictive, RAG Pipeline, Runtime Ops, Feedback Loop, DevOps Agent, Plugin Development

packages/spec/src/ui/app.test.ts

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ import {
33
AppSchema,
44
AppBrandingSchema,
55
NavigationItemSchema,
6+
NavigationAreaSchema,
67
ObjectNavItemSchema,
78
DashboardNavItemSchema,
89
PageNavItemSchema,
910
UrlNavItemSchema,
11+
ReportNavItemSchema,
12+
ActionNavItemSchema,
1013
GroupNavItemSchema,
1114
defineApp,
1215
type App,
1316
type NavigationItem,
17+
type NavigationArea,
1418
} from './app.zod';
1519

1620
describe('AppBrandingSchema', () => {
@@ -664,3 +668,317 @@ describe('AppSchema sharing and embed fields', () => {
664668
expect(app.navigation).toHaveLength(1);
665669
});
666670
});
671+
672+
describe('BaseNavItem new fields (order, badge, requiredPermissions)', () => {
673+
it('should accept nav item with order', () => {
674+
const item = NavigationItemSchema.parse({
675+
id: 'nav_home',
676+
label: 'Home',
677+
type: 'dashboard',
678+
dashboardName: 'home',
679+
order: 1,
680+
});
681+
expect(item.order).toBe(1);
682+
});
683+
684+
it('should accept nav item with string badge', () => {
685+
const item = NavigationItemSchema.parse({
686+
id: 'nav_inbox',
687+
label: 'Inbox',
688+
type: 'object',
689+
objectName: 'message',
690+
badge: 'New',
691+
});
692+
expect(item.badge).toBe('New');
693+
});
694+
695+
it('should accept nav item with numeric badge', () => {
696+
const item = NavigationItemSchema.parse({
697+
id: 'nav_tasks',
698+
label: 'Tasks',
699+
type: 'object',
700+
objectName: 'task',
701+
badge: 5,
702+
});
703+
expect(item.badge).toBe(5);
704+
});
705+
706+
it('should accept nav item with requiredPermissions', () => {
707+
const item = NavigationItemSchema.parse({
708+
id: 'nav_admin',
709+
label: 'Admin Panel',
710+
type: 'page',
711+
pageName: 'admin_panel',
712+
requiredPermissions: ['admin.access', 'system.manage'],
713+
});
714+
expect(item.requiredPermissions).toEqual(['admin.access', 'system.manage']);
715+
});
716+
717+
it('should accept nav item with all new fields combined', () => {
718+
const item = NavigationItemSchema.parse({
719+
id: 'nav_reports',
720+
label: 'Reports',
721+
type: 'page',
722+
pageName: 'reports_page',
723+
order: 10,
724+
badge: 3,
725+
visible: 'user.is_admin',
726+
requiredPermissions: ['reports.view'],
727+
});
728+
expect(item.order).toBe(10);
729+
expect(item.badge).toBe(3);
730+
expect(item.requiredPermissions).toEqual(['reports.view']);
731+
});
732+
733+
it('should still accept nav items without new fields (backward compatibility)', () => {
734+
const item = NavigationItemSchema.parse({
735+
id: 'nav_accounts',
736+
label: 'Accounts',
737+
type: 'object',
738+
objectName: 'account',
739+
});
740+
expect(item.order).toBeUndefined();
741+
expect(item.badge).toBeUndefined();
742+
expect(item.requiredPermissions).toBeUndefined();
743+
});
744+
});
745+
746+
describe('ReportNavItemSchema', () => {
747+
it('should accept report nav item', () => {
748+
const navItem = {
749+
id: 'nav_sales_report',
750+
label: 'Sales Report',
751+
icon: 'file-text',
752+
type: 'report' as const,
753+
reportName: 'quarterly_sales',
754+
};
755+
756+
const result = ReportNavItemSchema.parse(navItem);
757+
expect(result.type).toBe('report');
758+
expect(result.reportName).toBe('quarterly_sales');
759+
});
760+
761+
it('should accept report nav item via NavigationItemSchema', () => {
762+
const item = NavigationItemSchema.parse({
763+
id: 'nav_pipeline_report',
764+
label: 'Pipeline Report',
765+
type: 'report',
766+
reportName: 'pipeline_overview',
767+
order: 5,
768+
});
769+
expect(item.type).toBe('report');
770+
expect(item.reportName).toBe('pipeline_overview');
771+
});
772+
773+
it('should reject report nav item without reportName', () => {
774+
expect(() => ReportNavItemSchema.parse({
775+
id: 'nav_bad_report',
776+
label: 'Bad Report',
777+
type: 'report',
778+
})).toThrow();
779+
});
780+
});
781+
782+
describe('ActionNavItemSchema', () => {
783+
it('should accept action nav item', () => {
784+
const navItem = {
785+
id: 'nav_new_lead',
786+
label: 'New Lead',
787+
icon: 'plus',
788+
type: 'action' as const,
789+
actionDef: {
790+
actionName: 'create_lead',
791+
},
792+
};
793+
794+
const result = ActionNavItemSchema.parse(navItem);
795+
expect(result.type).toBe('action');
796+
expect(result.actionDef.actionName).toBe('create_lead');
797+
});
798+
799+
it('should accept action nav item with params', () => {
800+
const result = ActionNavItemSchema.parse({
801+
id: 'nav_run_flow',
802+
label: 'Run Flow',
803+
type: 'action',
804+
actionDef: {
805+
actionName: 'launch_approval_flow',
806+
params: { recordType: 'contract', priority: 'high' },
807+
},
808+
});
809+
expect(result.actionDef.params).toEqual({ recordType: 'contract', priority: 'high' });
810+
});
811+
812+
it('should accept action nav item via NavigationItemSchema', () => {
813+
const item = NavigationItemSchema.parse({
814+
id: 'nav_export',
815+
label: 'Export Data',
816+
type: 'action',
817+
actionDef: { actionName: 'export_csv' },
818+
badge: 'New',
819+
});
820+
expect(item.type).toBe('action');
821+
expect(item.actionDef.actionName).toBe('export_csv');
822+
});
823+
824+
it('should reject action nav item without actionDef', () => {
825+
expect(() => ActionNavItemSchema.parse({
826+
id: 'nav_bad_action',
827+
label: 'Bad Action',
828+
type: 'action',
829+
})).toThrow();
830+
});
831+
});
832+
833+
describe('NavigationAreaSchema', () => {
834+
it('should accept minimal area', () => {
835+
const area = NavigationAreaSchema.parse({
836+
id: 'area_sales',
837+
label: 'Sales',
838+
navigation: [],
839+
});
840+
expect(area.id).toBe('area_sales');
841+
expect(area.navigation).toEqual([]);
842+
});
843+
844+
it('should accept area with full properties', () => {
845+
const area = NavigationAreaSchema.parse({
846+
id: 'area_service',
847+
label: 'Service',
848+
icon: 'headset',
849+
order: 2,
850+
description: 'Customer service management',
851+
visible: 'user.has_permission("service.access")',
852+
requiredPermissions: ['service.access'],
853+
navigation: [
854+
{ id: 'nav_cases', type: 'object', label: 'Cases', objectName: 'case' },
855+
{ id: 'nav_knowledge', type: 'page', label: 'Knowledge Base', pageName: 'knowledge_base' },
856+
],
857+
});
858+
expect(area.id).toBe('area_service');
859+
expect(area.icon).toBe('headset');
860+
expect(area.order).toBe(2);
861+
expect(area.requiredPermissions).toEqual(['service.access']);
862+
expect(area.navigation).toHaveLength(2);
863+
});
864+
865+
it('should enforce snake_case for area id', () => {
866+
expect(() => NavigationAreaSchema.parse({
867+
id: 'AreaSales',
868+
label: 'Sales',
869+
navigation: [],
870+
})).toThrow();
871+
});
872+
});
873+
874+
describe('AppSchema with areas', () => {
875+
it('should accept app with areas', () => {
876+
const app = AppSchema.parse({
877+
name: 'enterprise_crm',
878+
label: 'Enterprise CRM',
879+
areas: [
880+
{
881+
id: 'area_sales',
882+
label: 'Sales',
883+
icon: 'briefcase',
884+
order: 1,
885+
navigation: [
886+
{ id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' },
887+
{ id: 'nav_opportunities', type: 'object', label: 'Opportunities', objectName: 'opportunity' },
888+
],
889+
},
890+
{
891+
id: 'area_service',
892+
label: 'Service',
893+
icon: 'headset',
894+
order: 2,
895+
navigation: [
896+
{ id: 'nav_cases', type: 'object', label: 'Cases', objectName: 'case' },
897+
],
898+
},
899+
{
900+
id: 'area_settings',
901+
label: 'Settings',
902+
icon: 'settings',
903+
order: 99,
904+
requiredPermissions: ['admin.access'],
905+
navigation: [
906+
{ id: 'nav_users', type: 'object', label: 'Users', objectName: 'user' },
907+
],
908+
},
909+
],
910+
});
911+
912+
expect(app.areas).toHaveLength(3);
913+
expect(app.areas![0].id).toBe('area_sales');
914+
expect(app.areas![0].navigation).toHaveLength(2);
915+
expect(app.areas![2].requiredPermissions).toEqual(['admin.access']);
916+
});
917+
918+
it('should accept app with both navigation and areas (backward compatibility)', () => {
919+
const app = AppSchema.parse({
920+
name: 'hybrid_app',
921+
label: 'Hybrid App',
922+
navigation: [
923+
{ id: 'nav_home', type: 'dashboard', label: 'Home', dashboardName: 'home' },
924+
],
925+
areas: [
926+
{
927+
id: 'area_main',
928+
label: 'Main',
929+
navigation: [
930+
{ id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'account' },
931+
],
932+
},
933+
],
934+
});
935+
expect(app.navigation).toHaveLength(1);
936+
expect(app.areas).toHaveLength(1);
937+
});
938+
939+
it('should accept app without areas (backward compatibility)', () => {
940+
const app = AppSchema.parse({
941+
name: 'simple_app',
942+
label: 'Simple App',
943+
navigation: [
944+
{ id: 'nav_home', type: 'object', label: 'Home', objectName: 'account' },
945+
],
946+
});
947+
expect(app.areas).toBeUndefined();
948+
expect(app.navigation).toHaveLength(1);
949+
});
950+
951+
it('should accept enterprise app with areas containing all nav item types', () => {
952+
const app = AppSchema.parse({
953+
name: 'full_enterprise',
954+
label: 'Full Enterprise',
955+
areas: [
956+
{
957+
id: 'area_main',
958+
label: 'Main',
959+
order: 1,
960+
navigation: [
961+
{ id: 'nav_home', type: 'dashboard', label: 'Home', dashboardName: 'home' },
962+
{ id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'account', order: 1 },
963+
{ id: 'nav_custom', type: 'page', label: 'Custom Page', pageName: 'custom' },
964+
{ id: 'nav_report', type: 'report', label: 'Sales Report', reportName: 'quarterly_sales' },
965+
{ id: 'nav_action', type: 'action', label: 'New Lead', actionDef: { actionName: 'create_lead' } },
966+
{ id: 'nav_docs', type: 'url', label: 'Docs', url: 'https://docs.example.com', target: '_blank' },
967+
{
968+
id: 'grp_nested',
969+
type: 'group',
970+
label: 'Nested',
971+
expanded: true,
972+
children: [
973+
{ id: 'nav_nested_item', type: 'object', label: 'Nested Item', objectName: 'nested', badge: 3 },
974+
],
975+
},
976+
],
977+
},
978+
],
979+
});
980+
981+
expect(app.areas).toHaveLength(1);
982+
expect(app.areas![0].navigation).toHaveLength(7);
983+
});
984+
});

0 commit comments

Comments
 (0)