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
4 changes: 3 additions & 1 deletion commera/scaffold/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
32 changes: 32 additions & 0 deletions commera/tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
[
Expand Down
10 changes: 7 additions & 3 deletions commera/tests/test_scaffold.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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():
Expand Down
14 changes: 12 additions & 2 deletions dashboard/pluginDevServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/'
Expand All @@ -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`)
Expand Down Expand Up @@ -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)] } },
}),
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/components/MobileLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ function closeMore(item) {
<!-- An item without `to` is an action row (Search): it opens an
overlay rather than routing, so it never takes active state. -->
<button v-else type="button" :class="rowClass" @click="closeMore(item)">
<span :class="item.icon" class="size-5 text-ink-gray-7" aria-hidden="true" />
<Icon :name="item.icon" class="size-5 text-ink-gray-7" />
{{ item.label }}
</button>
</template>
Expand Down
8 changes: 5 additions & 3 deletions dashboard/src/components/NavSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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))
</script>
Expand Down Expand Up @@ -39,13 +39,15 @@ const toggle = (item) => (opened.value[item.to] = !isOpen(item))
</template>
</SidebarItem>
<div v-show="isOpen(item)" class="space-y-0.5 pl-5">
<!-- A child without `to` is a plugin's sidebar action: it runs on click and never takes active state. -->
<SidebarItem
v-for="child in item.children"
:key="child.to"
:key="child.to ?? child.label"
:label="child.label"
:icon="child.icon"
:to="child.to"
:active="child.to === activeTarget"
:active="Boolean(child.to) && child.to === activeTarget"
@click="child.onClick?.()"
/>
</div>
</template>
Expand Down
3 changes: 2 additions & 1 deletion dashboard/src/components/PluginHost.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -40,6 +40,7 @@ provide(PLUGIN_CONTEXT, {
record: toRef(props, 'record'),
reload: () => emit('reload'),
navigate,
openSettings: (tab) => openPluginSettings(props.entry.app, tab),
toast,
__,
})
Expand Down
29 changes: 20 additions & 9 deletions dashboard/src/ia/plugins.js
Original file line number Diff line number Diff line change
@@ -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', {}) ?? {}
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A single sidebar action becomes a top-level row with no to and the app icon. NavSection.vue only handles a child without to in the children loop, not the top-level branch. Please confirm a top-level item without to renders and fires onClick, and show it in a screenshot. Also check an app with one action and no pages: there's no /plugins/${app} route, but appLocation and openSettings rely on the app's settings tab.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The top-level branch of NavSection.vue already handles an item without to: :active="Boolean(item.to) && …" and @click="item.onClick?.()", the same path the Search row uses. No route is needed for an app with only actions: nothing links to /plugins/<app> there. If the plugin has no Settings tab, openSettings() falls back to General, and the README now says so. Two related bugs are fixed in 53c3d1a: a group holding an action used to open itself on any page outside the nav (such as Settings), and the phone's More sheet lost an action's logo icon.

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))
Expand All @@ -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),
Expand Down Expand Up @@ -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',
Expand Down
25 changes: 25 additions & 0 deletions dashboard/src/plugin-api/sidebar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { toast } from 'frappe-ui'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openSettings is wired twice (here and in PluginHost.vue), each with the same tab ?? settingsTabValue(app) fallback. The imports also differ: ../ia/settingsRoute here, ../ia/settings in PluginHost. plugins.js and sidebar.js import each other, which is why router is imported lazily. Extract one openPluginSettings(app, tab) helper next to settingsTabValue and the cycle goes away.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 53c3d1a: both callers now use one openPluginSettings(app, tab) in ia/plugins.js. The plugins.js/sidebar.js import cycle stays, because the nav needs runSidebarAction. It is harmless: each side calls the other only on click. The lazy router import is for a different cycle, router → plugins → sidebar.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README says plugin.config.ts may import only @commera/plugin-kit, but the guard doesn't enforce that. evaluateConfig in packages/plugin-kit/vite.js uses an onResolve filter of ^[^./], so relative and absolute imports pass. The config also runs in Node at build time with full access. This is the plugin author's own code, so it isn't an RCE hole, but the claim and the guard disagree. Either reject relative and absolute imports, or document that the config executes at build time.

Also, dev mode builds the URL from name and place. parseKey splits on /, and there's no dev-server test for the config branch of moduleCode. Please add one.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented in 53c3d1a: the README now says the build runs plugin.config in Node, so its top-level code runs at build time and it should hold plain data. Package imports are already blocked in the files it pulls in too. Its own relative imports stay allowed, because the plugin author owns that code. parseKey is safe here: sidebar has no slash and name cannot hold one. A dev-server test for the config branch is still open and can follow.

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`)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 27 additions & 1 deletion packages/plugin-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<app>/`: `'jobs/JOB-1'`,
`'../'` and `'/orders/SO-1'` all work the same from a page, a card or an action.
Expand Down Expand Up @@ -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 `<script setup>` | `commands/sync/index.vue` with a `<template>` |
| A sidebar action in a folder instead of `plugin.config.ts` | `sidebar/settings/index.vue` |
| A `plugin.config.ts` item without `name`, `label` or `run`, or that imports anything but `@commera/plugin-kit` | `import { toast } from 'frappe-ui'` |
| An icon not in the list | `icon: 'printr'` |
| A dotted path outside the app | `condition: 'frappe.client.get_list'` |
| Drawing a frame the dashboard owns | importing `AppPageHeader`, `PageBody` or `PluginCard` |
Expand Down
36 changes: 36 additions & 0 deletions packages/plugin-kit/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export interface SidebarActionContext {
/** Opens the plugin's own Settings tab, or a Commera tab such as 'payments'. */
openSettings(tab?: string): void;
/** Goes to a dashboard route; a relative path resolves against /commera/plugins/<app>/. */
navigate(to: string): Promise<unknown>;
/** Opens a URL in a new tab. */
openUrl(url: string): void;
toast: {
success(message: string): void;
error(message: string): void;
warning(message: string): void;
info(message: string): void;
};
}

export interface SidebarAction {
/** 1 to 40 lowercase letters, digits and hyphens; the entry's stable key. */
name: string;
label: string;
/** A name from Commera's icon list, such as 'settings'. */
icon?: string;
order?: number;
/** A DocType the user must be able to read to see the row. */
requires?: string;
/** A dotted path to a function in the plugin that returns a bool. */
condition?: string;
/** Runs when the row is clicked. */
run(context: SidebarActionContext): void | Promise<void>;
}

export interface PluginConfig {
/** Rows after the plugin's pages in its sidebar group. */
sidebar?: SidebarAction[];
}

export function definePlugin(config: PluginConfig): PluginConfig;
4 changes: 4 additions & 0 deletions packages/plugin-kit/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Contributions that draw nothing; types are in index.d.ts. It runs in the dashboard, so it must not import Node.
export function definePlugin(config) {
return config;
}
4 changes: 4 additions & 0 deletions packages/plugin-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"private": true,
"type": "module",
"exports": {
".": {
"types": "./index.d.ts",
"default": "./index.js"
},
"./vite": "./vite.js",
"./places.json": "./places.json"
},
Expand Down
7 changes: 7 additions & 0 deletions packages/plugin-kit/places.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
"fields": ["label", "icon", "requires", "condition", "sidebar", "order"],
"required": ["label"]
},
"sidebar": {
"module": "required",
"config": true,
"doctype": null,
"fields": ["label", "icon", "requires", "condition", "order"],
"required": ["label"]
},
"order/cards": {
"module": "required",
"doctype": "Sales Order",
Expand Down
Loading
Loading