diff --git a/commera/scaffold/__init__.py b/commera/scaffold/__init__.py
index b26cace6..5231b096 100644
--- a/commera/scaffold/__init__.py
+++ b/commera/scaffold/__init__.py
@@ -92,7 +92,9 @@ def add_gitignore_lines(self, wanted: list[str]):
self.write_change(path, text + "".join(f"{line}\n" for line in missing), bool(missing))
def add_places(self):
- for place in PLACES:
+ for place, spec in PLACES.items():
+ if spec.get("config"):
+ continue
if (self.source_dir / place).exists():
self.changes.append(("skipped", f"commera/{place}/"))
elif place == "pages":
diff --git a/commera/tests/test_plugins.py b/commera/tests/test_plugins.py
index e512e246..343ef034 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": "plugin.config.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, "plugin.config.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("/plugin.config.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/commera/tests/test_scaffold.py b/commera/tests/test_scaffold.py
index 537b4ed9..aaccbbcf 100644
--- a/commera/tests/test_scaffold.py
+++ b/commera/tests/test_scaffold.py
@@ -79,8 +79,10 @@ def test_scaffold_adds_every_piece_to_a_new_app(self):
self.assertIn("sparkles", ICONS)
self.assertIn(f"useMethodRead('{APP}.api.get_summary')", page.read_text())
self.assertIn(f"{APP}/api.py", created)
- for place in PLACES:
- if place != "pages":
+ for place, spec in PLACES.items():
+ if spec.get("config"):
+ self.assertFalse((app_root / "commera" / place).exists(), place)
+ elif place != "pages":
self.assertTrue((app_root / "commera" / place / ".gitkeep").is_file(), place)
self.assertIn(f"bench build --app {APP}", (app_root / "commera" / "README.md").read_text())
self.assertIn("commera/vite.config.js", created)
@@ -181,7 +183,9 @@ def add(self, kind: str, name: str | None = None) -> list[tuple[str, str]]:
return AppScaffold(APP, self.apps_path).add_place(kind, name)
def test_every_placement_folder_has_a_kind(self):
- self.assertEqual({place for place, _starter in PLACE_KINDS.values()}, set(PLACES))
+ # Config places (sidebar actions) live in commera/plugin.config.ts, so `add` writes no folder for them.
+ folder_places = {place for place, spec in PLACES.items() if not spec.get("config")}
+ self.assertEqual({place for place, _starter in PLACE_KINDS.values()}, folder_places)
def test_each_kind_writes_its_placement_with_a_plugin_block(self):
for kind, (place, _starter) in PLACE_KINDS.items():
diff --git a/dashboard/pluginDevServer.js b/dashboard/pluginDevServer.js
index c6679e1c..a2ca8517 100644
--- a/dashboard/pluginDevServer.js
+++ b/dashboard/pluginDevServer.js
@@ -4,7 +4,8 @@ import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { searchForWorkspaceRoot } from 'vite'
import * as compiler from 'vue/compiler-sfc'
-import { discoverPlugins, pageEntryCode } from '../packages/plugin-kit/vite.js'
+import { discoverPlugins, findPluginConfig, pageEntryCode } from '../packages/plugin-kit/vite.js'
+import places from '../packages/plugin-kit/places.json' with { type: 'json' }
import { BENCH, readPluginApps, sourceDirOf } from './pluginApps.js'
const PREFIX = '/@commera-plugin/'
@@ -27,6 +28,12 @@ function parseKey(key) {
function moduleCode(key) {
const { app, place, name } = parseKey(key)
+ // Sidebar actions and other config places all live in one plugin.config file; the dashboard picks the item by name.
+ if (places.places[place]?.config) {
+ const configFile = findPluginConfig(sourceDirOf(app))
+ if (!configFile) throw new Error(`${app} has no commera/plugin.config.ts`)
+ return `export { default } from ${JSON.stringify(configFile)}`
+ }
const { entries } = discoverPlugins(sourceDirOf(app), { app, compiler })
const entry = entries.find((entry) => entry.place === place && entry.name === name)
if (!entry?.entryName) throw new Error(`${app} has no ${place}/${name}/index.vue with a template`)
@@ -121,7 +128,10 @@ export function pluginDevServer() {
config: () => ({
resolve: {
dedupe: ['vue', 'frappe-ui'],
- alias: { '@commera/admin': fileURLToPath(new URL('./src/plugin-api/index.js', import.meta.url)) },
+ alias: {
+ '@commera/admin': fileURLToPath(new URL('./src/plugin-api/index.js', import.meta.url)),
+ '@commera/plugin-kit': fileURLToPath(new URL('../packages/plugin-kit/index.js', import.meta.url)),
+ },
},
server: { fs: { allow: [searchForWorkspaceRoot(process.cwd()), ...apps.map(sourceDirOf)] } },
}),
diff --git a/dashboard/src/components/MobileLayout.vue b/dashboard/src/components/MobileLayout.vue
index 264045bb..ead28e89 100644
--- a/dashboard/src/components/MobileLayout.vue
+++ b/dashboard/src/components/MobileLayout.vue
@@ -87,7 +87,7 @@ function closeMore(item) {
diff --git a/dashboard/src/components/NavSection.vue b/dashboard/src/components/NavSection.vue
index d3a40be1..12ee018b 100644
--- a/dashboard/src/components/NavSection.vue
+++ b/dashboard/src/components/NavSection.vue
@@ -11,7 +11,7 @@ const props = defineProps({
// route lands inside it, and stays wherever you last put it otherwise.
const opened = ref({})
-const holdsRoute = (item) => item.children.some((child) => child.to === props.activeTarget)
+const holdsRoute = (item) => item.children.some((child) => child.to && child.to === props.activeTarget)
const isOpen = (item) => opened.value[item.to] ?? holdsRoute(item)
const toggle = (item) => (opened.value[item.to] = !isOpen(item))
@@ -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..6103ba30 100644
--- a/dashboard/src/components/PluginHost.vue
+++ b/dashboard/src/components/PluginHost.vue
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
import { toast } from 'frappe-ui'
import { __, PLUGIN_CONTEXT } from '../plugin-api/context'
import EmptyState from './EmptyState.vue'
-import { appLocation } from '../ia/plugins'
+import { appLocation, openPluginSettings } from '../ia/plugins'
const props = defineProps({
entry: { type: Object, required: true },
@@ -40,6 +40,7 @@ provide(PLUGIN_CONTEXT, {
record: toRef(props, 'record'),
reload: () => emit('reload'),
navigate,
+ openSettings: (tab) => openPluginSettings(props.entry.app, tab),
toast,
__,
})
diff --git a/dashboard/src/ia/plugins.js b/dashboard/src/ia/plugins.js
index 00d32949..81aec595 100644
--- a/dashboard/src/ia/plugins.js
+++ b/dashboard/src/ia/plugins.js
@@ -1,6 +1,8 @@
import { h } from 'vue'
import AppIcon from '../components/AppIcon.vue'
import { bootValue } from '../data/boot'
+import { runSidebarAction } from '../plugin-api/sidebar'
+import { openSettings } from './settingsRoute'
// commera/www/commera.py has already filtered these for the session user, so the client only places them.
const plugins = bootValue('plugins', {}) ?? {}
@@ -74,24 +76,27 @@ export function firstPageRoute(app) {
return first ? pageRoute(first) : null
}
-function pageNavItem(entry) {
- return { label: entry.label, icon: pluginIcon(entry), to: pageRoute(entry) }
+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))
@@ -101,6 +106,11 @@ export function settingsTabValue(app) {
return `plugin-${app}`
}
+// No tab opens the plugin's own Settings tab; a Commera tab name such as 'payments' opens that one.
+export function openPluginSettings(app, tab) {
+ openSettings(tab ?? settingsTabValue(app))
+}
+
export function pluginSettingsTabs() {
return placeEntries('settings').map((entry) => ({
value: settingsTabValue(entry.app),
@@ -139,6 +149,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/dashboard/src/plugin-api/sidebar.js b/dashboard/src/plugin-api/sidebar.js
new file mode 100644
index 00000000..929e4a77
--- /dev/null
+++ b/dashboard/src/plugin-api/sidebar.js
@@ -0,0 +1,25 @@
+import { toast } from 'frappe-ui'
+import { appLocation, openPluginSettings } from '../ia/plugins'
+
+// A sidebar action is an item in the plugin's commera/plugin.config.ts. The build ships that file as one module;
+// a click imports it, finds the item by name and calls its run() with what a row can do.
+export async function runSidebarAction(entry) {
+ const { app, place, name, module_url, label } = entry
+ try {
+ if (entry.error || !module_url) throw new Error(entry.error || 'This plugin has not been built yet.')
+ const url = import.meta.env.DEV ? `/@commera-plugin/${app}/${place}/${name}` : module_url
+ const config = (await import(/* @vite-ignore */ url)).default
+ const action = config?.[place]?.find((item) => item.name === name)
+ if (!action) throw new Error(`${name} is not in ${app}'s plugin.config`)
+ await action.run({
+ openSettings: (tab) => openPluginSettings(app, tab),
+ // Imported on use: router.js imports ia/plugins, which imports this file.
+ navigate: async (to) => (await import('../router')).router.push(appLocation(app, to)),
+ openUrl: (target) => window.open(target, '_blank', 'noopener'),
+ toast,
+ })
+ } catch (error) {
+ console.error(`[commera plugin ${entry.key}]`, error)
+ toast.error(`${label} couldn't run`)
+ }
+}
diff --git a/docs/implementation/media/plugin-sidebar-action.png b/docs/implementation/media/plugin-sidebar-action.png
new file mode 100644
index 00000000..39b37b96
Binary files /dev/null and b/docs/implementation/media/plugin-sidebar-action.png differ
diff --git a/packages/plugin-kit/README.md b/packages/plugin-kit/README.md
index 0e253393..dc6134ce 100644
--- a/packages/plugin-kit/README.md
+++ b/packages/plugin-kit/README.md
@@ -132,6 +132,30 @@ The logo shows as it is, in its own colours, so pick one that reads on both a li
`currentColor` in the file draws black. Without a logo, the sidebar row uses the first page's `icon`, and an
entry without an `icon` uses the dashboard's generic one.
+## plugin.config.ts
+
+Folders hold everything that draws UI. Contributions that draw nothing of their own go in one file,
+`commera/plugin.config.ts` (or `.js`). Today that is sidebar actions: rows after the plugin's pages in its sidebar
+group that run a function on click instead of opening a page.
+
+```ts
+import { definePlugin } from '@commera/plugin-kit'
+
+export default definePlugin({
+ sidebar: [
+ { name: 'settings', label: 'Settings', icon: 'settings', order: 9, run: ({ openSettings }) => openSettings() },
+ { name: 'help', label: 'Help', icon: 'circle-help', run: ({ openUrl }) => openUrl('https://example.com/help') },
+ ],
+})
+```
+
+The build reads `name` (1 to 40 lowercase letters, digits and hyphens, the entry's stable key), `label` (required),
+`icon`, `order`, `requires` and `condition` into the manifest, with the same checks as a folder entry, and ships
+the file as one module, `plugin.config.js`. `run` gets a context with `openSettings(tab)`, `navigate(to)`,
+`openUrl(url)` and `toast`. The build runs the file in Node to read those fields, so keep it to plain data:
+it and the files it imports may import only `definePlugin` from `@commera/plugin-kit`, and its top-level code
+runs at build time. Put work that takes time in a whitelisted method and reach it from a page.
+
## 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 +166,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 (General if it has none); `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 +204,8 @@ 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 `