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 `