diff --git a/docs/docs/guides/extending-the-dashboard/custom-providers/index.mdx b/docs/docs/guides/extending-the-dashboard/custom-providers/index.mdx new file mode 100644 index 0000000000..841bfa3732 --- /dev/null +++ b/docs/docs/guides/extending-the-dashboard/custom-providers/index.mdx @@ -0,0 +1,66 @@ +--- +title: 'Custom Providers' +metaTitle: 'Vendure Dashboard Custom Providers — Wrap the App & Layout' +metaDescription: 'Learn how to register custom React providers in Vendure Dashboard extensions, where they render (app vs layout) and how ordering works.' +--- + +Custom Providers let your extension **wrap parts of the Dashboard UI in your own React provider components**. +This is useful when you need to inject cross-cutting concerns like custom context, feature flags, error boundaries, +telemetry, or theming that should apply to dashboard pages. + +## What is a "Custom Provider"? + +A custom provider is a React component that receives `children` and returns a wrapped subtree: + +```tsx +export function MyProvider({ children }: { children: ReactNode }) { + return children; +} +``` + +You register it via `defineDashboardExtension()`: + +```tsx +import { defineDashboardExtension } from '@vendure/dashboard'; + +function MyProvider({ children }: { children: ReactNode }) { + return children; +} + +export default defineDashboardExtension({ + customProviders: [ + { + id: 'my-provider', + component: MyProvider, + location: 'app', + order: 0, + }, + ], +}); +``` + +## Where providers render: `location` + +Each provider can target one of two places: + +- **`location: 'app'`**: wraps the whole application (the highest level available to extensions). +- **`location: 'layout'`**: wraps the main content area of the authenticated layout (the `` subtree). The sidebar and header are outside this wrapper. + +If `location` is omitted, it defaults to `'app'`. + +## Provider ordering: `order` + +Providers at the same `location` are sorted by `order` (ascending). + +- Lower `order` values are rendered **outermost / earlier**. +- Higher `order` values are rendered **innermost / later**. + +If `order` is omitted, it defaults to `0`. + +## Common use cases + +- Providing a custom React context your extension components can consume +- Adding an `ErrorBoundary` around parts of the dashboard +- Wiring up feature flags or experimentation frameworks +- Adding analytics/telemetry providers +- Providing localization or formatting helpers specific to your organization diff --git a/docs/src/manifest.ts b/docs/src/manifest.ts index c83002f6d2..c00dbdddc8 100644 --- a/docs/src/manifest.ts +++ b/docs/src/manifest.ts @@ -594,6 +594,11 @@ const manifestInput: DocsPackageManifestInput = { slug: 'migration', file: file('docs/guides/extending-the-dashboard/migration/index.mdx'), }, + { + title: 'Custom Providers', + slug: 'custom-providers', + file: file('docs/guides/extending-the-dashboard/custom-providers/index.mdx'), + }, ], }, { diff --git a/packages/dashboard/src/app/app-providers.tsx b/packages/dashboard/src/app/app-providers.tsx index 4028bd7302..cddd969350 100644 --- a/packages/dashboard/src/app/app-providers.tsx +++ b/packages/dashboard/src/app/app-providers.tsx @@ -5,6 +5,7 @@ import { I18nProvider } from '@/vdb/providers/i18n-provider.js'; import { ServerConfigProvider } from '@/vdb/providers/server-config.js'; import { ThemeProvider } from '@/vdb/providers/theme-provider.js'; import { UserSettingsProvider } from '@/vdb/providers/user-settings.js'; +import { CustomProviders } from '@/vdb/framework/extension-api/custom-providers.js'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; @@ -19,7 +20,9 @@ export function AppProviders({ children }: { children: React.ReactNode }) { - {children} + + {children} + diff --git a/packages/dashboard/src/lib/components/layout/app-layout.tsx b/packages/dashboard/src/lib/components/layout/app-layout.tsx index 9981c31570..4f1993e263 100644 --- a/packages/dashboard/src/lib/components/layout/app-layout.tsx +++ b/packages/dashboard/src/lib/components/layout/app-layout.tsx @@ -8,6 +8,7 @@ import { DashboardToolbarItemDefinition, ToolbarItemPosition, } from '@/vdb/framework/extension-api/types/toolbar.js'; +import { CustomProviders } from '@/vdb/framework/extension-api/custom-providers.js'; import { getToolbarItemRegistry } from '@/vdb/framework/toolbar/toolbar-extensions.js'; import { useUserSettings } from '@/vdb/hooks/use-user-settings.js'; import { Outlet } from '@tanstack/react-router'; @@ -150,7 +151,9 @@ export function AppLayout() { - + + + diff --git a/packages/dashboard/src/lib/framework/extension-api/custom-providers.ts b/packages/dashboard/src/lib/framework/extension-api/custom-providers.ts new file mode 100644 index 0000000000..a51d046eeb --- /dev/null +++ b/packages/dashboard/src/lib/framework/extension-api/custom-providers.ts @@ -0,0 +1,130 @@ +import { globalRegistry } from '@/vdb/framework/registry/global-registry.js'; +import { ComponentType, createElement, ReactNode, useMemo } from 'react'; + +import { useDashboardExtensions } from './use-dashboard-extensions.js'; + +/** + * @description + * Allows you to define custom React providers that wrap selected parts of the dashboard UI. + * This is useful for cross-cutting concerns such as custom context, error boundaries, + * feature flags, telemetry, or theming. + * + * Providers can be mounted at either the application root (`'app'`) or the authenticated + * layout main content area (`'layout'`, i.e. the `` subtree only; sidebar and + * header are outside this wrapper). + * + * @docsCategory extensions-api + * @docsPage Custom Providers + * @since 3.7.0 + */ +export type DashboardCustomProviderDefinition = { + /** + * @description + * A unique identifier for this custom provider. + */ + id: string; + /** + * @description + * The React provider component to render. It receives `children` and should + * return a wrapped subtree. + */ + component: ComponentType<{ children: ReactNode }>; + /** + * @description + * Optional. Controls render order relative to other providers at the same location. + * Lower numbers render first (outermost), higher numbers render later (innermost). + */ + order?: number; + /** + * @description + * Determines where this provider is mounted in the dashboard hierarchy. + * + * - `'app'`: Wraps the entire dashboard application at the root level. + * - `'layout'`: Wraps the main content area of the authenticated layout (the `` subtree). + * + * The sidebar and header are outside this wrapper. + * + * Optional. Defaults to 'app' if not specified. + */ + location?: 'app' | 'layout'; +}; + +globalRegistry.register( + 'dashboardCustomProvidersRegistry', + new Map(), +); + +export function getDashboardCustomProvidersRegistry() { + return globalRegistry.get('dashboardCustomProvidersRegistry'); +} + +export function registerDashboardCustomProvider(customProvider: DashboardCustomProviderDefinition) { + globalRegistry.set('dashboardCustomProvidersRegistry', map => { + map.set(customProvider.id, { + ...customProvider, + location: customProvider.location ?? 'app', + }); + return map; + }); +} + +export function registerDashboardCustomProviders(providers: DashboardCustomProviderDefinition[] | undefined) { + if (!providers?.length) { + return; + } + const registry = getDashboardCustomProvidersRegistry(); + const allIds = [...registry.keys(), ...providers.map(p => p.id)]; + const seen = new Set(); + const duplicateIds = new Set(); + for (const id of allIds) { + if (seen.has(id)) { + duplicateIds.add(id); + } else { + seen.add(id); + } + } + + if (duplicateIds.size) { + const duplicates = Array.from(duplicateIds).sort(); + throw new Error( + `Duplicate dashboard custom provider ids detected: ` + + `${duplicates.map(id => `"${id}"`).join(', ')}. ` + + `Provider ids must be globally unique.`, + ); + } + + for (const provider of providers) { + registerDashboardCustomProvider(provider); + } +} + +export const renderProviders = ( + providers: DashboardCustomProviderDefinition[], + children: ReactNode, +): ReactNode => { + if (providers.length === 0) { + return children; + } + + const [currentProvider, ...remainingProviders] = providers; + const ProviderComponent = currentProvider.component; + + return createElement(ProviderComponent, null, renderProviders(remainingProviders, children)); +}; + +export interface CustomProvidersProps { + location: DashboardCustomProviderDefinition['location']; + children: ReactNode; +} + +export function CustomProviders({ location, children }: Readonly) { + const { extensionsLoaded, reloadCount } = useDashboardExtensions(); + const providersToRender = useMemo(() => { + const customProviders = Array.from(getDashboardCustomProvidersRegistry().values()); + return customProviders + .filter(provider => provider.location === location) + .sort((a, b) => (a.order || 0) - (b.order || 0)); + }, [extensionsLoaded, reloadCount, location]); + + return renderProviders(providersToRender, children); +} diff --git a/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.spec.ts b/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.spec.ts index cbf81603e1..72c01f129d 100644 --- a/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.spec.ts +++ b/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.spec.ts @@ -1,3 +1,5 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -12,6 +14,7 @@ import { } from '../nav-menu/nav-menu-extensions.js'; import { globalRegistry } from '../registry/global-registry.js'; +import { getDashboardCustomProvidersRegistry, renderProviders } from './custom-providers.js'; import { defineDashboardExtension, executeDashboardExtensionCallbacks, @@ -29,6 +32,11 @@ function resetWidgetRegistry() { globalRegistry.set('dashboardWidgetRegistry', () => new Map()); } +function resetCustomProvidersRegistry() { + globalRegistry.set('dashboardCustomProvidersRegistry', () => new Map()); + (globalRegistry as any).registry.set('registerDashboardExtensionCallbacks', new Set<() => void>()); +} + describe('defineDashboardExtension - navSections', () => { beforeEach(() => { resetNavState(); @@ -297,3 +305,72 @@ describe('DashboardWidgetDefinition - requiresPermissions', () => { expect(widget?.requiresPermissions).toEqual([]); }); }); + +describe('Dashboard custom providers', () => { + beforeEach(() => { + resetCustomProvidersRegistry(); + }); + + it('renders providers recursively in nesting order', () => { + const ProviderA = ({ children }: { children: React.ReactNode }) => + createElement('section', { 'data-provider': 'a' }, children); + const ProviderB = ({ children }: { children: React.ReactNode }) => + createElement('section', { 'data-provider': 'b' }, children); + + const result = renderProviders( + [ + { id: 'provider-a', component: ProviderA, location: 'app' }, + { id: 'provider-b', component: ProviderB, location: 'app' }, + ], + createElement('div', { id: 'content' }, 'content'), + ); + + const html = renderToStaticMarkup(createElement('div', null, result)); + expect(html).toContain('
'); + expect(html).toContain('
content
'); + }); + + it('registers providers sorted by order (ascending)', () => { + const callOrder: string[] = []; + + const ProviderFirst = ({ children }: { children: React.ReactNode }) => { + callOrder.push('first'); + return createElement('section', { 'data-provider': 'first' }, children); + }; + const ProviderSecond = ({ children }: { children: React.ReactNode }) => { + callOrder.push('second'); + return createElement('section', { 'data-provider': 'second' }, children); + }; + + defineDashboardExtension({ + customProviders: [ + { id: 'second', component: ProviderSecond, order: 20, location: 'app' }, + { id: 'first', component: ProviderFirst, order: 10, location: 'app' }, + ], + }); + executeDashboardExtensionCallbacks(); + + const providers = Array.from(getDashboardCustomProvidersRegistry().values()).sort( + (a, b) => (a.order || 0) - (b.order || 0), + ); + const tree = renderProviders(providers, createElement('div', null, 'leaf')); + renderToStaticMarkup(createElement('div', null, tree)); + + expect(callOrder).toEqual(['first', 'second']); + }); + + it('throws when duplicate custom provider ids are registered', () => { + const Provider = ({ children }: { children: React.ReactNode }) => children; + + defineDashboardExtension({ + customProviders: [{ id: 'duplicate-provider', component: Provider }], + }); + defineDashboardExtension({ + customProviders: [{ id: 'duplicate-provider', component: Provider }], + }); + + expect(() => executeDashboardExtensionCallbacks()).toThrow( + 'Duplicate dashboard custom provider ids detected: "duplicate-provider". Provider ids must be globally unique.', + ); + }); +}); diff --git a/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts b/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts index a6f9ac6bf1..96d10e9670 100644 --- a/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts +++ b/packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts @@ -1,6 +1,7 @@ import { getNavMenuConfig, setNavMenuConfig } from '../nav-menu/nav-menu-extensions.js'; import { globalRegistry } from '../registry/global-registry.js'; +import { registerDashboardCustomProviders } from './custom-providers.js'; import { DashboardExtension } from './extension-api-types.js'; import { registerAlertExtensions, @@ -118,6 +119,9 @@ export function defineDashboardExtension(extension: DashboardExtension) { // Register custom history entry components registerHistoryEntryComponents(extension.historyEntries); + // Register dashboard custom providers + registerDashboardCustomProviders(extension.customProviders); + // Register toolbar extensions registerToolbarExtensions(extension.toolbarItems); diff --git a/packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts b/packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts index b1f0ece149..641d630491 100644 --- a/packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts +++ b/packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts @@ -1,6 +1,7 @@ // Import types for the main interface import { NavMenuConfig } from '../nav-menu/nav-menu-extensions.js'; +import { DashboardCustomProviderDefinition } from './custom-providers.js'; import { DashboardActionBarItem, DashboardAlertDefinition, @@ -128,4 +129,12 @@ export interface DashboardExtension { * @since 3.5.3 */ toolbarItems?: DashboardToolbarItemDefinition[]; + + /** + * @description + * Allows you to add custom providers in different locations. + * + * @since 3.7.0 + */ + customProviders?: DashboardCustomProviderDefinition[]; } diff --git a/packages/dashboard/src/lib/framework/registry/registry-types.ts b/packages/dashboard/src/lib/framework/registry/registry-types.ts index 13d5ad59d8..2a5dcf3d83 100644 --- a/packages/dashboard/src/lib/framework/registry/registry-types.ts +++ b/packages/dashboard/src/lib/framework/registry/registry-types.ts @@ -1,3 +1,4 @@ +import { DashboardCustomProviderDefinition } from '@/vdb/framework/extension-api/custom-providers.js'; import { BulkAction, DashboardActionBarItem, @@ -32,4 +33,5 @@ export interface GlobalRegistryContents { historyEntries: Map; navMenuModifiers: Array<(config: NavMenuConfig) => NavMenuConfig>; dashboardToolbarItemRegistry: Map; + dashboardCustomProvidersRegistry: Map; } diff --git a/packages/dashboard/src/lib/index.ts b/packages/dashboard/src/lib/index.ts index d929aa3192..c631b4a3af 100644 --- a/packages/dashboard/src/lib/index.ts +++ b/packages/dashboard/src/lib/index.ts @@ -231,6 +231,7 @@ export * from './framework/document-introspection/get-document-structure.js'; export * from './framework/document-introspection/hooks.js'; export * from './framework/document-introspection/include-only-selected-list-fields.js'; export * from './framework/document-introspection/testing-utils.js'; +export * from './framework/extension-api/custom-providers.js'; export * from './framework/extension-api/define-dashboard-extension.js'; export * from './framework/extension-api/display-component-extensions.js'; export * from './framework/extension-api/extension-api-types.js';