Skip to content
Merged
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
46 changes: 46 additions & 0 deletions .changeset/7469-app-actions-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@object-ui/types': minor
'@object-ui/runner': minor
---

feat(types,runner)!: the app node's `actions` array, `AppAction` and `AppActionSchema` are retired; app-level actions are `navigation` items of `type: 'action'`

⚠️ Breaking, marked `minor` under this repo's version-alignment rule (a `major`
in the fixed group would move all of it off the `@objectstack` major).

**`@object-ui/types`.** An `app` node that authors `actions` now FAILS to
validate, with a named refusal at `actions` that points at the replacement, and
a TypeScript literal typed as `AppComponentSchema` that sets `actions` no longer
compiles. The `AppAction` type and the `AppActionSchema` mirror are no longer
exported. `AppMenuItem` and its mirror stay, because the legacy `menu` still
uses them.

The array was an objectui-only shape: free-form header buttons and a user
avatar menu. `@objectstack/spec`'s `AppSchema` is strict and never declared the
key, so the same app document parsed green here and was refused by the
platform. The console loads apps only from the platform, so it could never
receive the array. Only the standalone runner drew it, and its buttons declared
no behaviour to run. The maintainer's ruling (objectui#7469, option C) keeps
one channel for app-level actions:

```ts
navigation: [
{ id: 'quick_create', type: 'action', label: 'Quick Create', actionDef: { actionName: 'quick_create' } },
]
```

The console sidebar dispatches that item by action name. The signed-in user's
menu belongs to the host shell, not to app metadata. `actions` stays declared
as a `?: never` / `retirementTombstone()` pair (ADR-0049), because
`BaseSchema`'s `.passthrough()` would otherwise keep an authored array in
silence.

**`@object-ui/runner`.** The header no longer reads the app's `actions`. It
draws no toolbar button per `'button'` entry and no avatar menu per `'user'`
entry. The notification Bell is now always drawn. Before, it was hidden when a
`'button'` action was authored.

Changeset entries from objectui#6854, objectui#7344, objectui#7719 and
objectui#7721 describe `AppAction` as it stood before this retirement. The
`shortcut` refusal from objectui#7719 still applies to the legacy `menu` items. Pinned in `packages/types/src/__tests__/app-actions-retired-7469.test.ts`
and `packages/runner/src/__tests__/LayoutRenderer.chrome-7469.test.tsx`.
104 changes: 38 additions & 66 deletions content/docs/core/app-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import { SchemaExample } from '@/app/components/ComponentDemo';

# Application Schema

The `AppComponentSchema` defines the top-level configuration for your entire ObjectUI application, including navigation menus, branding, layout strategies, and global actions.
The `AppComponentSchema` defines the top-level configuration for your entire ObjectUI application, including navigation menus, branding, layout strategies, and app-level actions.

## Overview

AppComponentSchema provides a declarative way to configure:
- **Navigation menus** - Hierarchical menu structures with icons and badges
- **Branding** - Logo, title, favicon
- **Layout strategies** - Sidebar, header, or empty layout
- **Global actions** - User menu, global toolbar buttons
- **App-level actions** - `navigation` items of type `action` that run a declared action

## Interactive Examples

Expand Down Expand Up @@ -47,14 +47,6 @@ const app: AppComponentSchema = {
icon: 'layout-dashboard',
path: '/dashboard'
}
],

actions: [
{
type: 'user',
label: 'John Doe',
avatar: '/avatar.jpg'
}
]
};
```
Expand Down Expand Up @@ -139,28 +131,47 @@ interface AppMenuItem {

### Global Actions

The `actions` property defines global toolbar buttons:
App-level actions — a "Quick Create" or "Log a Call" entry that is not tied to
one record — are `navigation` items of type `action`. The item names an action
declared in your metadata by `actionDef.actionName`; it does not carry the
action's definition. This is the same item shape `@objectstack/spec` accepts in
an app's `navigation`, so the document validates on both sides:

```ts
// `AppAction.items` uses the navigation-item shape documented under "Navigation
// Menu" above — declared and published as `AppMenuItem`. The bare `MenuItem`
// export is the unrelated overlay menu type used by `ui:dropdown-menu`.
import type { AppMenuItem } from '@object-ui/types';
import type { AppComponentSchema } from '@object-ui/types';

interface AppAction {
type: 'button' | 'dropdown' | 'user';
label?: string;
icon?: string;
onClick?: never; // RETIRED (objectui#7344): the handler-expression string is refused by name — author an action:button node instead
avatar?: string; // For type='user'
description?: string; // For type='user'
items?: AppMenuItem[]; // For type='dropdown' or 'user'
shortcut?: string; // Keyboard shortcut
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
}
const app: AppComponentSchema = {
type: 'app',
name: 'acme_crm',
title: 'Acme CRM',
navigation: [
{
id: 'quick_create',
type: 'action',
label: 'Quick Create',
icon: 'zap',
actionDef: { actionName: 'quick_create' }
}
]
};
```

The console renders the item in the app's sidebar. On click it looks up the
action named in `actionDef.actionName` among the declared `action` metadata and
runs it through the same action runner as a toolbar button, so confirmations,
parameter dialogs and permission checks apply. `actionDef.params` passes
arguments to that action. An item that names no declared action shows an error
when clicked instead of doing nothing. The standalone runner renders only the
legacy `menu` and does not render `navigation` items.

<Callout type="warn">
The free-form `actions` array (`AppAction`: header buttons and a user avatar
menu) is retired, and an app that authors `actions` fails validation with a
message pointing here (objectui#7469). `@objectstack/spec`'s `AppSchema` never
declared the key, so the platform rejected those apps too. The signed-in user's
menu belongs to the host shell, not to app metadata.
</Callout>

## Complete Example

```ts
Expand Down Expand Up @@ -238,44 +249,6 @@ const crm: AppComponentSchema = {
path: '/settings',
hidden: '${user.role !== "admin"}'
}
],

actions: [
{
type: 'button',
label: 'Quick Actions',
icon: 'zap',
variant: 'outline'
},
{
type: 'user',
label: 'John Doe',
avatar: '/avatars/john.jpg',
description: 'john@acme.com',
items: [
{
type: 'item',
label: 'Profile',
icon: 'user',
path: '/profile'
},
{
type: 'item',
label: 'Settings',
icon: 'settings',
path: '/settings'
},
{
type: 'separator'
},
{
type: 'item',
label: 'Logout',
icon: 'log-out',
path: '/logout'
}
]
}
]
};
```
Expand Down Expand Up @@ -332,7 +305,6 @@ AppComponentSchema is ideal for:
3. **Limit top-level items** - Keep the main menu concise (5-7 items)
4. **Add badges for notifications** - Show counts or status indicators
5. **Hide admin features** - Use conditional visibility for role-based access
6. **Provide keyboard shortcuts** - Add shortcuts for frequently used actions

## Related

Expand Down
16 changes: 2 additions & 14 deletions content/docs/guide/schema-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ ObjectUI includes enterprise-grade capabilities to build production-ready applic
#### [App Schema](/docs/core/app-schema)
Define your entire application structure with navigation, branding, and global settings.

<!-- doc-snippet: fragment — a shape excerpt: `menu` and `actions` are written as a literal `[...]` ellipsis because the section is about the app schema's top-level keys, not about a menu -->
<!-- doc-snippet: fragment — a shape excerpt: `menu` and `navigation` are written as a literal `[...]` ellipsis because the section is about the app schema's top-level keys, not about a menu -->

```typescript
const app: AppComponentSchema = {
type: 'app',
title: 'My Application',
layout: 'sidebar',
menu: [...],
actions: [...]
navigation: [...]
};
```

Expand Down Expand Up @@ -232,25 +232,13 @@ const app: AppComponentSchema = {
{ type: 'item', label: 'Deals', path: '/deals' }
]
}
],

actions: [
{
type: 'user',
label: 'User Name',
items: [
{ type: 'item', label: 'Profile', path: '/profile' },
{ type: 'item', label: 'Logout', path: '/logout' }
]
}
]
};
```

This creates a professional-looking CRM application with:
- A sidebar layout with navigation menu
- Sales section with leads and deals
- User menu with profile and logout options

Theming is configured separately, as a theme document handed to `ThemeProvider` —
see [Theme Schema](/docs/core/theme-schema).
Expand Down
121 changes: 15 additions & 106 deletions packages/runner/src/LayoutRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,6 @@ import {
Sun,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Avatar,
AvatarImage,
AvatarFallback,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Expand Down Expand Up @@ -236,102 +226,21 @@ export const LayoutRenderer = ({ app, children, currentPath, onNavigate }: Layou
{theme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>

{/* Global Actions */}
{app.actions?.filter(a => a.type === 'button').map((action, i) => {
const Icon = action.icon ? getIcon(action.icon) : null;
return (
<button type="button"
key={i}
className={action.variant === 'ghost' ? "relative p-2 text-muted-foreground hover:text-foreground transition-colors hover:bg-muted rounded-md" : "p-2"}
title={action.label}
>
{Icon && <Icon className="h-5 w-5" />}
{action.label && !action.icon && <span>{action.label}</span>}
</button>
);
})}

{/* Fallback Bell if no actions defined, or keep it as specific logic?
The original code hardcoded a Bell button.
The app.json defines a 'Bell' button action.
So I should iterate app.actions for buttons as well.
*/}

{/* Original Bell Logic (Hardcoded in user request? No, it was hardcoded in my previous edit, but app.json has it too)
Let's check app.json. It has:
{ "type": "button", "variant": "ghost", "size": "icon", "icon": "Bell" }

If I render actions generically, I don't need the hardcoded Bell.
*/}

{(!app.actions || !app.actions.some(a => a.type === 'button')) && (
<button type="button" className="relative p-2 text-muted-foreground hover:text-foreground transition-colors">
<Bell className="h-5 w-5" />
<span className="absolute top-1.5 right-1.5 h-2 w-2 bg-red-600 rounded-full border-2 border-background"></span>
</button>
)}

{app.actions?.filter(a => a.type === 'user').map((userAction, i) => (
<DropdownMenu key={i}>
<DropdownMenuTrigger asChild>
<button type="button" className="relative h-8 w-8 rounded-full border bg-muted overflow-hidden focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 hover:opacity-90 transition-opacity">
<Avatar className="h-full w-full">
<AvatarImage
src={userAction.avatar}
alt={userAction.label || 'User'}
/>
<AvatarFallback>
{userAction.label?.substring(0, 2).toUpperCase() || 'JD'}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{userAction.label || 'User'}</p>
<p className="text-xs leading-none text-muted-foreground">
{userAction.description || 'user@example.com'}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{/*
* Renders `AppAction.items` from its DECLARED type and nothing else
* (objectui#6854, maintainer ruling of 2026-09-05, option B2).
*
* `items` is `AppMenuItem[]` (`@object-ui/types` `app.ts`), and the zod
* mirror parses it with the legacy `MenuItemSchema`. Neither makes
* `onClick` or `shortcut` AUTHORABLE; this map used to reach both
* through `as any`, i.e. past the type it was handed. The `onClick` read
* is also what made the retirement refusal's own sentence — "no renderer
* reads this key, so nothing could ever run it" — false. `type` and
* `label` ARE declared on `AppMenuItem` and stay.
*
* `shortcut` is SETTLED, and the answer left this map alone
* (objectui#7719, director seat decision batch #70 of 2026-09-07): it
* does not become authorable on `AppAction.items`. What changed is the
* DIAGNOSTIC on the types side — `shortcut?: never` on `AppMenuItem`
* and a named refusal on `MenuItemSchema`, so an authored value is
* refused instead of stripped in silence. A keyboard shortcut on a
* navigation entry is a capability of the `NavigationItem` line.
* ⛔ No read is re-added here; that is the ruling, not an open question.
*/}
{userAction.items?.map((item, idx) => {
if (item.type === 'separator') {
return <DropdownMenuSeparator key={idx} />;
}
return (
<DropdownMenuItem key={idx}>
{item.label}
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
))}
{/*
* The Bell is unconditional (objectui#7469, maintainer ruling C). It used
* to be a fallback drawn only when the app's `actions` array authored no
* `'button'` entry; that free-form array and its `'user'` avatar menu are
* retired on both faces of `@object-ui/types`, so this chrome reads no
* app metadata here any more and keeps one rule. The contract's one
* channel for app-level actions is a `navigation` item of
* `type: 'action'`, which the console sidebar dispatches; this runner
* draws the legacy `menu` only and renders no `navigation` item. Pinned
* in `__tests__/LayoutRenderer.chrome-7469.test.tsx`.
*/}
<button type="button" className="relative p-2 text-muted-foreground hover:text-foreground transition-colors">
<Bell className="h-5 w-5" />
<span className="absolute top-1.5 right-1.5 h-2 w-2 bg-red-600 rounded-full border-2 border-background"></span>
</button>
</div>
</header>

Expand Down
Loading
Loading