diff --git a/docs/features/dashboard.md b/docs/features/dashboard.md index 029e3bf3f..f846f7ae8 100644 --- a/docs/features/dashboard.md +++ b/docs/features/dashboard.md @@ -149,6 +149,8 @@ Each widget renderer composes the shared `` primitive and receives only A plugin with the `dashboard.widgets.register` permission can register widgets from its admin-window entrypoint via `api.dashboard.widgets.register(...)`. The widget's React `component` runs in the **admin app context** (not the QuickJS sandbox) — plugin server code runs sandboxed, but admin / dashboard widgets render in-process. +The host mounts plugin-owned widgets under the same `PluginContext` in the dashboard grid, Customize mode, and Block Library preview. Widget components can therefore use `usePluginContext`, `usePluginSettings`, `usePluginRoutes`, and other host hooks with the plugin's identity, grants, settings, and route scope intact. + Plugin-owned analytics tiles such as `visitors` or `top-pages` are plugin widgets, not first-party dashboard widgets. They are not seeded into the default layout; once a plugin registers them, users can add them from the Block Library and their saved layout references the plugin-owned id. --- @@ -333,6 +335,8 @@ That's it. Users see it in the BlockLibrary; dragging it onto the grid persists Plugins with `dashboard.widgets.register` permission register widgets from their admin-window entrypoint via `api.dashboard.widgets.register(...)`. The widget's `component` runs in the **admin React app** (not the QuickJS sandbox). Plugin server code runs sandboxed; plugin dashboard widgets do not. +Every dashboard render location supplies the widget's plugin context, including the grid, Customize mode, and the Block Library preview. Host hooks imported from `@instatic/host-hooks` therefore resolve the registering plugin just as they do in panels, app pages, and canvas overlays. + ### Gate widget data on capability Dashboard widget definitions do not carry a `requires` field. Gate sensitive data at the endpoint that feeds the widget: diff --git a/docs/features/plugin-system.md b/docs/features/plugin-system.md index c99f44747..e6796746c 100644 --- a/docs/features/plugin-system.md +++ b/docs/features/plugin-system.md @@ -306,7 +306,7 @@ That trust level is gated by one permission: **`editor.code`** (risk: dangerous) - `adminPages[].content.assetPath` is pinned to the plugin's own `/uploads/plugins/{id}/{version}` subtree so a manifest can't point the dynamic import at foreign code. - The install review dialog (always shown — even for zero-permission plugins) calls out `editor.code` with a dedicated unsandboxed-code warning. -Inside the admin window, plugin React surfaces (panels, app pages, canvas overlays) mount under a `PluginContext` carrying the granted permission set; permission-gated host hooks enforce against it — `useEditorStore` from `@instatic/host-hooks` requires `editor.store.read` and exposes no write accessor (writes go through `api.editor.store.transaction`, which requires `editor.store.write`). +Inside the admin window, plugin React surfaces (panels, app pages, canvas overlays, and dashboard widgets) mount under a `PluginContext` carrying the granted permission set; permission-gated host hooks enforce against it — `useEditorStore` from `@instatic/host-hooks` requires `editor.store.read` and exposes no write accessor (writes go through `api.editor.store.transaction`, which requires `editor.store.write`). Dashboard widgets receive the same context in the grid, Customize mode, and the Block Library preview. ### What's available inside @@ -501,7 +501,7 @@ export function activate(api) { } ``` -Widget ids must be namespaced under the plugin id (`.`). The component should compose the host `Widget` primitive so plugin tiles use the same card chrome, drag handle, menu, loading state, and tint behavior as first-party widgets. +Widget ids must be namespaced under the plugin id (`.`). The component should compose the host `Widget` primitive so plugin tiles use the same card chrome, drag handle, menu, loading state, and tint behavior as first-party widgets. It may use `usePluginContext`, `usePluginSettings`, `usePluginRoutes`, and the other `@instatic/host-hooks`; the host supplies the registering plugin's context at every widget mount. ### CMS routes — requires `cms.routes` (public routes also require `cms.routes.public`) diff --git a/src/__tests__/architecture/dashboard-widget-context-mounts.test.ts b/src/__tests__/architecture/dashboard-widget-context-mounts.test.ts new file mode 100644 index 000000000..d68f8a4f9 --- /dev/null +++ b/src/__tests__/architecture/dashboard-widget-context-mounts.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'bun:test' +import { readFileSync } from 'fs' +import { join } from 'path' + +const COMPONENT_ROOT = join(import.meta.dir, '../../admin/pages/dashboard/components') + +function countMounts(fileName: string): number { + const source = readFileSync(join(COMPONENT_ROOT, fileName), 'utf8') + return source.match(/ { + it('routes view, customize, and library preview renderers through the shared mount', () => { + expect(countMounts('DashboardGrid.tsx')).toBe(2) + expect(countMounts('BlockLibrary.tsx')).toBe(1) + }) +}) diff --git a/src/__tests__/plugins/pluginDashboardWidgetContext.test.tsx b/src/__tests__/plugins/pluginDashboardWidgetContext.test.tsx new file mode 100644 index 000000000..b0e3e2401 --- /dev/null +++ b/src/__tests__/plugins/pluginDashboardWidgetContext.test.tsx @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { createRef } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { DndContext } from '@dnd-kit/core' +import { DashboardGrid } from '@admin/pages/dashboard/components/DashboardGrid' +import { + usePluginContext, + usePluginRoutes, + usePluginSettings, +} from '@admin/plugin-host-hooks' +import { + activateEditorPlugin, + bindDashboardWidgetIconResolver, + pluginRuntime, +} from '@core/plugins/runtime' +import { dashboardWidgetRegistry } from '@core/dashboard' +import type { + PixelArtIconComponent, + PluginDashboardWidget, + PluginManifest, +} from '@core/plugin-sdk' + +const NoopIcon = (() => null) as unknown as PixelArtIconComponent + +const manifest: PluginManifest = { + id: 'acme.analytics', + name: 'Analytics', + version: '1.0.0', + apiVersion: 1, + permissions: ['editor.code', 'dashboard.widgets.register'], + grantedPermissions: ['editor.code', 'dashboard.widgets.register'], + entrypoints: { editor: 'editor/index.js' }, + resources: [], + adminPages: [], +} + +let requests: Array<{ input: string; credentials: RequestCredentials | undefined }> = [] +let originalFetch: typeof globalThis.fetch + +function ContextWidget() { + const context = usePluginContext() + const settings = usePluginSettings<{ sampleRate: number }>() + const routes = usePluginRoutes() + return ( + <> + + {context.pluginId}|{context.pluginVersion}|{context.surfaceId}|{context.surfaceLabel}| + {settings.sampleRate} + + + + ) +} + +beforeEach(() => { + originalFetch = globalThis.fetch + globalThis.fetch = async (input, init) => { + requests.push({ input: String(input), credentials: init?.credentials }) + return new Response('{}', { status: 200 }) + } + requests = [] + dashboardWidgetRegistry.reset() + pluginRuntime.reset() + bindDashboardWidgetIconResolver(() => NoopIcon) +}) + +afterEach(() => { + globalThis.fetch = originalFetch + dashboardWidgetRegistry.reset() + pluginRuntime.reset() + cleanup() +}) + +describe('plugin dashboard widget context', () => { + it('provides plugin identity, settings, and scoped routes in the dashboard grid', async () => { + pluginRuntime.setPluginSettings(manifest.id, { sampleRate: 7 }) + await activateEditorPlugin(manifest, { + activate(api) { + api.dashboard.widgets.register({ + id: 'acme.analytics.pageviews', + name: 'Pageviews', + description: 'Site-wide pageview chart', + iconName: 'chart', + defaultSize: 6, + tint: 'lilac', + component: ContextWidget as PluginDashboardWidget['component'], + }) + }, + }) + + const definition = dashboardWidgetRegistry.get('acme.analytics.pageviews') + expect(definition).toBeDefined() + + render( + + {}} + onResizeRows={() => {}} + onAddBlock={() => {}} + gridRef={createRef()} + dropTarget={null} + /> + , + ) + + expect(screen.getByTestId('plugin-widget-context').textContent).toBe( + 'acme.analytics|1.0.0|acme.analytics.pageviews|Pageviews|7', + ) + + fireEvent.click(screen.getByRole('button', { name: 'Load status' })) + await waitFor(() => { + expect(requests).toEqual([{ + input: '/admin/api/cms/plugins/acme.analytics/runtime/status', + credentials: 'include', + }]) + }) + }) +}) diff --git a/src/__tests__/plugins/pluginDashboardWidgets.test.ts b/src/__tests__/plugins/pluginDashboardWidgets.test.ts index ec5806512..f4233f4d9 100644 --- a/src/__tests__/plugins/pluginDashboardWidgets.test.ts +++ b/src/__tests__/plugins/pluginDashboardWidgets.test.ts @@ -40,6 +40,11 @@ const baseManifest: PluginManifest = { adminPages: [], } +const pluginContext = { + version: baseManifest.version, + grantedPermissions: baseManifest.grantedPermissions ?? [], +} + beforeEach(() => { dashboardWidgetRegistry.reset() pluginRuntime.reset() @@ -76,6 +81,7 @@ describe('dashboardWidgetRegistry — namespace + lifecycle', () => { dashboardWidgetRegistry.register({ id: 'pageviews', ownerId: 'acme.analytics', + pluginContext, name: 'Pageviews', description: 'Bad — no namespace', icon: NoopIcon, @@ -90,6 +96,7 @@ describe('dashboardWidgetRegistry — namespace + lifecycle', () => { dashboardWidgetRegistry.register({ id: 'acme.analytics.pageviews', ownerId: 'acme.analytics', + pluginContext, name: 'Pageviews', description: 'Site-wide pageview chart', icon: NoopIcon, @@ -101,6 +108,21 @@ describe('dashboardWidgetRegistry — namespace + lifecycle', () => { expect(dashboardWidgetRegistry.get('acme.analytics.pageviews')?.tint).toBe('lilac') }) + it('rejects a plugin widget without host context metadata', () => { + expect(() => + dashboardWidgetRegistry.register({ + id: 'acme.analytics.pageviews', + ownerId: 'acme.analytics', + name: 'Pageviews', + description: 'Missing plugin context', + icon: NoopIcon, + defaultSize: 6, + tint: 'lilac', + render: NoopBody, + }), + ).toThrow(/must include its host context metadata/) + }) + it('drops every widget for a given owner via unregisterByOwner', () => { dashboardWidgetRegistry.register({ id: 'core-only', @@ -115,6 +137,7 @@ describe('dashboardWidgetRegistry — namespace + lifecycle', () => { dashboardWidgetRegistry.register({ id: 'acme.analytics.first', ownerId: 'acme.analytics', + pluginContext, name: 'First', description: 'one', icon: NoopIcon, @@ -125,6 +148,7 @@ describe('dashboardWidgetRegistry — namespace + lifecycle', () => { dashboardWidgetRegistry.register({ id: 'acme.analytics.second', ownerId: 'acme.analytics', + pluginContext, name: 'Second', description: 'two', icon: NoopIcon, @@ -188,6 +212,10 @@ describe('plugin runtime — dashboard.widgets.register', () => { expect(captured).not.toBeNull() const def = dashboardWidgetRegistry.get('acme.analytics.pageviews') expect(def?.ownerId).toBe('acme.analytics') + expect(def?.pluginContext).toEqual({ + version: '1.0.0', + grantedPermissions: ['dashboard.widgets.register'], + }) expect(def?.tint).toBe('mint') expect(def?.icon).toBe(NoopIcon) }) diff --git a/src/admin/pages/dashboard/components/BlockLibrary.tsx b/src/admin/pages/dashboard/components/BlockLibrary.tsx index e0e9bdd66..b1e64316f 100644 --- a/src/admin/pages/dashboard/components/BlockLibrary.tsx +++ b/src/admin/pages/dashboard/components/BlockLibrary.tsx @@ -50,6 +50,7 @@ import { LIBRARY_MAX_HEIGHT, LIBRARY_MIN_HEIGHT, } from '../hooks/useDashboardLayout' +import { DashboardWidgetMount } from './DashboardWidgetMount' import styles from './BlockLibrary.module.css' /** @@ -419,7 +420,6 @@ function LibraryItem({ widget, onAdd }: LibraryItemProps) { attributes, isDragging, } = useDraggable({ id: `${LIBRARY_DRAG_PREFIX}${widget.id}` }) - const Render = widget.render // Preview height in pixels, matching what the same widget will occupy // on the dashboard once dropped. The dashboard uses @@ -487,7 +487,7 @@ function LibraryItem({ widget, onAdd }: LibraryItemProps) {