Widget Injection System
The Widget Injection System provides a centralized mechanism for injecting custom widgets into admin pages, similar to dashboard widgets but generalized for any location in the admin UI.
Overview
The widget injection system allows modules to:
- Define reusable widgets with standardized event handlers
- Map widgets to specific injection spots (e.g., CRUD forms, detail pages)
- Respond to lifecycle events (
onLoad,onBeforeSave,onSave,onAfterSave) - Block or augment standard behaviors (e.g., validation, side effects)
- Render multiple widgets per spot with placement hints (stacked, grouped cards, or tabs)
Architecture
Key Components
- Injection Widgets - React components with event handlers
- Injection Tables - Mappings of spot IDs to widget IDs
- Injection Spots - Locations where widgets can be injected
- Event Handlers - Lifecycle hooks for widget behavior
UMES Phase A and B
Phase A and B add two foundational capabilities:
- Phase A (Foundation): typed placement (
InjectionPosition) and headless widget loading (useInjectionDataWidgets) for non-visual extension payloads such as menu items. - Phase B (Menu Injection): menu injection widgets (
InjectionMenuItemWidget) rendered in app chrome without patching core navigation.
Menu surfaces (Phase B)
Use these spot IDs in widgets/injection-table.ts:
menu:sidebar:mainmenu:sidebar:settingsmenu:sidebar:profilemenu:topbar:profile-dropdownmenu:topbar:actions
You can also target scoped IDs such as menu:sidebar:settings:<sectionId>.
Positioning injected items
Use placement on each menu item:
import { InjectionPosition } from '@saasframe/shared/modules/widgets/injection-position'
placement: { position: InjectionPosition.Before, relativeTo: 'sign-out' }
Supported positions:
FirstLastBefore(requiresrelativeTo)After(requiresrelativeTo)
If relativeTo cannot be resolved, the item is appended.
i18n requirements for injected menus
Every user-facing injected menu item should include:
labelKey: translation keylabel: localized fallback text
For grouped sidebar/profile items:
groupLabelKey: translation key for group labelgroupLabel: optional fallback text
Example:
const widget: InjectionMenuItemWidget = {
metadata: { id: 'example.injection.example-menus' },
menuItems: [
{
id: 'example-todos-shortcut',
labelKey: 'example.menu.todosShortcut',
label: 'Example Todos',
href: '/backend/todos',
icon: 'CheckSquare',
features: ['example.todos.view'],
groupId: 'example.nav.group',
groupLabelKey: 'example.nav.group',
placement: { position: InjectionPosition.Before, relativeTo: 'sign-out' },
},
{
id: 'example-quick-add-todo',
labelKey: 'example.menu.quickAddTodo',
label: 'Quick Add Todo',
href: '/backend/todos/create',
icon: 'PlusSquare',
features: ['example.todos.manage'],
placement: { position: InjectionPosition.Before, relativeTo: 'sign-out' },
},
],
}
Separators and relative insertion
Use separator: true to render visual separators for dropdown-style hosts. Combine with placement to insert separators before/after specific built-in items.
UMES Phase C — Events & DOM Bridge
Phase C adds real-time server-to-client event delivery and richer widget lifecycle handlers.
DOM Event Bridge (SSE)
Server-side events marked with clientBroadcast: true are automatically bridged to the browser via Server-Sent Events (SSE). The flow:
- Server emits event via event bus (e.g.,
example.todo.created) - SSE endpoint (
/api/events/stream) server-filters audience and emits only matching events to each connection - Client
useEventBridge()hook (mounted inAppShell) receives the event - Dispatches a
om:eventCustomEvent onwindow - Widget
useAppEventhooks receive and react
Audience filtering is enforced server-side using event payload fields:
tenantId(required)organizationIdororganizationIdsrecipientUserIdorrecipientUserIdsrecipientRoleIdorrecipientRoleIds
When any provided audience field does not match the connection (tenant, selected organization, authenticated user, role set), the event is dropped before delivery.
Enabling broadcast on events
In your module's events.ts, add clientBroadcast: true:
export const eventsConfig = createModuleEvents('example', {
todo: {
created: { clientBroadcast: true },
updated: { clientBroadcast: true },
deleted: { clientBroadcast: true },
},
} as const)
Consuming events in widgets
import { useAppEvent } from '@saasframe/ui/backend/injection/useAppEvent'
// Wildcard — matches example.todo.created, example.todo.updated, etc.
useAppEvent('example.todo.*', (event) => {
console.log('Todo event:', event.id, event.payload)
refreshData()
})
// Exact match
useAppEvent('example.todo.created', (event) => {
flash('New todo created!', 'info')
})
Extended Widget Event Handlers (Phase C)
Widgets now support additional lifecycle handlers beyond the original save/delete/load events:
| Handler | Type | Description |
|---|---|---|
onFieldChange | Action | Fires when a form field changes. Can return a warning message. |
onBeforeNavigate | Action | Navigation guard — return { ok: false } to block. |
onVisibilityChange | Action | Fires when widget visibility changes. |
onAppEvent | Action | Fires when a matching SSE event arrives. |
transformFormData | Transformer | Pipeline: transform data before save. |
transformDisplayData | Transformer | Pipeline: transform data before display. |
transformValidation | Transformer | Pipeline: transform validation errors. |
Action handlers fire independently for each widget. Transformer handlers run as a pipeline — the output of widget N becomes the input of widget N+1.
Example: Action handlers
const widget: InjectionWidgetModule = {
metadata: { id: 'example.injection.phase-c-actions', /* ... */ },
Widget: ActionWidget,
eventHandlers: {
onFieldChange: async (fieldId, value) => {
if (fieldId === 'title' && typeof value === 'string' && value.toUpperCase().includes('TEST')) {
return {
message: { text: 'Title contains "TEST" — is this intentional?', severity: 'warning' },
}
}
},
onBeforeNavigate: async (target) => {
if (target.includes('/blocked')) {
return { ok: false, message: 'Navigation blocked by widget policy' }
}
return { ok: true }
},
onVisibilityChange: async (visible, context) => {
context.sharedState?.set('lastVisibility', visible)
},
onAppEvent: async (event, context) => {
context.sharedState?.set('lastEventId', event.id)
},
},
}
Example: Transformer handlers
const widget: InjectionWidgetModule = {
metadata: { id: 'example.injection.phase-c-transformers', /* ... */ },
Widget: TransformerWidget,
eventHandlers: {
transformFormData: async (data, context) => {
const trimmed = { ...data }
for (const [key, value] of Object.entries(trimmed)) {
if (typeof value === 'string') trimmed[key] = value.trim()
}
return trimmed
},
transformDisplayData: async (data) => {
return {
...data,
title: typeof data.title === 'string' ? data.title.toUpperCase() : data.title,
}
},
transformValidation: async (errors) => {
if (!errors.title) return errors
return { ...errors }
},
},
}
Integration Test Coverage (Phase C)
Use a small test harness page that mounts the widget and calls useInjectionSpotEvents(...) to trigger each handler deterministically.
Recommended Playwright coverage:
| Test ID | What to verify |
|---|---|
TC-UMES-E03 | onFieldChange updates warning state when title contains TEST. |
TC-UMES-E04 | transformFormData trims input (" x " → "x"). |
TC-UMES-E07 | onBeforeNavigate blocks /blocked and allows safe targets. |
TC-UMES-E08 | onVisibilityChange stores latest visibility state. |
TC-UMES-E09 | onAppEvent runs when om:event (example.todo.created) is dispatched. |
TC-UMES-E10 | transformDisplayData and transformValidation mutate output as expected. |
How to run:
npx playwright test --config .ai/qa/tests/playwright.config.ts apps/saasframe/src/modules/example/__integration__/TC-UMES-003.spec.ts
CrudForm Env Toggle
CrudForm emits Phase C handlers by default.
Set this variable to disable automatic emission:
NEXT_PUBLIC_SF_CRUDFORM_EXTENDED_EVENTS_ENABLED=false
When enabled (true, default), CrudForm emits:
onFieldChangeon field writes (setValue)onBeforeNavigatebefore guarded in-form navigation and CrudForm redirectsonVisibilityChangeon browservisibilitychangeonAppEventwhenom:eventis receivedtransformFormDatabefore save pipelinetransformDisplayDatawhen initial form values are appliedtransformValidationbefore setting form field errors
Example Injection Widgets Toggle
The example module ships demo injection widgets (CRUD validation panel, sales todos tab, catalog SEO report, sample menu items).
These widgets are disabled by default. Enable them with:
NEXT_PUBLIC_SF_EXAMPLE_INJECTION_WIDGETS_ENABLED=true
Default:
NEXT_PUBLIC_SF_EXAMPLE_INJECTION_WIDGETS_ENABLED=false
Operation Progress Tracking
Track long-running server operations in widgets:
import { useOperationProgress } from '@saasframe/ui/backend/injection/useOperationProgress'
function ImportProgressWidget() {
const progress = useOperationProgress('catalog.import.*')
if (progress.status === 'idle') return null
return <ProgressBar value={progress.progress} label={progress.currentStep} />
}
UMES Phase D — Response Enrichers
Phase D adds a federation-like data enrichment system. Modules can inject computed fields into other modules' API responses without modifying core code.
How It Works
- Module defines enrichers in
data/enrichers.ts - Generator discovers and registers them at bootstrap
- CRUD factory calls enrichers after
afterListhook, before HTTP response - Enriched fields appear under a
_<moduleName>namespace - Response includes
_meta.enrichedBytracking which enrichers ran
Creating a Response Enricher
Create src/modules/<module>/data/enrichers.ts:
import type { ResponseEnricher } from '@saasframe/shared/lib/crud/response-enricher'
const customerTodoCount: ResponseEnricher = {
id: 'example.customer-todo-count',
targetEntity: 'customers.person', // which entity to enrich
features: ['example.view'], // ACL gating
priority: 10, // higher = runs first
timeout: 2000, // max ms per enricher
fallback: { _example: { todoCount: 0 } }, // used on timeout/error
async enrichOne(record, context) {
const em = context.em.fork()
const count = await em.count(Todo, { organizationId: context.organizationId })
return { ...record, _example: { todoCount: count } }
},
// MUST implement for list endpoints (prevents N+1 queries)
async enrichMany(records, context) {
const em = context.em.fork()
const todos = await em.find(Todo, { organizationId: context.organizationId })
return records.map(r => ({ ...r, _example: { todoCount: todos.length } }))
},
}
export const enrichers: ResponseEnricher[] = [customerTodoCount]
Opting In on CRUD Routes
Add enrichers to your makeCrudRoute call:
export const { metadata, GET, POST, PUT, DELETE } = makeCrudRoute({
// ... other config
enrichers: { entityId: 'customers.person' },
})
API Response Shape
{
"items": [
{
"id": "abc-123",
"firstName": "John",
"_example": { "todoCount": 5, "openTodoCount": 3 }
}
],
"_meta": {
"enrichedBy": ["example.customer-todo-count"]
}
}
Key Rules
- Enriched fields MUST be namespaced under
_<moduleName>(e.g.,_example.todoCount) - Enrichers are additive-only — never modify or remove existing fields
enrichMany()is mandatory for list endpoints (batch queries prevent N+1)- EntityManager usage is read-only — no writes in enrichers
- Non-critical enrichers fail silently (use
fallback); setcritical: trueto propagate errors - Export paths automatically strip enricher fields (anything prefixed with
_) - Keep
cacheableOnListHitat itsfalsedefault unless the enriched output is record-pure — see below
List Cache Interaction (cacheableOnListHit)
When the opt-in CRUD list cache (ENABLE_CRUD_API_CACHE) is on, the factory stores the enriched list payload and partitions cache entries by the set of enrichers active for the caller (after ACL + tenant filtering). What happens on a cache hit depends on whether the active enrichers are cacheable:
- If every active enricher sets
cacheableOnListHit: true, the hit serves the stored enriched fields directly without re-running enrichers — this is the fast path that makes list caching actually eliminate enrichment cost. - Otherwise the hit re-runs all active enrichers and the cache stores only the base (pre-enrichment) payload, so non-cacheable enrichers always reflect current data.
const customerStatusBadge: ResponseEnricher = {
id: 'example.customer-status-badge',
targetEntity: 'customers.person',
cacheableOnListHit: true, // pure function of the record's own cached fields → safe on a hit
// ...
}
The shipped example.customer-todo-count enricher, by contrast, reads other modules' tables, so it keeps the false default and re-runs on every hit.
Set cacheableOnListHit: true only when the enricher's output for a record is a pure function of that record's own cached state and is invalidated together with it. Leave it false (the fail-closed default) for any enricher whose output depends on data the list cache does not invalidate on:
- cross-module / cross-entity reads (e.g. a product image fetched for a sales line),
- wall-clock-relative values (e.g. "days in stage"),
- aggregates over other tables.
Opting in on such an enricher would serve stale values from the shared cache entry on a hit.
Directory Structure
src/modules/<module>/
├── data/
│ └── enrichers.ts # Response enrichers (auto-discovered)
├── widgets/
│ ├── injection/
│ │ └── <widget-name>/
│ │ ├── widget.ts # Widget definition & event handlers
│ │ └── widget.client.tsx # React component (client-side)
│ └── injection-table.ts # Spot ID → Widget ID mappings
Built-in Injection Spots
- CRUD forms:
crud-form:<entityId>(automatically derived fromentityId/entityIdspassed toCrudForm). Widgets can requestplacement.kind: 'group'to render as a side-card andcolumn: 2to appear in the right column. Field injection spot:crud-form:<entityId>:fields. - Data tables:
data-table:<tableId>(or passinjectionSpotIdtoDataTable). Header/footer child spots::header,:footer. Deep extension spots::columns,:row-actions,:bulk-actions,:filters,:toolbar,:search-trailing.:toolbarrenders inside the right-side actions row alongside Refresh / Filters / Columns / Export. Suitable for full-sized toolbar buttons.:search-trailingrenders inside theFilterBar, immediately to the right of the search input on the same row. Reserve it for compact triggers — AI assistants, saved-view shortcuts, focus toggles. Full-width or multi-action toolbars belong in:toolbaror:header. The slot is automatically suppressed when the host DataTable does not render a search input. UseButton variant="outline"(default size, h-9,rounded-md) with a single leading icon plus a short caption (e.g.AI) so the trigger matches the search input'sh-9row height and the toolbar's standard rounded-rectangle button radius. Resolve the spot id withDataTableInjectionSpots.searchTrailing(tableId)from@saasframe/ui/backend/injection/spotIds.
- Backend record context:
backend:record:current(mounted once per backend page with{ path, query }context). - Backend layout:
backend:layout:top,backend:layout:footer(top and bottom of the main backend content area). - Backend sidebar:
backend:sidebar:top,backend:sidebar:footer(desktop sidebar top/bottom areas inAppShell).
Replacement Handles
Component replacement uses stable handle IDs:
page:<path>for backend page-level replacement (for examplepage:/backend/customers/people)data-table:<tableId>for eachDataTableinstancecrud-form:<entityId>for eachCrudForminstancesection:<scope>.<sectionName>for detail sections (for examplesection:ui.detail.NotesSection)- Module overrides are auto-discovered from
widgets/components.ts(componentOverrides) and generated to.saasframe/generated/component-overrides.generated.ts. - Shared detail sections expose stable handles:
section:ui.detail.NotesSectionsection:ui.detail.ActivitiesSectionsection:ui.detail.AddressesSectionsection:ui.detail.TagsSectionsection:ui.detail.CustomDataSectionsection:ui.detail.DetailFieldsSectionsection:ui.detail.AttachmentsSection
- Admin layout wrapper:
admin.page:<path-handle>:before|afterfromPageInjectionBoundary(wraps every backend page). - Global backend mutations:
GLOBAL_MUTATION_INJECTION_SPOT_IDresolves tobackend:record:currentfor non-CrudFormsave hooks.backend-mutation:globalis still mounted inAppShellas a legacy compatibility slot.
UMES Phase L — Integration Extension Widgets
Phase L adds three specialized widget types for building integration modules: a multi-step wizard, pollable status badges, and an external ID mapping display.
InjectionWizard
A multi-step wizard component for integration onboarding flows (OAuth setup, API credential entry, sync configuration).
Step definition
Each step is an InjectionWizardStep with:
id— unique step identifierlabel— displayed in the step indicator (supports i18n keys)description— optional subtitle textfields— optional array ofInjectedFielddefinitions (text, select, etc.)validate— optional async function returning{ ok, message?, fieldErrors? }customComponent— optional React component for fully custom step content
Usage
import { InjectionWizard } from '@saasframe/ui/backend/injection/InjectionWizard'
import type { InjectionWizardWidget, InjectionContext } from '@saasframe/shared/modules/widgets/injection'
const widget: InjectionWizardWidget = {
metadata: { id: 'my_module.integration.setup-wizard', title: 'Integration Setup' },
kind: 'wizard',
steps: [
{
id: 'credentials',
label: 'Credentials',
fields: [
{ id: 'apiKey', label: 'API Key', type: 'text' },
{ id: 'apiSecret', label: 'API Secret', type: 'text' },
],
validate: async (data) => {
if (!data.apiKey || !data.apiSecret) {
return { ok: false, message: 'Both fields are required.' }
}
return { ok: true }
},
},
{ id: 'scope', label: 'Scope', fields: [/* ... */] },
],
onComplete: async (allData, context) => {
// Save integration config
},
}
<InjectionWizard widget={widget} context={context} />
The wizard renders a numbered step indicator with connecting lines, validates each step before allowing progression, and calls onComplete with accumulated data from all steps. Escape cancels the wizard.
StatusBadgeRenderer
A pollable status badge for displaying integration or service health.
Badge definition
An InjectionStatusBadgeWidget contains a badge object with:
label— display text (supports i18n)statusLoader— async function returning{ status, tooltip?, count? }pollInterval— refresh interval in seconds (default: 60)href— optional link target
Status values and their colors:
| Status | Color |
|---|---|
healthy | Green |
warning | Yellow |
error | Red |
unknown | Gray |
Usage
import { StatusBadgeRenderer } from '@saasframe/ui/backend/injection/StatusBadgeRenderer'
import type { InjectionStatusBadgeWidget, StatusBadgeContext } from '@saasframe/shared/modules/widgets/injection'
const widget: InjectionStatusBadgeWidget = {
metadata: { id: 'my_module.integration.sync-status' },
kind: 'status-badge',
badge: {
label: 'Sync Engine',
pollInterval: 30,
statusLoader: async (ctx) => {
const res = await fetch('/api/integrations/health')
const data = await res.json()
return { status: data.healthy ? 'healthy' : 'error', tooltip: data.message }
},
},
}
<StatusBadgeRenderer widget={widget} context={context} />
ExternalIdsWidget
Displays external system ID mappings from the _integrations namespace added by the external ID mapping enricher (see Data extensibility).
Each row shows:
- Provider name — resolved via
getIntegrationTitle()from the integration registry - External ID — monospace code badge
- Sync status — color-coded dot (
synced/pending/error/not_synced) - External link — icon linking to the record in the external system (when
buildExternalUrlis registered)
The widget reads data._integrations (keyed by integration ID) and renders automatically when enriched data is available. It is registered as an injection widget at integrations.injection.external-ids.
Widget source: packages/core/src/modules/integrations/widgets/injection/external-ids/widget.client.tsx
Creating an Injection Widget
1. Define the Widget
Create src/modules/<module>/widgets/injection/<widget-name>/widget.ts:
import type { InjectionWidgetModule } from '@saasframe/shared/modules/widgets/injection'
import MyWidgetClient from './widget.client'
const widget: InjectionWidgetModule<ContextType, DataType> = {
metadata: {
id: 'module.injection.widget-name',
title: 'Widget Title',
description: 'Widget description',
features: ['module.feature'],
priority: 100,
enabled: true,
},
Widget: MyWidgetClient,
eventHandlers: {
onLoad: async (context) => {
// Called when widget loads
console.log('Widget loaded', context)
},
onBeforeSave: async (data, context) => {
// Called before save action
// Return false to prevent save, or provide a message/field errors
if (!isValid(data)) {
return {
ok: false,
message: 'Title is required before saving',
fieldErrors: { title: 'Title is required' },
}
}
return { ok: true }
},
onSave: async (data, context) => {
// Called during save action
console.log('Saving', data)
},
onAfterSave: async (data, context) => {
// Called after successful save
console.log('Saved', data)
},
},
}
export default widget
2. Create the Widget Component
Create src/modules/<module>/widgets/injection/<widget-name>/widget.client.tsx:
"use client"
import * as React from 'react'
import type { InjectionWidgetComponentProps } from '@saasframe/shared/modules/widgets/injection'
export default function MyWidget({ context, data, onDataChange, disabled }: InjectionWidgetComponentProps) {
return (
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm">
<div className="font-medium text-blue-900">My Widget</div>
<div className="text-blue-700 mt-1">
Custom content here. Context: {JSON.stringify(context)}
</div>
{disabled && <div className="text-xs text-gray-500 mt-1">Saving...</div>}
</div>
)
}
3. Register in Injection Table
Create or edit src/modules/<module>/widgets/injection-table.ts:
import type { ModuleInjectionTable } from '@saasframe/shared/modules/widgets/injection'
export const injectionTable: ModuleInjectionTable = {
// Map injection spot IDs to widget IDs
'crud-form:catalog.product': 'module.injection.widget-name',
// Can also inject multiple widgets
'crud-form:catalog.variant': [
'module.injection.widget-name',
'module.injection.another-widget',
],
}
export default injectionTable
Using Injection Spots in CRUD Forms
The CrudForm component automatically supports widget injection:
<CrudForm
fields={fields}
onSubmit={handleSubmit}
injectionSpotId="crud-form:catalog.product"
// ... other props
/>
Standard Injection Spot IDs
Use the helper functions to generate consistent spot IDs:
import { generateCrudFormInjectionSpotId, CrudFormInjectionSpots } from '@saasframe/ui/backend/injection/helpers'
// Basic form spot
const spotId = generateCrudFormInjectionSpotId('catalog.product')
// Result: 'crud-form:catalog.product'
// Specific locations
const beforeFieldsSpot = CrudFormInjectionSpots.beforeFields('catalog.product')
// Result: 'crud-form:catalog.product:before-fields'
const afterFieldsSpot = CrudFormInjectionSpots.afterFields('catalog.product')
// Result: 'crud-form:catalog.product:after-fields'
Event Handler Reference
onLoad
Called when the widget is first mounted.
Signature:
onLoad?: (context: TContext) => void | Promise<void>
Use Cases:
- Initialize widget state
- Fetch additional data
- Register listeners
onBeforeSave
Called before a save action is executed. Can prevent the save by returning false or throwing an error.
Signature:
onBeforeSave?: (data: TData, context: TContext) => boolean | { ok?: boolean; message?: string; fieldErrors?: Record<string, string> } | void | Promise<boolean | { ok?: boolean; message?: string; fieldErrors?: Record<string, string> } | void>
Use Cases:
- Validation
- Confirmation dialogs
- Data transformation
- Blocking invalid operations
Example:
onBeforeSave: async (data, context) => {
if (!data.title || data.title.length < 10) {
alert('Title must be at least 10 characters')
return false // Prevent save
}
return true // Allow save
}
onSave
Called when save action is triggered (alongside the main save operation).
Signature:
onSave?: (data: TData, context: TContext) => void | Promise<void>
Use Cases:
- Side effects during save
- Logging
- Analytics
onAfterSave
Called after save completes successfully.
Signature:
onAfterSave?: (data: TData, context: TContext) => void | Promise<void>
Use Cases:
- Success notifications
- Cache invalidation
- Related data updates
Advanced Usage
Using the Injection Spot Hook
For custom components that need to trigger injection widget events:
import { useInjectionSpotEvents } from '@saasframe/ui/backend/injection/InjectionSpot'
function MyCustomForm() {
const { triggerEvent } = useInjectionSpotEvents('crud-form:my.form')
const handleSave = async (data) => {
// Trigger onBeforeSave
const canProceed = await triggerEvent('onBeforeSave', data, context)
if (!canProceed) {
return // Blocked by widget
}
// Trigger onSave
await triggerEvent('onSave', data, context)
// Perform save
await saveData(data)
// Trigger onAfterSave
await triggerEvent('onAfterSave', data, context)
}
return <form onSubmit={handleSave}>...</form>
}
Global Mutation Hook for Non-CrudForm Screens
For backend pages that do not use CrudForm (for example custom detail screens), use the global mutation spot and emit the generic mutation error event.
import { useInjectionSpotEvents } from '@saasframe/ui/backend/injection/InjectionSpot'
import {
GLOBAL_MUTATION_INJECTION_SPOT_ID,
dispatchBackendMutationError,
} from '@saasframe/ui/backend/injection/mutationEvents'
import { withScopedApiRequestHeaders } from '@saasframe/ui/backend/utils/apiCall'
const { triggerEvent } = useInjectionSpotEvents(GLOBAL_MUTATION_INJECTION_SPOT_ID)
async function runMutation(operation: () => Promise<unknown>, payload: Record<string, unknown>, context: Record<string, unknown>) {
const beforeSave = await triggerEvent('onBeforeSave', payload, context)
if (!beforeSave.ok) {
dispatchBackendMutationError({ contextId: context.formId as string, error: beforeSave.details ?? beforeSave })
throw new Error(beforeSave.message ?? 'Save blocked by validation')
}
try {
const result =
beforeSave.requestHeaders && Object.keys(beforeSave.requestHeaders).length > 0
? await withScopedApiRequestHeaders(beforeSave.requestHeaders, operation)
: await operation()
await triggerEvent('onAfterSave', payload, context)
return result
} catch (error) {
dispatchBackendMutationError({ contextId: context.formId as string, error })
throw error
}
}
This keeps API helpers generic while still enabling module-level behaviors such as conflict dialogs, validation, or save guards.
Directly Rendering an Injection Spot
import { InjectionSpot } from '@saasframe/ui/backend/injection/InjectionSpot'
function MyPage() {
const context = { pageId: 'my-page', userId: currentUser.id }
const [data, setData] = useState({})
return (
<div>
<InjectionSpot
spotId="page:my-page:header"
context={context}
data={data}
onDataChange={setData}
/>
{/* Rest of page */}
</div>
)
}
Examples
Here’s how injected widgets look in the admin UI:



See these modules for reference implementations:
packages/example/src/modules/example/widgets/injection/crud-validation- Basic validation widgetpackages/core/src/modules/catalog/widgets/injection/product-seo- SEO helper widget for products
Code Generation
The widget injection system is integrated into the module code generator:
- Widgets in
src/modules/<module>/widgets/injection/**/widget.ts(x)are auto-discovered - Injection tables in
src/modules/<module>/widgets/injection-table.tsare auto-loaded - Generated registry files are created in
generated/injection-widgets.generated.ts - Run
yarn generateto regenerate
Best Practices
- Keep widgets focused - Each widget should have a single, clear purpose
- Use descriptive IDs - Follow the pattern
module.injection.widget-name - Handle errors gracefully - Catch errors in event handlers to avoid breaking the form
- Document context requirements - Clearly specify what data the widget expects in context
- Use TypeScript - Leverage type safety for context and data types
- Test event handlers - Ensure validation logic works correctly
- Respect disabled state - Disable UI interactions when
disabledprop is true
Troubleshooting
Widget not appearing
- Check that the widget is in the correct directory structure
- Verify the injection table maps the spot ID correctly
- Run
yarn generateto regenerate - Check browser console for loading errors
Events not firing
- Ensure the injection spot ID matches exactly
- Check that event handlers are defined in the widget module
- Verify the host component is using
useInjectionSpotEventsorInjectionSpot - Check for errors in the event handler itself
TypeScript errors
- Import types from
@saasframe/shared/modules/widgets/injection - Use the correct generic types for context and data
- Ensure the widget module default export matches
InjectionWidgetModule