Component Authoring
This page is the entry point for adding a custom component to a Media-iBox CMS page.
In a hurry? If you'd rather follow a step-by-step walk-through — from
docker compose upto a working custom widget on a custom mediatype — jump straight to the Hands-On Tutorial. This reference doc is the deeper "why each piece works the way it does"; the tutorial is the "type these commands in order" version. A "component" is one panel of a page (a list, a form, a custom widget). Each component has up to two halves:
- A C# class in MibServer3 (the BFF) that handles the server side —
permissions, configuration, and the
{schema, data}payload returned to the React shell. - A React component in a federated remote that renders the panel using
that
{schema, data}payload.
The two halves are joined by a string — the component-type key (list,
form, my_custom_widget, …). The BFF picks which C# class to instantiate
from the database; the React shell picks which React component to render
from the type-key it receives in the response.
You do not need both halves to be custom. The matrix is small but each quadrant has different mechanics — see What you can mix and match below before you start coding.
Related docs.
System Overview — RenderV2 — what the BFF does on a render request.
Pages & Templates — how a page is laid out and how its components are bound to a template.
6.0/React Components — per-type Schema/Data shapes for stock components.
Legacy Component Model — the pre-V2 (MVC) component contract that some legacy customer code still uses.
What you can mix and match
Both halves of a component are independent. Four combinations are possible:
| Backend (C#) | Frontend (React) | When to use it |
|---|---|---|
| Core | Core | The default. The customer plugs a built-in component (e.g. list of a built-in mediatype) onto a page through database rows only. No code involved. |
| Custom | Core | Server-side logic is custom — different query, computed columns, validation — but the rendered widget is the stock list/form/etc. Your C# class emits the same Schema/Data shape as the core component so the shell can dispatch to the built-in widget. |
| Core | Custom | Backend is one of the framework's built-in components, but you want a different React UI for it. Less common; typically used to swap the React renderer for a built-in component on a specific page. |
| Custom | Custom | Both sides are yours. The BFF instantiates your C# class, your class emits a {schema, data} payload using your own shapes, and your federated React widget renders it. |
The component-type string is what controls dispatch on each side:
- BFF side — picks which C# class through the
MIB3UX_COMPONENTS/MIB3UX_TEMPLATE_COMPONENTSrows (ASSEMBLY_NAME+CLASS_NAME). The chosen class is loaded at request time by reflection. - React side — picks which React component through the type string in
the response. Resolution order:
MIB3UX_TEMPLATE_COMPONENTS.COMPONENT_VIEW_TYPEoverride →IComponentTypeProvider.GetComponentType()if the C# class implements it → the class name lower-cased withComponentstripped (soListComponent→list).
That last rule is critical for the Custom backend + Core React quadrant:
either name your C# class ListComponent (so the default rule resolves to
list) or set COMPONENT_VIEW_TYPE = list on the template-component row.
Otherwise the shell will look for a React widget under your custom
component's name and fail to find one.
Big picture
sequenceDiagram
participant Shell as React shell<br/>(apps/cms)
participant BFF as MibServer3 (BFF)
participant DB as MIB3UX_* tables
participant Cmp as Your C# class<br/>(loaded from BIN)
participant Widget as Your React widget<br/>(federated remote)
Shell->>BFF: GET /api/v2/display/<pageKey>/<id>
BFF->>DB: lookup template → components → assembly+class
BFF->>Cmp: reflectively load DLL, instantiate class
BFF->>Cmp: project per-instance config onto IComponentConfiguration
BFF->>Cmp: ShouldHideComponent? (optional)
BFF->>Cmp: MapSchema(viewData, ct)
BFF->>Cmp: MapData(viewData, ct)
Cmp-->>BFF: { schema, data }
BFF->>BFF: ResolveComponentType(class, COMPONENT_VIEW_TYPE, IComponentTypeProvider)
BFF-->>Shell: { components: [{ type, schema, data }, ...] }
Shell->>Widget: lookup type in coreComponents / customComponents
Shell->>Widget: render(<Widget schema={...} data={...} {...} />)
Three things to notice:
- The BFF doesn't scan assemblies for an attribute at startup. It
reads
MIB3UX_COMPONENTSfrom the database; the rows tell it which DLL and class to load on demand. That's why there is no[ComponentType]attribute in the codebase — the wiring is in the database, not in code. - The C# class is loaded via
Assembly.LoadFromagainst the/app/working directory. Custom customer DLLs go flat next toMediaiBox.Cms.FrontEnd.Server.dll. There is no plugins folder, no isolatedAssemblyLoadContext. The loader isMibReflection.GetAssemblyFromBin(inMediaiBox.Core.Reflection.MibReflection). - The component-type string is derived at request time, not
declared by attribute. See
DisplayWorkflow.ResolveComponentType(precedence:COMPONENT_VIEW_TYPEcolumn →IComponentTypeProvider→ class name minusComponent).
The C# side
NuGet packages
Reference the two BFF contract packages from your customisation csproj:
<PackageReference Include="MediaiBox.Cms.FrontEnd.Model.Mvc" Version="..." />
<PackageReference Include="MediaiBox.Cms.FrontEnd.Model" Version="..." />
The first carries IRenderableComponentV2, IComponent<T>,
IHideableComponent, IRefreshableComponentV2, IComponentTypeProvider.
The second carries IComponentConfiguration, IComponentSchema,
ContextViewData, ComponentContext, etc.
Set <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> on the
csproj — MSBuild will then put your DLL plus any transitive references into
the publish output, which is what dotnet publish rolls into the Docker
image.
The V2 contracts
Components are plain C# classes that implement one or more of these interfaces. None of them require an attribute; the BFF discovers your class through the database row.
// Required for any visible component.
public interface IRenderableComponentV2<TConfiguration, TData, TSchema>
: IComponent<TConfiguration>
where TConfiguration : IComponentConfiguration
where TSchema : IComponentSchema
{
ComponentPermission GetPermission();
Task<TData> MapData (object viewData, CancellationToken cancellationToken);
Task<TSchema> MapSchema(object viewData, CancellationToken cancellationToken);
}
// Optional. Gates rendering on a per-request boolean.
public interface IHideableComponent
{
Task<bool> ShouldHideComponent(ComponentContext context, CancellationToken ct);
}
// Optional. Lets the React side refresh just this widget without a full reload.
public interface IRefreshableComponentV2<TConfiguration, TViewData>
: IComponent<TConfiguration>
where TConfiguration : IComponentConfiguration
{
Task<TViewData> MapRefreshData(DisplayComponentViewData viewData, CancellationToken ct);
}
// Optional. Lets one C# class be dispatched to different React widgets at runtime.
public interface IComponentTypeProvider
{
string GetComponentType();
}
TConfiguration, TData, TSchema are your own DTOs — they're the
contract you negotiate with the React side. TSchema must implement
IComponentSchema (the framework marker interface).
A minimal V2 component
using MediaiBox.Cms.FrontEnd.Model.Mvc.UI.Component;
using MediaiBox.Cms.FrontEnd.Model.UI.Component;
namespace Acme.Mib.MyWidget;
public class MyWidgetComponent
: IRenderableComponentV2<MyWidgetConfiguration, MyWidgetData, MyWidgetSchema>,
IHideableComponent
{
public ContextViewData Context { get; set; }
public MyWidgetConfiguration Configuration { get; set; }
public ComponentDisplayMode DisplayMode => ComponentDisplayMode.Edit;
public ComponentPermission GetPermission() => new()
{
Create = PermissionType.DontCare,
Read = PermissionType.Allow,
Write = PermissionType.DontCare,
Delete = PermissionType.DontCare,
};
public Task<MyWidgetSchema> MapSchema(object viewData, CancellationToken ct)
{
return Task.FromResult(new MyWidgetSchema
{
MediaType = Configuration.MediaType,
AllowEdit = Configuration.AllowEdit,
Columns = MyWidgetColumns.Default,
});
}
public async Task<MyWidgetData> MapData(object viewData, CancellationToken ct)
{
// Note: returning null from MapData is treated as a render FAILURE,
// not as "no data". Return an empty payload instead.
if (Context.IDs is null || !Context.IDs.Any())
return new MyWidgetData { Items = Array.Empty<MyWidgetItem>() };
var items = await FetchItemsAsync(Context.IDs, ct);
return new MyWidgetData { Items = items };
}
public Task<bool> ShouldHideComponent(ComponentContext ctx, CancellationToken ct)
{
// Example: hide for users without write permission on the mediatype.
var canWrite = ctx.MediaTypeInformation?.Permissions.HasFlag(Permission.CanWrite) ?? false;
return Task.FromResult(!canWrite);
}
public Task Initialize(...) => Task.CompletedTask; // required by IComponent
}
Context and Configuration are settable properties — the BFF injects
them via reflection after constructing the instance. See
Reflection-based instantiation below for
how it works.
MibComponentWrapper — the GVP convenience pattern
GVP customisations don't write the V2 interfaces by hand. They use a
customer-shipped wrapper at
tvopenplatform-mib3/src/Pages/Common/Component/MibComponentWrapper.cs
that implements all the V1 and V2 interfaces and forwards the calls to
an inner MibComponent-style class with [AjaxMethod]-decorated methods:
namespace GVP.Mib3.ScheduleModificationsReact;
public class ScheduleModificationsComponent
: MibComponentWrapper<ScheduleModificationsMibComponent> { }
[Component("ScheduleModificationsReact", "{DICT:GVP_MIB3_SCHEDULEMODIFICATIONS/TITLE}")]
public class ScheduleModificationsMibComponent : MibComponent
{
public ScheduleModificationsMibComponent(IMibApiClientLibrary apiClient)
: this(new InstanceClient(apiClient), /* ... */ ) { }
[AjaxMethod]
public BaseItemsResponse<EpgScheduleSelectItem> GetInstancesItems()
{
var permission = Permissions.Get(MediaTypeNames.EpgSchedules);
// ...
}
[AjaxMethod(Component.HttpMethod.Put)]
public BaseResponse Save(EpgScheduleSaveReactModel request) { /* ... */ }
}
The empty subclass is the only thing referenced in
MIB3UX_COMPONENTS.CLASS_NAME (e.g.
GVP.Mib3.ScheduleModificationsReact.ScheduleModificationsComponent); the
inner MibComponent carries the logic and the AJAX endpoints. The wrapper
itself is not part of the BFF framework — it's a customer convenience
adapter, so you'd need to copy or reimplement it for a new customisation,
or write directly against IRenderableComponentV2.
[ComponentType] does not exist
Earlier versions of these docs claimed there is a [ComponentType("…")]
attribute. There isn't. The component-type string the React shell uses is
resolved in DisplayWorkflow.ResolveComponentType (in the
MediaiBox.Cms.FrontEnd.Server assembly) with this precedence:
MIB3UX_TEMPLATE_COMPONENTS.COMPONENT_VIEW_TYPE(a per-instance string override on the template-component row).IComponentTypeProvider.GetComponentType()if the C# class implements that interface.- The C# class name, lower-cased, with
Componentstripped from the suffix. SoListComponent→list,MyWidgetComponent→mywidget.
For a custom widget pick a key that won't collide with framework or other
customisation strings — e.g. acme_my_widget — and either name your
class AcmeMyWidgetComponent (relying on rule 3) or set
COMPONENT_VIEW_TYPE = acme_my_widget on the template-component row.
Reflection-based instantiation
At every render request, the BFF:
- Reads the template's components from
MIB3UX_TEMPLATE_COMPONENTS(joined toMIB3UX_COMPONENTSforASSEMBLY_NAME+CLASS_NAME). - Calls
MibReflection.GetAssemblyFromBin(assemblyName). This first checks already-loaded assemblies in the AppDomain, then falls back toAssembly.LoadFrom(BaseDirectory + "bin/" + assemblyName + ".dll"), thenAssembly.LoadFrom(BaseDirectory + assemblyName + ".dll"). No plugin folder, noAssemblyLoadContextisolation. - Calls
Activator.CreateInstance(type)— parameterless constructor only. Constructor injection from a DI container is not used at this layer. Anything you need must come fromContext(theContextViewDatathe BFF sets via reflection),Configuration(your typed config DTO, populated fromMIB3UX_PAGE_COMPONENT_CONFIGURATIONS), or a service you instantiate yourself insideMapSchema/MapData. - Sets
ContextandConfigurationon the instance, then callsInitialize(IMibApiClientLibrary, IPublicWorkflowFactory, IHttpContextAccessor).
This is the legacy reflection-based loader inherited from MVC-era MIB.
Treat your component class as a thin coordinator: it picks up its inputs
from Context and Configuration, calls your real domain code (a Mib
API client, a helper class), and returns a DTO.
Context — what the request looks like to the component
Context is a ContextViewData (defined at
MediaiBox.Cms.FrontEnd.Model/UI/Context/ContextViewData.cs):
| Property | Meaning |
|---|---|
Context.IDs |
The selected mediatype ids on the current page (empty in list mode; one or more in edit / bulk-edit mode). |
Context.User |
A UserViewData — id, email, time zone, groups, MediaTypePermissions, SourcePermissions, token hash. |
Context.CopiedFrom |
If the page is being opened from a "copy" action, the source id; otherwise 0. |
Context.IgnoreRelateds |
Used by the copy-with-relateds flow. |
Context.PreviousPageKey |
The page the user came from (used by "Return" navigation). |
Context.RootUrl, Context.Language, Context.RequestId, Context.MibLog |
Inherited from BaseContext — installation root URL, active language, the request id you'll see in logs, the per-request log accumulator. |
MapSchema vs MapData — what goes where
| Aspect | MapSchema |
MapData |
|---|---|---|
| Stability | Roughly stable per mediatype / per page. | Changes per item / per save. |
| Cacheable? | Often yes (the framework caches MediaTypeInformation). |
Generally no. |
| Per-user variation? | Only what permission flags require. | Yes — different items, different permission checks. |
Allowed to return null? |
No — null is treated as a render failure (see below). | No — same. Return an empty payload (e.g. new MyWidgetData { Items = [] }) instead. |
Practical guidance:
- Put column/field definitions, configuration, dropdown option lists,
permission-derived UI flags into
MapSchema. - Put row values, item field values, related-record references into
MapData. - If your widget loads its own data over AJAX after mount, return an
empty data DTO from
MapData(not null). The React widget then fetches over AJAX on mount.
Resilience — what happens if you throw or return null
ComponentEvaluator (in MediaiBox.Cms.FrontEnd.Workflow.Mvc/Display/)
wraps your MapSchema / MapData calls in a try/catch. Both an
exception and a null return value are treated as a render failure —
the framework logs a [Render-ComponentFailure] {json} line, marks the
component Errored, and drops it from the response. The rest of the
page still renders.
So:
- Never rely on throwing to abort the whole page. If you really must, fail upstream (e.g. fail the BFF authentication middleware).
- Don't return null from
MapDataeven when there's no data to render; return an empty payload that matches yourTDatashape.
See ComponentEvaluator resilience gate for the full sequence.
Refresh — IRefreshableComponentV2
When the React side wants to re-pull just this component's data without
a full page reload, implement IRefreshableComponentV2<TConfiguration, TViewData>:
public Task<MyWidgetData> MapRefreshData(
DisplayComponentViewData viewData, CancellationToken ct)
{
// Same shape as MapData would have returned.
return Task.FromResult(new MyWidgetData { Items = ... });
}
The BFF exposes this through the refresh endpoint, and the React side
invokes it directly via an @agilecontent/mib-api-connector client call
keyed by pageKey + templateComponentKey. (The shell does not pass a
per-widget refresh() callback in MibComponentProps — you call the
endpoint yourself from inside the widget.)
For a refresh that re-fetches the entire page instead, prefer
revalidateView() from MibComponentProps — it's the supported
"reload everything" hook.
Persistence — saving form-like components
Form-shaped widgets save by POSTing to the standard BFF persist endpoint
(POST /Pages/Persist for the legacy flow, the form-component-specific
endpoints for V2). You don't need a special interface for that — the
persist pipeline routes saved fields straight to MibObjects on the Data
API.
If you need a custom server-side step on save (validation, side effects, batch ops), use a Custom Business Rule plugin instead of overloading the component class:
The legacy IPersistableComponent interface is not part of the V2
contract; it remains only for legacy MVC components that still drive
the old MVC persist flow.
The React side
Where the widget lives
A custom React widget is part of a federated remote — a separate Vite
app + Docker image whose remoteEntry.js the React shell loads at
runtime. The minimal working scaffold ships in the
hands-on tutorial artifacts (look at
vite.config.ts + src/main.tsx); a larger production example is
mini-agiletv.
The remote's vite.config.ts uses @originjs/vite-plugin-federation to
expose ./all → ./src/main.tsx:
federation({
name: 'customComponents',
filename: 'remoteEntry.js',
exposes: { './all': './src/main.tsx' },
shared: ['react', 'react-dom', '@agilecontent/ui',
'@agilecontent/mib-api-connector', 'react-i18next',
'@agilecontent/mib-modules', 'styled-components', 'swr'],
}),
The federation entry point
src/main.tsx is a plain module with several named exports that the
shell merges into its own surface. The shape (from real GVP code):
// src/main.tsx
import allComponents from './components';
import coreDecorators from './core-decorators';
import { getHandlers, seedDatabase } from 'common/mock-server';
export { default as translations } from './application/lang/mib/translations.json';
export { useFormChange } from './hooks/use-form-change';
export const handlers = getHandlers; // MSW handlers for Storybook
export const skeletons = { /* per-page loading skeletons */ };
export const components = componentsWithSuspense; // ← the dispatch map
export const decorators = coreDecorators;
export default components;
components is the dispatch map. Each key is a component-type string
(matching ResolveComponentType on the BFF side); each value is a React
component:
// src/components.ts
import { lazy } from 'react';
const ScheduleModifications = lazy(() => import('./components/schedule-modifications'));
const Dictionary = lazy(() => import('./components/dictionary'));
// ...
export const components = {
'schedule-modifications': ScheduleModifications,
dictionaries: Dictionary,
// ...
};
Lazy-loading is recommended so the remote stays a single small
remoteEntry.js that pulls in each widget's bundle only when its page
opens.
How the shell dispatches
The shell pairs each component in the BFF response with a React
component via its internal dispatcher (roughly
apps/cms/src/common/helpers/components.ts in MibFrontEnd):
import { isCoreComponent, components as coreComponents } from 'core-components';
import { customComponents } from 'application/config/federation';
export const buildComponents = (components: ComponentSchema[]) =>
components.map((component) => {
if (isCoreComponent(component)) {
return [component, coreComponents[component.type]];
}
return [component, customComponents ? customComponents[component.type] : null];
});
coreComponentsis the hard-coded record inpackages/core-components/src/components/index.ts(keys:list,form,relatedList,asyncRelatedList,message,contentcriteria,dmm,epg,permissionList, plus stubs forhtmlrender,orderedrelatedlist,quickupload,relatedautocomplete,relatedform,simplerelatedlist).isCoreComponentreturns true iff the type-string is a key ofcoreComponents. So the moment you pick a custom type-string outside this list, the shell looks for it incustomComponents.customComponentsis the merged map from up to three federated remotes —customComponents,customComponents2,customComponents3— see Federation slots below.- If neither map has the type, the shell renders a
<Missing />placeholder and logs a warning to the console. That's the symptom of a mismatched component-type string.
Federation slots
The shell can load up to three independent federated remotes:
// apps/cms/vite.config.ts (truncated)
const remoteConfigs = [
{ name: 'customComponents', configKey: 'CUSTOM_COMPONENTS_URL' },
{ name: 'customComponents2', configKey: 'CUSTOM_COMPONENTS_2_URL' },
{ name: 'customComponents3', configKey: 'CUSTOM_COMPONENTS_3_URL' },
];
This is what lets you split widgets across multiple customer-owned projects (e.g. GVP's main remote + a partner's remote + an internal team's remote) and still have one shell. The merger order is the order listed; later remotes override earlier ones if the same type-key is present in more than one.
If a slot's URL is not set, the dynamic import('customComponentsN/all')
is skipped (silent no-op) and the slot contributes nothing.
The props contract
Each React widget receives a MibComponentProps<S, D> from the shell,
defined at packages/api-connector/src/services/components/types.ts:
export type MibComponentProps<S, D> = {
revalidateView: (
page?: PageSchema | ((prev: PageSchema) => PageSchema),
options?: { revalidate?: boolean }
) => void;
componentKey: string; // MIB3UX_TEMPLATE_COMPONENTS.TEMPLATE_COMPONENT_KEY
title?: string; // optional, from MapSchema
schema: S; // ← what your MapSchema returned
data: D; // ← what your MapData returned
pageKey: string;
pageIds?: string; // selected ids in edit / bulk-edit mode
isBulkEdit?: boolean;
setDirty?: (dirty: boolean) => void;
isValidating?: boolean;
};
setDirty(true)integrates with the shell's "unsaved changes" banner — call it when the user edits, andsetDirty(false)when the edit is reverted or persisted.revalidateView(page?, opts?)triggers the shell to re-fetch the whole page (or applies a synchronous local update). It's the refresh-after-save hook.- The shell does not pass a per-widget
refresh()callback. If you need granular refresh of just this widget, expose your own AJAX endpoint viaIRefreshableComponentV2on the C# side and call it from the React widget.
Minimal widget
import { useState } from 'react';
import type { MibComponentProps } from '@agilecontent/mib-api-connector';
import type { MyWidgetSchema, MyWidgetData } from './types';
export const MyWidget = ({
schema, data, setDirty, revalidateView,
}: MibComponentProps<MyWidgetSchema, MyWidgetData>) => {
const [localState, setLocalState] = useState(data.items);
const onEdit = (next: typeof localState) => {
setLocalState(next);
setDirty?.(true);
};
return (
<section className="my-widget">
<h3>{schema.title}</h3>
{/* ... render with @agilecontent/ui or your antd-derived components ... */}
<button onClick={() => revalidateView()}>Reload page</button>
</section>
);
};
export default MyWidget;
Hooks exposed from the shell
Federated remotes can pull a small surface of helpers from
@agilecontent/mib-modules and @agilecontent/mib-api-connector (both
listed in the federation shared array, so they're deduped at load
time). The actually-exported public hooks are limited:
| Export | Purpose | From |
|---|---|---|
useGlobalActions |
Register a top-right toolbar action (e.g. "Export TSV") that the shell renders next to "Save" / "Cancel". | @agilecontent/mib-modules |
useAccount |
Read the current user's account info. | @agilecontent/mib-modules |
useQuickEditContext |
The shell's per-page "quick edit" context. | @agilecontent/mib-modules |
useTranslation |
Standard react-i18next hook. Use for translations; same back-end source as {DICT:Section/Key} in C#. |
react-i18next |
ContentEdition, ComponentList, Sorter, Filters, History, SavedFilters, GridPersonalization |
Components, not hooks. Re-usable widgets the shell ships. | @agilecontent/mib-modules |
Notes on hooks not in the public surface but widely used by
customer customisations: useContentEdition, useFilters,
useSorter, and useTranslate are not exported from
@agilecontent/mib-modules's public API
(packages/modules/src/index.tsx); customer code imports them through
internal paths. The framework continues to use them and they are
documented in
React Hooks & Events — Shared hooks
with the "internal" caveat. The supported public-API alternatives are
to render <ContentEdition> yourself, use useGlobalActions to
register a Save button, drive list filter/sort through the URL query
string, and use react-i18next's standard useTranslation() for
translations.
Styling and UI primitives
Federated widgets share the shell's CSS custom variables (see
Theming). Use the variables (--color-highlight,
--color-title, …) rather than hard-coding colours; this makes the same
widget themeable across customers.
For per-widget styles, ship a CSS module or styled-components file
inside your remote — the federation bundler will include it in
remoteEntry.js.
For UI primitives — buttons, form fields, tables, modals, toasts — use
@agilecontent/ui (the shell's curated antd
wrapper) instead of importing antd directly. It's listed in the
shell's federation shared array, so federated remotes get the exact
same version the shell loaded; importing antd directly results in a
double-bundled UI library with subtle bugs (broken modals, doubled
ConfigProvider, etc.).
Storybook for isolation
Each component should ship with a *.stories.tsx file. The remote can
include its own @agilecontent/mock-server MSW handlers (exported from
main.tsx as handlers) so widgets can be developed end-to-end
without a running BFF.
Database wiring
Every component lives on a page through a template. A page is
not "code" — it's a row in MIB3UX_PAGES whose TEMPLATE_ID points at
a MIB3UX_TEMPLATES row, which in turn has 1..N
MIB3UX_TEMPLATE_COMPONENTS rows describing the layout. Each
template-component points at a MIB3UX_COMPONENTS row that gives the
C# assembly + class. Per-instance configuration is keyed in
MIB3UX_PAGE_COMPONENT_CONFIGURATIONS.
The tables involved
| Table | What it holds | Key columns when adding a component |
|---|---|---|
MIB3UX_COMPONENTS |
The catalog of available component classes. One row per (assembly, class) combo. |
ID, NAME, COMPONENT_KEY, ASSEMBLY_NAME, CLASS_NAME |
MIB3UX_TEMPLATES |
The catalog of templates (page layouts). | ID, NAME, TEMPLATE_KEY |
MIB3UX_TEMPLATE_COMPONENTS |
An instance of a component on a template. This is where the layout actually lives. | ID, NAME, TEMPLATE_COMPONENT_KEY, ORDER, COMPONENT_ID (→ MIB3UX_COMPONENTS.ID), TEMPLATE_ID (→ MIB3UX_TEMPLATES.ID), PARENT_TEMPLATE_COMPONENT_ID, COMPONENT_VIEW_TYPE |
MIB3UX_PAGES |
The catalog of pages (the URLs the React shell routes to). | ID, NAME, PAGE_KEY, TEMPLATE_ID (→ MIB3UX_TEMPLATES.ID) |
MIB3UX_PAGE_CONFIGURATIONS |
Per-page key/value config (e.g. API_URL). |
ID, NAME, CONFIGURATION_KEY, CONFIGURATION_VALUE, PAGE_ID |
MIB3UX_PAGE_COMPONENT_CONFIGURATIONS |
Per-component-on-a-page key/value config (what Configuration properties get populated from). |
ID, NAME, CONFIGURATION_KEY, CONFIGURATION_VALUE, PAGE_ID, TEMPLATE_COMPONENT_ID |
MIB3UX_MENU |
Sidebar entry that points at a page. | ID, NAME, MENU_KEY, URL, PARENT_MENU_ID, ORDER, ICON_URL, OWNER |
COMPONENT_VIEW_TYPE is the column that overrides the React type-key
resolution. Set it when you want the same C# class to render as a
different React widget — see
Mixing core/custom.
COMPONENT_KEY, TEMPLATE_KEY, TEMPLATE_COMPONENT_KEY, PAGE_KEY,
MENU_KEY are the functional keys used by code; NAME is just for
the admin UI's display.
Adding a new component to a new page — canonical SQL
This is the script the official sample (MibCustomComponent.Sample)
ships with — adapted to V2. Run it once per environment as a migration.
DECLARE @component_id INT,
@template_id INT,
@template_component_id INT,
@page_id INT;
-- 1. Register the C# class
INSERT INTO MIB3UX_COMPONENTS
(OWNER, NAME, COMPONENT_KEY, ASSEMBLY_NAME, CLASS_NAME)
VALUES (1, 'acme_my_widget', 'acme_my_widget',
'Acme.Mib.MyWidget',
'Acme.Mib.MyWidget.MyWidgetComponent');
SELECT @component_id = MAX(ID) FROM MIB3UX_COMPONENTS;
-- 2. Create the template (page layout)
INSERT INTO MIB3UX_TEMPLATES (OWNER, NAME, TEMPLATE_KEY)
VALUES (1, 'acme_my_widget_template', 'acme_my_widget_template');
SELECT @template_id = MAX(ID) FROM MIB3UX_TEMPLATES;
-- 3. Add the component instance to that template
INSERT INTO MIB3UX_TEMPLATE_COMPONENTS
(OWNER, [ORDER], NAME, TEMPLATE_COMPONENT_KEY, COMPONENT_ID, TEMPLATE_ID, COMPONENT_VIEW_TYPE)
VALUES (1, 1, 'acme_my_widget_1', 'acme_my_widget_1',
@component_id, @template_id,
'acme_my_widget'); -- ← the React type-key
SELECT @template_component_id = MAX(ID) FROM MIB3UX_TEMPLATE_COMPONENTS;
-- 4. Create the page that uses that template
INSERT INTO MIB3UX_PAGES (OWNER, NAME, PAGE_KEY, TEMPLATE_ID)
VALUES (1, 'acme_my_widget_page', 'acme_my_widget_page', @template_id);
SELECT @page_id = MAX(ID) FROM MIB3UX_PAGES;
-- 5. Per-component configuration (read by your TConfiguration properties)
INSERT INTO MIB3UX_PAGE_COMPONENT_CONFIGURATIONS
(OWNER, NAME, CONFIGURATION_KEY, CONFIGURATION_VALUE, PAGE_ID, TEMPLATE_COMPONENT_ID)
VALUES (1, 'acme_my_widget_page/MEDIA_TYPE', 'MEDIA_TYPE', 'MVP_MOVIES', @page_id, @template_component_id),
(1, 'acme_my_widget_page/ALLOW_EDIT', 'ALLOW_EDIT', 'true', @page_id, @template_component_id);
-- 6. (Optional) Sidebar menu entry
INSERT INTO MIB3UX_MENU (OWNER, NAME, MENU_KEY, URL, [ORDER], ICON_URL)
VALUES (1, 'My Widget', 'acme_my_widget_menu', '/acme_my_widget_page', 100, '...');
OWNER is the MIB user id; 1 is the admin user that ships with the
seed.
How configuration is projected onto your DTO
ConfigurationLoader.DictionaryToObject
(MediaiBox.Cms.FrontEnd.Workflow.Mvc/ConfigurationLoader.cs) walks the
properties of your TConfiguration class and matches them against the
flat CONFIGURATION_KEY → CONFIGURATION_VALUE rows by name. Matching is
case-insensitive and underscore-insensitive — so a property AllowEdit
matches a key ALLOW_EDIT, ALLOWEDIT, allow_edit, or allowEdit.
Supported scalar types: string, bool, int, Int64, int[],
string[], Guid, Uri (relative URIs are resolved against the
installation RootUrl), List<FormGrouping>,
Dictionary<string,string>, Dictionary<string,Uri>, List<string>.
Nested objects are not supported; flatten into multiple top-level keys
instead.
public class MyWidgetConfiguration : IComponentConfiguration
{
public string PageKey { get; set; } // set by the framework
public string TemplateComponentKey { get; set; } // set by the framework
public string ParentTemplateComponentKey { get; set; } // set by the framework
public string SerializedConfiguration { get; set; } // set by the framework (debug-friendly view)
// Your own keys
public string MediaType { get; set; }
public bool AllowEdit { get; set; } = true;
public int PageSize { get; set; } = 20;
}
The four framework-managed properties are required by the
IComponentConfiguration interface — the framework sets them after
projection. Don't store anything in SerializedConfiguration; it's a
human-readable snapshot the framework writes for logs/debugging.
Deployment
There are three artifacts to ship — depending on which quadrant of the mix-and-match matrix you're in, you may only need a subset.
Backend: custom C# DLL
Drop your customisation DLL flat into MibServer3's working directory. There is no plugins folder, no overlay, no isolated load context.
The base FrontEnd Server image looks like:
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
COPY ["/publish", "./"]
ENTRYPOINT ["dotnet", "MediaiBox.Cms.FrontEnd.Server.dll"]
Everything in your publish/ directory ends up at /app/. Your
Acme.Mib.MyWidget.dll sits next to MediaiBox.Cms.FrontEnd.Server.dll.
Build pipeline:
- Reference your
MyWidget.csprojfrom a composition project (a csproj that has<ProjectReference>lines for every customer DLL — GVP's istvopenplatform-mib3/Server/Mib3.Server.csproj). dotnet publishagainst that composition project. Set<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>on each customisation csproj so transitive references land in the same flat folder.- Build a thin Docker image
FROM <base-mib-server-image>with aCOPYof your customisation DLLs over the base image's flat dir. See~/Documents/agilecontent/Build/deploy_mib_frontend_dev264.shfor an example overlay script. - Push to ECR; redeploy in the cluster.
There is a Custom/ subdirectory in the publish output but it currently
contains only placeholder.txt and wwwroot/; the assembly loader does
not probe it.
Common failure mode — your DLL builds locally but the BFF can't load
it: check that all transitive DLLs are in the publish dir (newer
versions of MSBuild sometimes prune them). The runtime exception will
be a CouldNotLoadComponentAssemblyException in the BFF logs, naming
the missing assembly.
Frontend: federated React remote
The remote is a separate Vite app and a separate Docker image. The shell container picks it up at runtime via environment variables.
# On the React shell container (apps/cms)
CUSTOM_COMPONENTS_URL=https://acme-mib-components.acme.example.com/assets/remoteEntry.js
CUSTOM_COMPONENTS_2_URL=
CUSTOM_COMPONENTS_3_URL=
apps/cms/start.sh transcribes these into /tmp/config.json, which the
shell fetches at boot and uses to bind the federation imports. If a URL
is empty, that slot is skipped silently.
Build pipeline for the remote:
pnpm i && pnpm buildproducesdist/withremoteEntry.jsand the widget bundles.- Build a Docker image that serves
dist/via nginx on port 4000 (the default; seeMIBFrontEnd-GVP-components/Dockerfile). - Deploy the image to a host the shell can reach (intranet, S3+CloudFront, another K8s service in the same cluster).
- Set
CUSTOM_COMPONENTS_URLon the shell container to the deployedremoteEntry.jsURL; restart the shell.
The shell does not rebuild when the remote redeploys. As long as the URL is stable, you can ship new widgets by redeploying just the remote.
Database: migration script
Run the SQL from Adding a new component to a new page
against the customer's MIB3 database. For development, use
MibDevelopmentTool (see Local Development) to
apply the rows interactively. For production, ship them as a
MibMigrator migration.
Wiring it up — end-to-end checklist
The exact steps depend on the matrix quadrant:
Custom backend + custom React (most flexible)
- C# side — create the class implementing
IRenderableComponentV2<TCfg, TData, TSchema>. Build the DLL into your composition project's publish dir. - React side — create the React component in your federated
remote. Add it to
src/components.tsunder the type-key you chose (e.g.'acme_my_widget'). Build and deploy the remote. - Database — run the SQL from above. Make sure
MIB3UX_TEMPLATE_COMPONENTS.COMPONENT_VIEW_TYPEmatches your React key, or rely on the class-name fallback (AcmeMyWidgetComponent→acmemywidget). - Shell env — set
CUSTOM_COMPONENTS_URLon the shell to point at your remote'sremoteEntry.js. - Verify — see Inspecting a render.
Custom backend + core React
- C# side — write the class. Emit the same Schema/Data shape the
core component expects (see
6.0/React Components for
list/form/relatedListschemas). - No React work needed.
- Database — same SQL as above, but set
COMPONENT_VIEW_TYPE = 'list'(or whichever core key) on the template-component row. The shell will dispatch to the built-in widget. - No shell env change required if you don't have other custom widgets.
Core backend + custom React
- No C# work — the built-in component handles the server side.
- React side — write a widget that consumes the framework's
Schema/Data shape (it'll be the same one the core React widget would
have seen). Add it to your remote under a custom type-key
(e.g.
'acme_movies_list'). - Database — replace the existing template-component's
COMPONENT_VIEW_TYPEwith'acme_movies_list'so the shell dispatches to your widget instead of the built-in. - Shell env — set
CUSTOM_COMPONENTS_URLif not already set.
Core backend + core React
Just database rows. No code, no DLLs, no remote deploy. See Pages & Templates for the high-level page-building flow.
Inspecting a render in production
Three signals to debug a misbehaving component without a debugger:
Server log line — search Kibana for
evt:"RenderV2-Stats" AND pageKey:"<your page>". Each component has its own entry withoutcome(Success,Errored,SkippedHidden,SuccessDataNull) plusschemaMs/dataMstimings. AnErroredoutcome will have a paired[Render-ComponentFailure]line carrying the stack trace.Network panel —
GET /api/v2/display/<page>/<id>. Look for yourTemplateComponentKeyin the response'scomponents[]. If it's missing but other components are present, the component isErroredorSkippedHidden.React console — when a
components[i].typedoesn't resolve to any registered React widget (core or custom), the shell renders a<Missing />placeholder and logs a console warning. That's the symptom of a mismatched component-type string betweenMIB3UX_TEMPLATE_COMPONENTS.COMPONENT_VIEW_TYPE(or the class-name fallback) and the Reactcomponents: { ... }map.
Common failure-mode triage:
| Symptom | Likely cause |
|---|---|
CouldNotLoadComponentAssemblyException in BFF logs |
DLL not present in /app/ of the FrontEnd Server container, or a transitive dependency is missing. |
Errored outcome, stack trace ends in NullReferenceException on Context.MediaTypeInformation |
The page is in list mode (no IDs) and you assumed there'd be an item. Check Context.IDs.Any() first. |
| Component is in the response but the React widget shows nothing | The type-key doesn't match the React components registry. Look at the network panel's components[i].type value, then look at the keys of src/components.ts in your remote. |
Component is in the response, the type-key matches a core component, but the page errors with Cannot destructure property 'rules' of t.configuration as it is null |
You returned null from MapData or MapSchema — see Resilience. |
See also
- System Overview — RenderV2 end-to-end
- 6.0/React Components — per-type Schema/Data shapes
- Pages & Templates
- Local Development
- UI Primitives —
@agilecontent/ui— what to import for buttons, form fields, tables, etc. - React Hooks & Events
- Shipping JavaScript (federated remotes)
- Theming
- Localization
- Plugin: Custom Validation
- Plugin: Custom Business Rule
- Legacy Component Model — the pre-V2 MVC contract still used by some customer code