Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions content/docs/ui/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ rejected — a divider has nothing to label, gate, or badge.)
| `id` | `string` | Unique identifier (`snake_case`, required) |
| `label` | `string` | Display label |
| `icon` | `string` | Icon name (Lucide) |
| `order` | `number` | Sort order within the same level (lower = first) |
| `order` | `number` | Sort order within the same level (lower = first); entries [contributed by another package](#contributing-navigation-to-another-app) sort in the same level |
| `badge` | `string \| number` | Badge text or count displayed on the item |
| `visible` | `Expression` | Visibility predicate (CEL expression) |
| `requiredPermissions` | `string[]` | Permissions required to see/access this item |
Expand Down Expand Up @@ -844,7 +844,7 @@ navigationContributions: [
group: 'group_integrations',
priority: 100,
items: [
{ id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', requiresObject: 'sys_webhook' },
{ id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', requiresObject: 'sys_webhook', order: 30 },
],
},
]
Expand All @@ -860,6 +860,17 @@ navigationContributions: [
The runtime merges every contribution into the target app's `navigation` by group
id and priority when the app is read, so registration order does not matter.

**A contributed entry's own `order` places it among the target app's entries.**
The merge appends each contribution's items to the end of their group (or of the
top level) and keeps every item as written, `order` included. The console then
sorts each level by `order`, as it does for any app: the top level, each group's
children, and the mobile tab bar. So an item with `order: 25` is drawn between the
app's own `order: 20` and `order: 30` entries. An entry with no `order` sorts as
`0`, and entries with equal `order` keep their merged sequence, so `priority`
decides only between contributions whose items have the same `order`. This needs
the target app to number its own entries: when it numbers none, every entry sorts
as `0` and a contribution stays after the app's own entries.

**A group id the target app does not declare is not refused.** The items are
appended at the app's top level and a `nav_contribution_group_missing` diagnostic
is raised, at `warn` by the runtime and as a warning by `os build` and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,3 +493,123 @@ describe('#14454 item 2 — `navigationContributions[].group` semantics across a
expect(second.navigation).toEqual(first.navigation);
});
});

/**
* A contributed entry's `order` is how a module places it among the target
* app's own entries, and the fold is what has to leave it alone.
*
* The console sorts each level of the served tree by `order` after the fold
* (`byNavOrder` in `ui/packages/layout/src/navOrder.ts`, pinned by that
* package's own tests), so a contributed `order: 25` is drawn between the
* host's `20` and `30`. That holds only while `applyNavContributions` hands the
* item on as authored: it appends, it does not sort the level, and it neither
* writes, rewrites nor drops `order`. These cases pin that half, in a group and
* at the top level. They do not re-test the console's comparator.
*/
describe('navigationContributions — the fold keeps a contributed entry\'s `order` as authored', () => {
type OrderedItem = NavItem & { order?: number };

/** A host that numbers its own entries, the precondition for interleaving. */
const numberedHost = () => ({
id: 'com.acme.crm',
name: 'acme_crm',
version: '1.0.0',
type: 'app',
namespace: 'crm',
objects: [
{ name: 'crm_account', label: 'Account', fields: { name: { name: 'name', label: 'Name', type: 'text' } } },
],
apps: [{
name: 'crm_app',
label: 'CRM',
navigation: [
{ id: 'nav_home', type: 'object', objectName: 'crm_account', label: 'Home', order: 10 },
{
id: 'sales_group',
type: 'group',
label: 'Sales',
order: 20,
children: [
{ id: 'nav_accounts', type: 'object', objectName: 'crm_account', label: 'Accounts', order: 10 },
{ id: 'nav_contacts', type: 'object', objectName: 'crm_account', label: 'Contacts', order: 30 },
],
},
{ id: 'nav_reports', type: 'object', objectName: 'crm_account', label: 'Reports', order: 30 },
],
}],
});

/** A module contributing one item, with or without its own `order`. */
const orderedContributor = (short: string, group: string | undefined, order: number | undefined) => ({
id: `com.acme.crm.${short}`,
name: `acme_crm_${short}`,
version: '1.0.0',
type: 'module',
namespace: 'crm',
objects: [
{ name: `crm_${short}`, label: short, fields: { note: { name: 'note', label: 'Note', type: 'text' } } },
],
navigationContributions: [{
app: 'crm_app',
...(group === undefined ? {} : { group }),
items: [{
id: `nav_${short}`, type: 'object', objectName: `crm_${short}`, label: short,
...(order === undefined ? {} : { order }),
}],
}],
});

const foldedNav = async (...contributors: unknown[]): Promise<OrderedItem[]> => {
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
await kernel.use(new ObjectQLPlugin());
await kernel.bootstrap();
kernels.push(kernel);
await (kernel.getService('manifest') as ManifestService).register(artifactOf(numberedHost(), ...contributors));
const app = engineOf(kernel).registry.getApp('crm_app') as { navigation?: OrderedItem[] };
return app.navigation ?? [];
};

/** `[id, order]` pairs in the fold's own sequence, so position and value are read together. */
const placed = (items: OrderedItem[] | undefined) =>
(items ?? []).map((i) => [i.id, i.order] as const);

it('in a group: the item lands after the group\'s own children with its `order` untouched', async () => {
const nav = await foldedNav(orderedContributor('cpq', 'sales_group', 20));

// Appended, not sorted in: 20 sits after 30. The console's sort is what
// draws it between `nav_accounts` (10) and `nav_contacts` (30).
expect(placed(groupOf(nav, 'sales_group')?.children)).toEqual([
['nav_accounts', 10],
['nav_contacts', 30],
['nav_cpq', 20],
]);
});

it('at the top level: the item lands after the app\'s own entries with its `order` untouched', async () => {
const nav = await foldedNav(orderedContributor('forecast', undefined, 25));

expect(placed(nav)).toEqual([
['nav_home', 10],
['sales_group', 20],
['nav_reports', 30],
['nav_forecast', 25],
]);
});

it('an item with no `order` gets none from the fold, in a group or at the top level', async () => {
// The console reads a missing `order` as 0. If the fold wrote a number in,
// the item would move; if it wrote 0, a host that numbers nothing would be
// indistinguishable from one that does. Absent stays absent.
const nav = await foldedNav(
orderedContributor('cpq', 'sales_group', undefined),
orderedContributor('forecast', undefined, undefined),
);

const cpq = groupOf(nav, 'sales_group')?.children?.find((i) => i.id === 'nav_cpq');
const forecast = nav.find((i) => i.id === 'nav_forecast');
expect(cpq).toBeDefined();
expect(forecast).toBeDefined();
expect(Object.prototype.hasOwnProperty.call(cpq, 'order')).toBe(false);
expect(Object.prototype.hasOwnProperty.call(forecast, 'order')).toBe(false);
});
});
Loading