From 512088ddbfb325dbf56cdd79d1ee98f307fea51a Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Sat, 10 Oct 2026 01:10:59 +0000 Subject: [PATCH 1/4] feat(plugins): add sidebar actions and openSettings to the plugin API A plugin could only add pages to its sidebar group, so a row that opens its Settings tab, or does any other quick job, had no home. A new placement, sidebar//index.vue, adds a row after the plugin's pages that runs the file's + + diff --git a/commera/tests/test_plugins.py b/commera/tests/test_plugins.py index 0d83d1ee..48acf258 100644 --- a/commera/tests/test_plugins.py +++ b/commera/tests/test_plugins.py @@ -96,6 +96,18 @@ def page(name="jobs", **fields) -> dict: } +def sidebar_action(name="settings", **fields) -> dict: + return { + "place": "sidebar", + "name": name, + "module": f"sidebar/{name}.js", + "hash": "4b7e2d", + "label": "Settings", + "icon": "settings", + **fields, + } + + def card(name="print-status", place="order/cards", **fields) -> dict: return { "place": place, @@ -430,6 +442,26 @@ def test_a_page_condition_runs_at_boot_but_a_record_condition_waits_for_the_reco self.assertEqual(self.visible_labels(STOCK_USER), ["Card", "Shown"]) self.assertTrue(error_logged(f"Commera plugin {APP}:pages:broken condition failed", self.started_at)) + def test_a_sidebar_action_reaches_the_boot_with_its_module_and_runs_its_condition(self): + self.write_manifest( + [ + sidebar_action(), + sidebar_action("hidden", label="Hidden", condition=f"{FUNCTIONS}.hide_page"), + ] + ) + self.write_asset( + APP, "sidebar/settings.js", f"/* commera-plugin-api: {API_VERSION} */\nexport default {{}}" + ) + + entries = get_visible_plugins(STOCK_USER)["entries"] + self.assertEqual([entry["label"] for entry in entries], ["Settings"]) + self.assertEqual(entries[0]["place"], "sidebar") + self.assertTrue(entries[0]["module_url"].endswith("/sidebar/settings.js?v=4b7e2d")) + + def test_a_sidebar_action_without_a_module_is_dropped(self): + self.write_manifest([sidebar_action(module=None)]) + self.assertEqual(get_visible_plugins(STOCK_USER)["entries"], []) + def test_the_dashboard_boot_carries_no_server_paths(self): self.write_manifest( [ diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 93b6f3dd..da0ae343 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -69,6 +69,7 @@ declare module 'vue' { PluginHost: typeof import('./src/components/PluginHost.vue')['default'] PluginPageFrame: typeof import('./src/components/PluginPageFrame.vue')['default'] PluginSettingsPanel: typeof import('./src/components/settings/PluginSettingsPanel.vue')['default'] + PluginSidebarRunner: typeof import('./src/components/PluginSidebarRunner.vue')['default'] PluginSlot: typeof import('./src/components/PluginSlot.vue')['default'] PluginsSettings: typeof import('./src/components/settings/PluginsSettings.vue')['default'] ProductBasics: typeof import('./src/components/product/ProductBasics.vue')['default'] diff --git a/dashboard/src/App.vue b/dashboard/src/App.vue index a4224395..d8f77305 100644 --- a/dashboard/src/App.vue +++ b/dashboard/src/App.vue @@ -6,6 +6,7 @@ import { visibleRoute } from './ia/settingsRoute' import { useIsMobile } from './utils/useIsMobile' import AppShell from './components/AppShell.vue' import AppSettingsDialog from './components/settings/AppSettingsDialog.vue' +import PluginSidebarRunner from './components/PluginSidebarRunner.vue' import SearchPalette from './components/SearchPalette.vue' import ImportDialog from './components/import/ImportDialog.vue' import AddProductDialog from './components/AddProductDialog.vue' @@ -35,6 +36,7 @@ provide(routeLocationKey, shallowReactive(shellRoute)) them; opened from the workspace menu and the sidebar footer. --> + diff --git a/dashboard/src/components/NavSection.vue b/dashboard/src/components/NavSection.vue index d3a40be1..cbc9782a 100644 --- a/dashboard/src/components/NavSection.vue +++ b/dashboard/src/components/NavSection.vue @@ -39,13 +39,15 @@ const toggle = (item) => (opened.value[item.to] = !isOpen(item))
+
diff --git a/dashboard/src/components/PluginHost.vue b/dashboard/src/components/PluginHost.vue index 902d4cb4..17b80dc6 100644 --- a/dashboard/src/components/PluginHost.vue +++ b/dashboard/src/components/PluginHost.vue @@ -1,10 +1,11 @@ + + diff --git a/dashboard/src/ia/plugins.js b/dashboard/src/ia/plugins.js index 00d32949..a6ca8cdf 100644 --- a/dashboard/src/ia/plugins.js +++ b/dashboard/src/ia/plugins.js @@ -1,4 +1,4 @@ -import { h } from 'vue' +import { h, shallowRef } from 'vue' import AppIcon from '../components/AppIcon.vue' import { bootValue } from '../data/boot' @@ -74,24 +74,37 @@ export function firstPageRoute(app) { return first ? pageRoute(first) : null } -function pageNavItem(entry) { - return { label: entry.label, icon: pluginIcon(entry), to: pageRoute(entry) } +// The sidebar action a click asked for. PluginSidebarRunner hosts it once, then sets this back to null. +export const runningSidebarAction = shallowRef(null) + +let sidebarRuns = 0 + +export function runSidebarAction(entry) { + // A new run id each click, so the same row clicked twice runs twice. + runningSidebarAction.value = { ...entry, run: ++sidebarRuns } +} + +function navItem(entry) { + return entry.place === 'sidebar' + ? { label: entry.label, icon: pluginIcon(entry), onClick: () => runSidebarAction(entry) } + : { label: entry.label, icon: pluginIcon(entry), to: pageRoute(entry) } } -// One row per app: its only page, or a disclosure over its pages shaped like the Analytics row. +// One row per app: its only page or action, or a disclosure over its pages and then its actions, +// shaped like the Analytics row. export function pluginNavItems() { - const shown = placeEntries('pages').filter((entry) => entry.sidebar !== false) + const rows = [...placeEntries('pages').filter((entry) => entry.sidebar !== false), ...placeEntries('sidebar')] const byApp = new Map() - for (const entry of shown) byApp.set(entry.app, [...(byApp.get(entry.app) ?? []), entry]) + for (const entry of rows) byApp.set(entry.app, [...(byApp.get(entry.app) ?? []), entry]) return [...byApp.entries()] - .map(([app, pages]) => - pages.length === 1 - ? { ...pageNavItem(pages[0]), icon: appIcon(app) } + .map(([app, appRows]) => + appRows.length === 1 + ? { ...navItem(appRows[0]), icon: appIcon(app) } : { label: appTitle(app), icon: appIcon(app), to: `/plugins/${app}`, - children: pages.map(pageNavItem), + children: appRows.map(navItem), }, ) .sort((left, right) => left.label.localeCompare(right.label)) @@ -139,6 +152,7 @@ export function pluginCommands() { const PLACE_LABELS = { pages: 'Page', + sidebar: 'Sidebar action', 'order/cards': 'Order card', 'product/cards': 'Product card', 'customer/cards': 'Customer card', diff --git a/packages/plugin-kit/README.md b/packages/plugin-kit/README.md index 0e253393..4ca5f70e 100644 --- a/packages/plugin-kit/README.md +++ b/packages/plugin-kit/README.md @@ -55,6 +55,7 @@ Run `bench commera add ` inside your app's folder to add one placem | `order-action`, `product-action`, `customer-action` | `commera//actions//index.vue` and a whitelisted `_(name)` in your `api.py` | | `settings` | `commera/settings/index.vue` (no name: one per plugin) | | `command` | `commera/commands//index.vue` and a whitelisted `()` in your `api.py` | +| `sidebar` | `commera/sidebar//index.vue`, a sidebar row that opens the plugin's Settings tab | The command never overwrites: it stops when the placement or the method already exists. @@ -66,6 +67,7 @@ The folder decides where a plugin goes. `places.json` is the one list; Commera's | Path under `commera/` | Where it shows | `plugin` fields: **required** / optional | | --- | --- | --- | | `pages//index.vue` | `/commera/plugins//`, plus a sidebar row | **label** / icon, requires, condition, sidebar, order | +| `sidebar//index.vue` | A sidebar row that runs the file's ` + + +``` + +The setup runs and the action ends; it draws nothing. Put work that takes time in a whitelisted method and call it +with `useMethodAction`. + ## The dashboard draws the frame The plugin fills the content; Commera draws the chrome around it, so every plugin looks like the rest of the @@ -142,7 +165,7 @@ dashboard. Import these from `@commera/admin`: | a page | `usePage()` | `setTitle(text)`, `setBreadcrumbs([{ label, to }])`, `setActions([{ label, icon, variant, onClick, loading, disabled }])`. Each takes a value, a ref or a getter. The first action is the main one; past two, the rest fold into a More menu. | | a card | `useCard()` | `hide()`, `show()`, `setHidden(bool)`. The frame only appears after the card's first render, so hiding during setup never shows an empty card. | | an action | `useAction()` | `setPrimary({ label, disabled, loading })`, `onSubmit(async () => …)`, `close(result)`. Resolve to close; resolve `{ reload: true }` to reload the record; throw to keep the dialog open with the error under the form. | -| all | `usePlugin()` | `plugin`, `path`, `query`, `record` (`{ doctype, name }` on cards and actions), `reload()`, `navigate(to)`, `toast`, `__`. | +| all | `usePlugin()` | `plugin`, `path`, `query`, `record` (`{ doctype, name }` on cards and actions), `reload()`, `navigate(to)`, `openSettings(tab)`, `toast`, `__`. `openSettings()` opens the plugin's own Settings tab; `openSettings('payments')` opens a Commera tab. | `navigate(to)` and a breadcrumb's `to` resolve like a link against `/commera/plugins//`: `'jobs/JOB-1'`, `'../'` and `'/orders/SO-1'` all work the same from a page, a card or an action. @@ -180,6 +203,7 @@ Folder and `plugin` problems are collected and reported together. | An unknown, missing or mistyped field | `sidebr: false` (with a did-you-mean), no `label`, `sidebar` on a card | | An action or settings tab with both or neither of template and `method`/`doctype` | | | A command with a template or `\n${body}`; + + test('a sidebar action with a template fails', async () => { + await buildFails( + { + 'sidebar/settings/index.vue': sidebarAction( + '\n', + ), + }, + /sidebar\/settings\/index\.vue: can't have a