Skip to content

Commit 742ec51

Browse files
Copilothotlong
andcommitted
Add interfaces[], defaultInterface, icon, and group fields to schemas
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 8574c79 commit 742ec51

4 files changed

Lines changed: 236 additions & 14 deletions

File tree

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,3 +605,79 @@ describe('defineApp', () => {
605605
})).toThrow();
606606
});
607607
});
608+
609+
describe('AppSchema with interfaces[] field', () => {
610+
it('should accept app with interfaces array', () => {
611+
const app = AppSchema.parse({
612+
name: 'sales_app',
613+
label: 'Sales App',
614+
interfaces: ['sales_workspace', 'lead_review', 'sales_analytics'],
615+
});
616+
617+
expect(app.interfaces).toHaveLength(3);
618+
expect(app.interfaces).toEqual(['sales_workspace', 'lead_review', 'sales_analytics']);
619+
});
620+
621+
it('should accept app with defaultInterface', () => {
622+
const app = AppSchema.parse({
623+
name: 'crm_app',
624+
label: 'CRM',
625+
interfaces: ['sales_workspace', 'lead_review'],
626+
defaultInterface: 'sales_workspace',
627+
});
628+
629+
expect(app.defaultInterface).toBe('sales_workspace');
630+
});
631+
632+
it('should accept app with both interfaces and navigation', () => {
633+
const app = AppSchema.parse({
634+
name: 'modern_app',
635+
label: 'Modern App',
636+
interfaces: ['main_workspace', 'analytics'],
637+
defaultInterface: 'main_workspace',
638+
navigation: [
639+
{ id: 'nav_settings', label: 'Settings', type: 'page', pageName: 'admin_settings' },
640+
{ id: 'nav_help', label: 'Help', type: 'url', url: 'https://help.example.com' },
641+
],
642+
});
643+
644+
expect(app.interfaces).toHaveLength(2);
645+
expect(app.navigation).toHaveLength(2);
646+
expect(app.defaultInterface).toBe('main_workspace');
647+
});
648+
649+
it('should accept app without interfaces (backward compatibility)', () => {
650+
const app = AppSchema.parse({
651+
name: 'legacy_app',
652+
label: 'Legacy App',
653+
navigation: [
654+
{ id: 'nav_accounts', label: 'Accounts', type: 'object', objectName: 'account' },
655+
],
656+
});
657+
658+
expect(app.interfaces).toBeUndefined();
659+
expect(app.navigation).toHaveLength(1);
660+
});
661+
662+
it('should accept empty interfaces array', () => {
663+
const app = AppSchema.parse({
664+
name: 'empty_app',
665+
label: 'Empty App',
666+
interfaces: [],
667+
});
668+
669+
expect(app.interfaces).toHaveLength(0);
670+
});
671+
672+
it('should accept defaultInterface without interfaces array', () => {
673+
// This is technically allowed even though it may not be meaningful
674+
const app = AppSchema.parse({
675+
name: 'test_app',
676+
label: 'Test App',
677+
defaultInterface: 'some_interface',
678+
});
679+
680+
expect(app.defaultInterface).toBe('some_interface');
681+
expect(app.interfaces).toBeUndefined();
682+
});
683+
});

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

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ export const UrlNavItemSchema = BaseNavItemSchema.extend({
8181
* 5. Interface Navigation Item
8282
* Navigates to a specific Interface (self-contained multi-page surface).
8383
* Bridges AppSchema (navigation container) with InterfaceSchema (content surface).
84+
*
85+
* **Note:** While this schema remains for backward compatibility, the preferred pattern
86+
* is now to use `AppSchema.interfaces[]` to declare interfaces, which automatically
87+
* generates a two-level Interface→Pages sidebar menu. This navigation item type is
88+
* retained for explicit interface navigation and global utility entries.
8489
*/
8590
export const InterfaceNavItemSchema = BaseNavItemSchema.extend({
8691
type: z.literal('interface'),
@@ -152,16 +157,21 @@ export const AppBrandingSchema = z.object({
152157
* App Configuration Schema
153158
* Defines a business application container, including its navigation, branding, and permissions.
154159
*
155-
* @example CRM App
160+
* The new architecture makes App an "Interface switcher" where the sidebar content is
161+
* auto-generated from `interfaces[]`, rendering a two-level Interface→Pages menu. The
162+
* `navigation[]` field is retained for global utility entries only (Settings, Help,
163+
* external links) and is rendered at the bottom of the sidebar.
164+
*
165+
* @example CRM App with new Interface-driven pattern
156166
* {
157167
* name: "crm",
158168
* label: "Sales CRM",
159169
* icon: "briefcase",
170+
* interfaces: ["sales_workspace", "lead_review", "sales_analytics"],
171+
* defaultInterface: "sales_workspace",
160172
* navigation: [
161-
* { type: "object", id: "nav_leads", label: "Leads", objectName: "leads" },
162-
* { type: "object", id: "nav_deals", label: "Deals", objectName: "deals" }
163-
* ],
164-
* requiredPermissions: ["app.crm.access"]
173+
* { type: "page", id: "nav_settings", label: "Settings", pageName: "admin_settings" }
174+
* ]
165175
* }
166176
*/
167177
export const AppSchema = z.object({
@@ -190,10 +200,28 @@ export const AppSchema = z.object({
190200
isDefault: z.boolean().optional().default(false).describe('Is default app'),
191201

192202
/**
193-
* Navigation Tree Structure.
194-
* Replaces the old flat 'tabs' list with a structured menu.
203+
* Interface names registered in this App.
204+
* Sidebar renders as a two-level menu: Interface (collapsible group) → Pages (menu items).
205+
* The runtime auto-generates the sidebar navigation from these interfaces and their pages.
206+
*/
207+
interfaces: z.array(z.string()).optional()
208+
.describe('Interface names available in this App. Sidebar renders as Interface→Pages two-level menu.'),
209+
210+
/** Default interface to activate on App launch */
211+
defaultInterface: z.string().optional()
212+
.describe('Default interface to show when the App opens'),
213+
214+
/**
215+
* Navigation Tree Structure (Global Utility Entries Only).
216+
* This field is now repurposed for global utility navigation items only (Settings, Help,
217+
* external links, etc.) that are rendered at the bottom of the sidebar. The main sidebar
218+
* content is auto-generated from `interfaces[]`.
219+
*
220+
* For backward compatibility, this field remains optional and can still contain the full
221+
* navigation tree. However, the new recommended pattern is to use `interfaces[]` for
222+
* the main navigation and reserve this field for global utility entries.
195223
*/
196-
navigation: z.array(NavigationItemSchema).optional().describe('Structured navigation menu tree'),
224+
navigation: z.array(NavigationItemSchema).optional().describe('Global utility navigation items (Settings, Help, external links) — rendered at bottom of sidebar'),
197225

198226
/**
199227
* App-level Home Page Override
@@ -241,7 +269,20 @@ export const App = {
241269
*
242270
* Validates the config at creation time using Zod `.parse()`.
243271
*
244-
* @example
272+
* @example New Interface-driven pattern
273+
* ```ts
274+
* const crmApp = defineApp({
275+
* name: 'crm',
276+
* label: 'CRM',
277+
* interfaces: ['sales_workspace', 'lead_review', 'sales_analytics'],
278+
* defaultInterface: 'sales_workspace',
279+
* navigation: [
280+
* { id: 'nav_settings', label: 'Settings', type: 'page', pageName: 'settings' },
281+
* ],
282+
* });
283+
* ```
284+
*
285+
* @example Legacy navigation tree pattern (backward compatible)
245286
* ```ts
246287
* const crmApp = defineApp({
247288
* name: 'crm',

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,84 @@ describe('defineInterface', () => {
403403
});
404404
});
405405

406+
describe('InterfaceSchema with icon and group fields', () => {
407+
it('should accept interface with icon', () => {
408+
const iface = InterfaceSchema.parse({
409+
name: 'sales_workspace',
410+
label: 'Sales Workspace',
411+
icon: 'briefcase',
412+
pages: [],
413+
});
414+
415+
expect(iface.icon).toBe('briefcase');
416+
});
417+
418+
it('should accept interface with group', () => {
419+
const iface = InterfaceSchema.parse({
420+
name: 'service_portal',
421+
label: 'Service Portal',
422+
group: 'Service Cloud',
423+
pages: [],
424+
});
425+
426+
expect(iface.group).toBe('Service Cloud');
427+
});
428+
429+
it('should accept interface with both icon and group', () => {
430+
const iface = InterfaceSchema.parse({
431+
name: 'analytics_dashboard',
432+
label: 'Analytics Dashboard',
433+
icon: 'chart-bar',
434+
group: 'Analytics',
435+
pages: [],
436+
});
437+
438+
expect(iface.icon).toBe('chart-bar');
439+
expect(iface.group).toBe('Analytics');
440+
});
441+
442+
it('should accept interface without icon or group (backward compatibility)', () => {
443+
const iface = InterfaceSchema.parse({
444+
name: 'legacy_interface',
445+
label: 'Legacy Interface',
446+
pages: [],
447+
});
448+
449+
expect(iface.icon).toBeUndefined();
450+
expect(iface.group).toBeUndefined();
451+
});
452+
453+
it('should accept full interface with all new fields', () => {
454+
const iface = InterfaceSchema.parse({
455+
name: 'complete_interface',
456+
label: 'Complete Interface',
457+
description: 'Full-featured interface',
458+
icon: 'layout-dashboard',
459+
group: 'Sales Cloud',
460+
object: 'opportunity',
461+
pages: [
462+
{
463+
name: 'page_dashboard',
464+
label: 'Dashboard',
465+
type: 'dashboard',
466+
regions: [],
467+
},
468+
],
469+
homePageName: 'page_dashboard',
470+
branding: {
471+
primaryColor: '#1A73E8',
472+
},
473+
assignedRoles: ['sales_rep'],
474+
isDefault: true,
475+
});
476+
477+
expect(iface.icon).toBe('layout-dashboard');
478+
expect(iface.group).toBe('Sales Cloud');
479+
expect(iface.object).toBe('opportunity');
480+
expect(iface.pages).toHaveLength(1);
481+
});
482+
});
483+
406484
// ---------------------------------------------------------------------------
407485
// Content Elements in PageComponentType
408486
// ---------------------------------------------------------------------------

packages/spec/src/ui/interface.zod.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,38 @@ export const InterfaceBrandingSchema = AppBrandingSchema.extend({
2323
* an Interface is a focused, role-specific surface that stitches together
2424
* views, elements, and actions into a cohesive experience.
2525
*
26-
* An App can contain multiple Interfaces.
27-
*
28-
* Pages within an Interface use the unified `PageSchema` with interface page types
29-
* (dashboard, grid, kanban, record_review, etc.).
26+
* An App can contain multiple Interfaces. When used with `AppSchema.interfaces[]`,
27+
* the interface's `icon` and `group` properties control sidebar rendering.
3028
*
3129
* **NAMING CONVENTION:**
3230
* Interface names must be lowercase snake_case.
3331
*
34-
* @example
32+
* @example Sales Workspace Interface
33+
* ```ts
34+
* const salesInterface = defineInterface({
35+
* name: 'sales_workspace',
36+
* label: 'Sales Workspace',
37+
* icon: 'briefcase',
38+
* group: 'Sales Cloud',
39+
* object: 'opportunity',
40+
* pages: [
41+
* {
42+
* name: 'pipeline',
43+
* label: 'Pipeline',
44+
* type: 'kanban',
45+
* object: 'opportunity',
46+
* regions: [],
47+
* },
48+
* ],
49+
* });
50+
* ```
51+
*
52+
* @example Order Review Interface
3553
* ```ts
3654
* const reviewInterface = defineInterface({
3755
* name: 'order_review',
3856
* label: 'Order Review',
57+
* icon: 'clipboard-check',
3958
* object: 'order',
4059
* pages: [
4160
* {
@@ -61,6 +80,14 @@ export const InterfaceSchema = z.object({
6180
label: I18nLabelSchema.describe('Interface display label'),
6281
description: I18nLabelSchema.optional().describe('Interface purpose description'),
6382

83+
/** Icon for sidebar display in the App's Interface→Pages menu */
84+
icon: z.string().optional()
85+
.describe('Icon name for sidebar display (Lucide icon)'),
86+
87+
/** Business group for sidebar grouping (rendered as a section separator label) */
88+
group: z.string().optional()
89+
.describe('Business group label for sidebar grouping (e.g. "Sales Cloud", "Service Cloud")'),
90+
6491
/** Primary object binding */
6592
object: z.string().optional().describe('Primary object binding (snake_case)'),
6693

0 commit comments

Comments
 (0)