DeenruvDeenruv
Extending the Admin UI

Plugin System

Deep dive into the Deenruv admin UI plugin system — all extension points, location IDs, and folder conventions

The Deenruv admin UI plugin system allows you to extend virtually every part of the admin dashboard. Plugins are defined using the createDeenruvUIPlugin function, which provides full TypeScript type safety for all extension points.

createDeenruvUIPlugin

This is an identity function that validates and types your plugin definition:

import { createDeenruvUIPlugin } from '@deenruv/react-ui-devkit';

export const MyPlugin = createDeenruvUIPlugin({
  name: 'my-plugin-ui',
  version: '1.0.0',
  // ...extension points
});

Extension Points

The plugin definition supports 15 extension points:

Extension PointTypeDescription
namestringPlugin name (required)
versionstringPlugin version (required)
configTCustom plugin configuration object
pagesPluginPage[]Custom routes (auto-prefixed: admin-ui/extensions/{name}/{path})
tablesDeenruvUITable[]List view columns, row actions, bulk actions
tabsDeenruvTabs[]Detail view tabs
actions{ inline?, dropdown? }Detail view action buttons
componentsDeenruvUIDetailComponent[]Inject into detail views and sidebars
modalsDeenruvUIModalComponent[]Inject into modals
widgetsWidget[]Dashboard widgets
inputsPluginComponent[]Custom field input overrides
navMenuGroupsPluginNavigationGroup[]Navigation menu groups
navMenuLinksPluginNavigationLink[]Navigation links
notificationsNotification[]Polling notifications
translations{ ns, data }i18n translation bundles

Pages

Define custom routes for your plugin. Paths are automatically prefixed with admin-ui/extensions/{plugin-name}/:

pages: [
  { path: '', element: <MyListPage /> },         // /admin-ui/extensions/my-plugin-ui/
  { path: 'new', element: <MyCreatePage /> },     // /admin-ui/extensions/my-plugin-ui/new
  { path: ':id', element: <MyDetailPage /> },     // /admin-ui/extensions/my-plugin-ui/:id
],

Tables

Extend existing list views with additional columns, row actions, and bulk actions:

tables: [
  {
    locationId: 'products-list-view',
    columns: [
      {
        header: 'Review Score',
        accessorKey: 'customFields.reviewScore',
        cell: ({ row }) => <StarRating value={row.original.customFields?.reviewScore} />,
      },
    ],
    rowActions: [
      {
        label: 'View Reviews',
        onClick: (row) => navigate(`/reviews/${row.original.id}`),
      },
    ],
    bulkActions: [
      {
        label: 'Export Reviews',
        onClick: (selectedRows) => exportReviews(selectedRows),
      },
    ],
  },
],

Tabs

Add custom tabs to detail views:

tabs: [
  {
    locationId: 'products-detail-view',
    tab: {
      id: 'reviews',
      label: 'Reviews',
      component: ProductReviewsTab,
    },
    hideSidebar: false,              // Hide the sidebar when this tab is active
    sidebarReplacement: null,        // Replace sidebar content
    disabled: false,                 // Disable the tab
  },
],

Actions

Add action buttons to detail views:

actions: {
  inline: [
    {
      locationId: 'products-detail-view',
      component: ({ entity }) => (
        <Button onClick={() => generateReport(entity.id)}>
          Generate Report
        </Button>
      ),
    },
  ],
  dropdown: [
    {
      locationId: 'orders-detail-view',
      label: 'Send custom email',
      onClick: (entity) => sendEmail(entity.id),
    },
  ],
},

Components

Inject custom components into detail views and sidebars:

components: [
  // Inject into the main area of the product detail view
  {
    id: 'products-detail-view',
    tab: 'product',                    // Only show on a specific tab
    component: MyProductComponent,
  },
  // Inject into the sidebar (append -sidebar to the location ID)
  {
    id: 'products-detail-view-sidebar',
    tab: 'product',
    component: MyProductSidebar,
  },
],

To inject into a detail view sidebar, append -sidebar to the location ID. You can optionally filter by tab to only show the component on a specific tab.

Modals

Inject components into admin panel modals:

modals: [
  {
    id: 'manual-order-state',
    component: MyOrderStateComponent,
  },
],

Widgets

Register dashboard widgets:

widgets: [
  {
    id: 'sales-chart',
    title: 'Sales Overview',
    component: SalesChartWidget,
    size: { width: 2, height: 1 },
    sizes: [
      { width: 1, height: 1 },
      { width: 2, height: 1 },
      { width: 2, height: 2 },
    ],
  },
],

Inputs

Override custom field inputs with your own components:

inputs: [
  {
    id: 'my-custom-color-picker',
    component: ColorPickerInput,
  },
],

Add navigation groups and links:

navMenuGroups: [
  {
    id: 'my-plugin-group',
    label: 'My Plugin',
    placement: 'after:assortment-group',
  },
],
navMenuLinks: [
  {
    id: 'my-plugin-link',
    labelId: 'nav.myPlugin',
    href: '',
    groupId: 'assortment-group',  // Use BASE_GROUP_ID values
    icon: ListIcon,
  },
],

Top Navigation

Add components to the top navigation bar:

topNavigationComponents: [
  { id: 'my-status-indicator', component: StatusIndicator },
],
topNavigationActionsMenu: [
  { label: 'Quick Action', onClick: () => doSomething(), icon: ZapIcon },
],

Notifications

Register polling-based notifications:

notifications: [
  {
    id: 'pending-reviews',
    fetch: async () => {
      const result = await apiClient('query')({
        pendingReviews: [
          {},
          { totalItems: true },
        ],
      });
      return { count: result.pendingReviews.totalItems };
    },
    interval: 30000,   // Poll every 30 seconds
    placements: {
      main: (data) => ({
        name: 'pending-reviews',
        title: 'Pending Reviews',
        description: `${data.count} reviews need approval`,
        icon: <StarIcon />,
        when: (data) => data.count > 0,
      }),
      navigation: [
        {
          id: 'my-plugin-link',
          component: (data) => <Badge>{data.count}</Badge>,
        },
      ],
    },
  },
],

Translations

Register i18n translation bundles for your plugin:

translations: {
  ns: 'my-plugin',
  data: {
    en: { 'my-plugin': { nav: { myPlugin: 'My Plugin' } } },
    pl: { 'my-plugin': { nav: { myPlugin: 'Moja wtyczka' } } },
  },
},

Location IDs

Location IDs are string identifiers that target specific views in the admin panel.

List Locations (21)

Used with tables to extend list views:

Location IDEntity Type
assets-list-viewAsset
admins-list-viewAdministrator
channels-list-viewChannel
collections-list-viewCollection
countries-list-viewCountry
customerGroups-list-viewCustomerGroup
customers-list-viewCustomer
facets-list-viewFacet
facet-values-listFacetValue
orders-list-viewOrder
paymentMethods-list-viewPaymentMethod
products-list-viewProduct
productVariants-list-viewProductVariant
promotions-list-viewPromotion
roles-list-viewRole
sellers-list-viewSeller
shippingMethods-list-viewShippingMethod
stockLocations-listStockLocation
stockLocations-list-viewStockLocation
taxCategories-list-viewTaxCategory
taxRates-list-viewTaxRate
zones-list-viewZone

Detail Locations (20)

Used with tabs, actions, and components to extend detail views:

Location IDEntity Type
admins-detail-viewAdministrator
channels-detail-viewChannel
collections-detail-viewCollection
countries-detail-viewCountry
customerGroups-detail-viewCustomerGroup
customers-detail-viewCustomer
facets-detail-viewFacet
globalSettings-detail-viewGlobalSettings
orders-detail-viewOrder
orders-summaryOrder
paymentMethods-detail-viewPaymentMethod
products-detail-viewProduct
promotions-detail-viewPromotion
roles-detail-viewRole
sellers-detail-viewSeller
shippingMethods-detail-viewShippingMethod
stockLocations-detail-viewStockLocation
taxCategories-detail-viewTaxCategory
taxRates-detail-viewTaxRate
zones-detail-viewZone

To inject into the sidebar of a detail view, append -sidebar to the location ID. For example, products-detail-view-sidebar.

Location IDType
manual-order-stateOrder state transition modal

Use the BASE_GROUP_ID enum values to target existing navigation groups:

import { BASE_GROUP_ID } from '@deenruv/react-ui-devkit';

// Available group IDs:
BASE_GROUP_ID.SHOP          // 'shop-group'
BASE_GROUP_ID.ASSORTMENT    // 'assortment-group'
BASE_GROUP_ID.USERS         // 'users-group'
BASE_GROUP_ID.PROMOTIONS    // 'promotions-group'
BASE_GROUP_ID.SHIPPING      // 'shipping-group'
BASE_GROUP_ID.SETTINGS      // 'settings-group'

Full Plugin Example

Here is a complete example of a plugin that uses all major extension points:

src/plugin-ui/index.tsx
import { createDeenruvUIPlugin, BASE_GROUP_ID, apiClient } from '@deenruv/react-ui-devkit';
import { StarIcon, ListIcon } from 'lucide-react';
import { ReviewsListPage } from './pages/ReviewsListPage';
import { ReviewDetailPage } from './pages/ReviewDetailPage';
import { ProductReviewsTab } from './components/ProductReviewsTab';
import { ReviewsSidebar } from './components/ReviewsSidebar';
import { ReviewsWidget } from './components/ReviewsWidget';
import en from './locales/en';
import pl from './locales/pl';

const PLUGIN_NAME = 'reviews-plugin-ui';

export const ReviewsPlugin = createDeenruvUIPlugin({
  name: PLUGIN_NAME,
  version: '1.0.0',

  translations: {
    ns: 'reviews',
    data: { en, pl },
  },

  pages: [
    { path: '', element: <ReviewsListPage /> },
    { path: ':id', element: <ReviewDetailPage /> },
  ],

  navMenuLinks: [
    {
      id: 'reviews-link',
      labelId: 'nav.reviews',
      href: '',
      groupId: BASE_GROUP_ID.ASSORTMENT,
      icon: StarIcon,
    },
  ],

  tabs: [
    {
      locationId: 'products-detail-view',
      tab: {
        id: 'reviews',
        label: 'Reviews',
        component: ProductReviewsTab,
      },
    },
  ],

  components: [
    {
      id: 'products-detail-view-sidebar',
      tab: 'reviews',
      component: ReviewsSidebar,
    },
  ],

  widgets: [
    {
      id: 'recent-reviews',
      title: 'Recent Reviews',
      component: ReviewsWidget,
      size: { width: 2, height: 1 },
    },
  ],

  notifications: [
    {
      id: 'pending-reviews',
      fetch: async () => {
        const result = await apiClient('query')({
          pendingReviews: [{}, { totalItems: true }],
        });
        return { count: result.pendingReviews.totalItems };
      },
      interval: 30000,
      placements: {
        main: (data) => ({
          name: 'pending-reviews',
          title: 'Pending Reviews',
          description: `${data.count} reviews awaiting approval`,
          icon: <StarIcon />,
          when: (data) => data.count > 0,
        }),
      },
    },
  ],
});

Plugin View Markers

Press Ctrl+Q in the admin panel to toggle plugin view markers. This highlights all injection points in the current view, making it easy to see where your plugin components will be rendered.

Plugin Registry & Env Toggle

How It Works

The admin panel uses a central manifest at apps/panel/src/plugins/registry.ts to declare every available UI plugin. Each manifest entry has:

FieldTypeDescription
idstringUnique identifier used in the env var
pluginDeenruvUIPluginThe plugin instance (imported from the plugin package)
enabledByDefaultbooleanWhether this plugin loads when the env var is unset

At build time, apps/panel/src/plugins/enabled.ts reads the VITE_ADMIN_UI_PLUGINS environment variable and resolves which plugins to activate.

VITE_ADMIN_UI_PLUGINS Semantics

ValueBehaviour
unset (undefined)Only plugins with enabledByDefault: true are loaded
"" (empty string)No plugins are loaded
"all" or "*"Every plugin in the manifest is loaded
CSV list (e.g. "dashboard-widgets,badges")Only the listed IDs are loaded

Unknown IDs (typos, removed plugins) trigger a console warning listing the available IDs.

# Examples
VITE_ADMIN_UI_PLUGINS="dashboard-widgets,badges" pnpm start:admin-ui
VITE_ADMIN_UI_PLUGINS="all" pnpm start:admin-ui
VITE_ADMIN_UI_PLUGINS="" pnpm start:admin-ui   # no plugins

Adding a Plugin to the Manifest

  1. Install the plugin package in apps/panel/package.json.
  2. Import its UI plugin export at the top of apps/panel/src/plugins/registry.ts.
  3. Add an entry to the pluginManifest array with a unique id.
apps/panel/src/plugins/registry.ts
import { BadgesUiPlugin } from '@deenruv/product-badges-plugin/plugin-ui';

export const pluginManifest = [
  // ...existing entries
  { id: 'badges', plugin: BadgesUiPlugin, enabledByDefault: false },
];

Coexistence with Server-Side Plugin Registration

The manifest in registry.ts controls only the admin UI side. Server-side plugins are registered separately in your DeenruvConfig.plugins array. A typical setup has both:

  • Server pluginDeenruvConfig.plugins (handles GraphQL extensions, services, etc.)
  • UI pluginpluginManifest in registry.ts (handles admin panel UI extensions)

Both sides are independent — you can have a server plugin without a UI plugin and vice versa.

On this page