Extending Core Components
This page is the reference for extending a built-in (Core) component — List, Form, SimpleRelatedList, OrderedRelatedList, ContentCriteria — by overriding a small seam instead of reimplementing the whole component.
When do I need this? You want one of the built-in components, but with a minor backend difference: a computed column, an extra filter, a save side-effect (Kafka, an external API), a custom permission rule, a hidden panel. Historically that meant copying a 150–520 line component class. With the seams below it is a ~10–60 line override.
Related docs. Authoring Guide — how the BFF picks and runs your C# class (read this first if you are new to custom components). Samples & Use Cases — complete, realistic components (often combining seams) with wiring + tests, plus a broad "I want to…" use-case catalogue. Seam Reference — source-verified signatures, the type glossary, enums, and schema shapes (the authoritative contract for you and for codegen/LLMs). Extension Building Blocks — the reusable helpers (reconciler, schema builders, JSON, validation, resilience) your overrides call. Calling an External API — how a seam gets an
HttpClientthat logsapi-calland propagatesX-Request-ID, and how a component the loader doesn't construct directly reaches the container. Troubleshooting — symptom → cause → fix when an override doesn't behave as expected. Migration Cookbook — real customer components rewritten as a Core subclass + a seam. List · Form · Simple Related List · Ordered Related List · Content Criteria — the per-type Schema/Data shapes the seams operate on.
Quick start — your first seam in three steps
A seam customization is a tiny subclass plus a config row. End to end:
1. Subclass the Core component and override one seam. (Add the helper DLL as a dependency; see the Authoring Guide.)
namespace Acme.Cms.Components;
public sealed class ActiveMoviesListComponent : ListComponent // a Core base
{
// Server-side filter: this list only ever shows ACTIVE rows.
protected override void OnSearchCriteriaBuilt(SearchCriteria criteria)
=> criteria.GlobalFilter = string.IsNullOrEmpty(criteria.GlobalFilter)
? "STATUS=ACTIVE"
: $"({criteria.GlobalFilter}) AND STATUS=ACTIVE";
}
2. Point the template-component row at your class — keep the Core type-key.
The ASSEMBLY_NAME / CLASS_NAME config keys select your C# class; the
type-key stays the stock list so the standard React widget renders it. (Exact
SQL is in the Authoring Guide → Database wiring.)
| Config key | Value |
|---|---|
ASSEMBLY_NAME |
Acme.Cms.Components |
CLASS_NAME |
Acme.Cms.Components.ActiveMoviesListComponent |
| (type-key) | list (unchanged — stock widget) |
3. Deploy the DLL and verify. Ship the assembly with the BFF (deployment); the page renders through the stock list widget, now filtered. Nothing else changed — fetch, schema, paging, permissions are all inherited.
That's the whole loop: subclass → override one seam → wire the row. Pick the seam from the catalogue or the Which seam do I need? table; copy its exact signature from the Seam Reference.
The customization cliff this removes
A Core component can be customized at three tiers, with a cliff between the second and the third:
| Tier | Mechanism | Reach |
|---|---|---|
| 1 — Config keys | INCLUDEFIELDS, FILTER, READONLY_FIELDS, FORMGROUPING, CUSTOM_FIELD_SCHEMA, ASIDE_* |
Render tweaks after the core fetch. Cannot change the data source, add a column from elsewhere, or touch save. |
| 2 — Business-rule / validator plugins | BUSINESSRULE_ASSEMBLY_NAME / CLASS_NAME, VALIDATION_ASSEMBLY_NAME / CLASS_NAME |
Form + RelatedList only; save/delete validation only. See Custom Business Rule / Custom Validation. |
| 3 — Full reimplementation | A brand-new MibComponentWrapper<T> class overriding MapData / MapSchema / Save / OnAfterSave |
Anything else. ~50–60% of every such class is identical plumbing. |
The extension seams are a new tier between 2 and 3: override one small, named method and keep the entire Core pipeline (fetch, schema, save reconciliation, permissions, response shaping) intact.
Two backend customization models
There are two ways to write a custom C# backend for a component. Knowing which one you are in tells you whether the seams on this page apply.
| Core subclass + seam (this page) | MibComponent wrapper SDK (legacy) |
|
|---|---|---|
| Base class | ListComponent, FormComponent, SimpleRelatedListComponent, OrderedRelatedListComponent, ContentCriteriaComponent |
MibComponent (wrapped by MibComponentWrapper<T>) |
| You override | one protected virtual seam (default no-op) |
MapData(object)→object, MapSchema, Save(IMibRequest, ISaveManager) — the whole pipeline |
| Save model | IPersistenceSaveData (built by the Core) |
ISaveManager.Save(mediaType, anonymous) |
| Boilerplate | none — you inherit fetch/schema/save | ~50–60% per class |
| Best for | new components, and Core-shaped behavior with a small twist | bespoke widgets the Core shape can't express |
Most existing GVP/CMSE custom components were written against the wrapper SDK because the Core components did not offer seams. The seams are the modern path: prefer a Core subclass + seam for new work, and re-platform wrapper classes opportunistically (see the Migration Cookbook). This page documents the Core-subclass model only.
Which model do I need? (decision flow)
flowchart TD
A[Change a built-in component] --> B{Render-only tweak<br/>after the core fetch?}
B -- yes --> C[Tier 1 — config keys<br/>INCLUDEFIELDS / FILTER / READONLY_FIELDS / …]
B -- no --> D{Save/delete validation<br/>on Form or RelatedList?}
D -- yes --> E[Tier 2 — business-rule /<br/>validator plugin]
D -- no --> F{Does a named seam cover it?<br/>data · schema · visibility · permission ·<br/>save/delete lifecycle · fetch · data-source}
F -- yes --> G([Tier 2.5 — Core subclass + seam<br/>★ THIS PAGE])
F -- no --> H[Tier 3 — full MibComponent wrapper SDK]
How a seam works — the three guarantees
Every seam follows the same contract, and that contract is the whole point:
- It is a
protected virtualmethod with a no-op / identity default. The Core component calls it at a well-defined point — but the default body does nothing. If you do not override it, the component behaves exactly as it did before. Byte-identical by default. - It is additive and V2-only. Seams fire on the V2/React render and save
path (
MapData/MapSchema/Save/OnAfterSave/Delete/OnAfterDelete/GetPermissions). The legacy RazorIndex()/GetRenderDataviewers are not touched. - You override the seam, not the pipeline. You receive the model the Core already built (the V2 data, the schema, the reconciled save set, the resolved permission, the response) and adjust it in place. The framework keeps owning the orchestration.
Where the seams fire
sequenceDiagram
participant BFF as ComponentEvaluator
participant Cmp as Your subclass<br/>(overrides one seam)
Note over BFF,Cmp: RENDER (read)
BFF->>Cmp: ShouldHideComponent(ctx, ct)
Note right of Cmp: visibility
BFF->>Cmp: GetSecondaryInformationRequests(...)
Note right of Cmp: OnSearchCriteriaBuilt(criteria) — List
BFF->>Cmp: MapSchema(viewData, ct)
Note right of Cmp: ContributeSchema(schema)
BFF->>Cmp: MapData(viewData, ct)
Note right of Cmp: TransformData(v2Data, raw)
BFF->>Cmp: GetPermissions(responses)
Note right of Cmp: OnPermissionResolved(perm, ...)
Note over BFF,Cmp: WRITE (save / delete)
BFF->>Cmp: Save(request, saveList, ct)
Note right of Cmp: On*SaveBuilt / OnRelatedSaveReconciled
BFF->>Cmp: OnAfterSave(results)
Note right of Cmp: OnAfterSaveResponseBuilt(response, results)
BFF->>Cmp: Delete(request, deleteList)
Note right of Cmp: OnDeleteBuilt(deleteData, request)
BFF->>Cmp: OnAfterDelete(results)
Note right of Cmp: OnAfterDeleteResponseBuilt(response, results)
Reaching the framework collaborators
The bases expose their collaborators as protected so an override can use them
without re-resolving anything:
protected IMibApiClientLibrary ApiClient { get; } // the MIB API client
protected IWorkflowFactory WorkflowFactory { get; } // workflows (list, form, meta, preference…)
public IComponentConfiguration Configuration { get; }
public ContextViewData Context { get; } // IDs, User, CopiedFrom, RestoreState…
The seam catalogue
Each seam below lists its signature, what you can touch (the shape of
the model handed to you), when it fires, what happens if it throws, a
use case, an example, and gotchas. Examples subclass a Core
component; wire the subclass to a template-component row exactly as in the
Authoring Guide (ASSEMBLY_NAME + CLASS_NAME,
and keep the type-key resolving to the Core widget so the stock React renderer
is used).
1. Render data — TransformData
// List / SimpleRelatedList / OrderedRelatedList
protected virtual void TransformData(ListV2ViewData data, ListViewData viewData) { }
// Form
protected virtual void TransformData(FormV2ViewData data, FormViewData viewData) { }
// ContentCriteria
protected virtual void TransformData(ContentCriteriaV2ViewData data, ContentCriteriaViewData viewData) { }
- What you can touch:
datais the V2 payload the React shell receives.ListV2ViewData→Items(List<ItemV2ViewData>),Total,Charts,AddedIds.ItemV2ViewData→Id,Fields(Dictionary<string, object>),Permissions,Etag,Title.FormV2ViewData→ the form'sFields(+CreatedAt).ContentCriteriaV2ViewData→Groups,Order,Highlights.viewDatais the raw Core view data (fetched rows/fields) — read it to derive values.
- Fires: inside
MapData, after the Core builds the V2 payload and before it is returned. - If it throws: the component is caught by
ComponentEvaluatorand rendered asErrored— the page survives (see Error & resilience contract). - Use case: a computed column, a provider-data overlay, masking a value, enriching rows from a second source.
public sealed class MovieListComponent : ListComponent
{
// Add a derived "Availability" value to each row from data already fetched.
protected override void TransformData(ListV2ViewData data, ListViewData viewData)
{
foreach (var item in data.Items)
{
var start = item.Fields.GetValue<DateTime?>("AVAILABLE_FROM");
var end = item.Fields.GetValue<DateTime?>("AVAILABLE_TO");
item.Fields["AVAILABILITY"] = Availability(start, end); // computed, not stored
}
}
}
Gotcha.
item.Fieldsis the same dictionary the React widget reads by columnDataIndex. To display a value you add here, declare the column inContributeSchema(next). A value with no column is carried but not shown.
2. Schema — ContributeSchema
protected virtual void ContributeSchema(ListSchema schema) { } // List
protected virtual void ContributeSchema(RelatedListSchema schema) { } // SimpleRelatedList / OrderedRelatedList
protected virtual void ContributeSchema(FormSchema schema) { } // Form
protected virtual void ContributeSchema(ContentCriteriaSchema schema) { } // ContentCriteria
- What you can touch:
ListSchema/RelatedListSchema→Columns(List<ColumnV2ViewData>),Filters,FormPageKey,ParentField(related).ColumnV2ViewData→DataIndex,Title,Hidden,DefaultHidden,DefaultOrder,DisabledHide,Sorter,Width,Field(inline edition),RenderConfig.FormSchema→Fields(List<FormFieldSchema>),Title,Configuration.FormFieldSchema→Type(the Reactvariant),Config,Props,Behaviors.
- Fires: inside
MapSchema, after the Core assembles the schema and before it is returned. - If it throws: caught →
Errored(page survives). - Use case: add the column you computed in
TransformData, hide a column, mark a field read-only, add a button. Prefer theListSchemaBuilder/FormSchemaBuilderover editing the collections by hand.
public sealed class MovieListComponent : ListComponent
{
protected override void ContributeSchema(ListSchema schema)
{
ListSchemaBuilder.For(schema)
.AddColumn("AVAILABILITY", "Availability") // show the computed value
.Hide("INTERNAL_NOTES"); // drop a noisy column
}
}
Pair them.
TransformDatasupplies a value;ContributeSchemadeclares the column that displays it. Override both for a computed column.
3. Visibility — ShouldHideComponent
public virtual Task<bool> ShouldHideComponent(ComponentContext context, CancellationToken ct)
=> Task.FromResult(false);
- What you can touch: return
trueto hide. The passedcontext(ComponentContext) exposes onlycontext.ViewData— the component's built view data, typed asobject(cast it to the archetype's view-data type if you need it). For the entity / user / config inputs, read the component's own members —Context.IDs,Context.User,Configuration— not the passedcontext. - Fires: in
ComponentEvaluator, before the component is rendered, as a single whole-component decision (not once per form-group segment). Upgrade note: this check now runs ahead of the form-group branch, so a form-with-groups can be hidden byShouldHideComponentwhere it previously could not — the one intentional behavior change in the seam rollout. A component that doesn't override it is unaffected (defaultfalse). - If it throws: caught →
Errored(page survives). Returningtrueinstead yields the cleanSkippedHiddenoutcome. - Default:
false— never hide. - Use case: hide a panel based on entity state, a feature flag, or the user — without emitting an empty component.
public sealed class PpvScheduleComponent : OrderedRelatedListComponent
{
public override Task<bool> ShouldHideComponent(ComponentContext context, CancellationToken ct)
=> Task.FromResult(Configuration.GetFlag("PPV_ENABLED") == false);
}
4. Permission — OnPermissionResolved
protected virtual void OnPermissionResolved(ComponentPermission permission,
List<ResponseItemContent> responses) { }
What you can touch:
permission.Create/Read/Write/Delete, each aPermissionType:public enum PermissionType { Deny = -1, DontCare = 1, Allow = 2 }Denyalways wins;DontCaredefers to the page/source default;Allowgrants. Adjust in place — the Core already computed the baseline.Fires: at the end of
GetPermissions, on the resolvedComponentPermission.If it throws: caught →
Errored(page survives).Use case: customer-specific permission overrides — the tvopenplatform
GvpCustomPermissionsand CMSESkipDeleteErrorCheckcases — layered on top of the standard computation.
public sealed class SubscriptionFormComponent : FormComponent
{
protected override void OnPermissionResolved(ComponentPermission permission,
List<ResponseItemContent> responses)
{
if (!Context.User.HasCapability("subscription.create"))
permission.Create = PermissionType.Deny; // revoke create; leave the rest as resolved
}
}
5. Save — before persist
// SimpleRelatedList / OrderedRelatedList — the reconciled add/remove set
protected virtual void OnRelatedSaveReconciled(PersistenceComponentSaveRelatedEntitiesData saveData,
PostbackComponentRequest request) { }
// Form — the single-entity save model
protected virtual void OnFormSaveBuilt(PersistenceComponentSaveSingleEntityData saveData,
PostbackComponentRequest request) { }
// ContentCriteria — the built save model (recreate / new-from-item paths)
protected virtual void OnContentCriteriaSaveBuilt(IPersistenceSaveData saveData,
PostbackComponentRequest request) { }
- What you can touch:
PersistenceComponentSaveRelatedEntitiesData→AddedRelateds(List<int>),RemovedRelateds(List<int>),OrderedRelateds(List<string>),ParentIDs,ParentBatchReference,BulkMode.PersistenceComponentSaveSingleEntityData→Fields(Dictionary<string, object>),Etags(Dictionary<int, string>),Files,IDs,Relateds.
- Fires: inside
Save, after the Core builds the persistence save model and before it is sent to the API. - If it throws: the save fails with that exception. Throw a
PersistenceValidationException(TemplateComponentKey, "{DICT:…}")to surface a clean, localized validation message to the user (this is exactly what the realSubscriptionRelationcomponent does). - Use case: enforce a rule on the change set, set a generated field, drop a duplicate relation, stamp a derived value.
public sealed class SubscriptionRelationComponent : SimpleRelatedListComponent
{
// One rule on the add set — the whole reason this used to be a ~177-line class.
protected override void OnRelatedSaveReconciled(PersistenceComponentSaveRelatedEntitiesData saveData,
PostbackComponentRequest request)
{
var existing = GetExistingDefinesIds();
saveData.AddedRelateds.RemoveAll(id => existing.Contains(id));
}
}
public sealed class MovieFormComponent : FormComponent
{
protected override void OnFormSaveBuilt(PersistenceComponentSaveSingleEntityData saveData,
PostbackComponentRequest request)
=> saveData.Fields["SLUG"] = Slugify(saveData.Fields.GetValue<string>("TITLE"));
}
Gotcha —
ParentBatchReferenceis load-bearing. When the parent is being created in the same save, related rows reference it throughParentBatchReference; don't null it. UseRelatedSaveBuilder/LifecycleContextif you build a save model from scratch.
6. Save — after persist
protected virtual void OnAfterSaveResponseBuilt(PostbackComponentResponse response,
List<PersistenceComponentResult> results) { }
What you can touch:
response.Status(PostbackStatus),response.ErrorMessage,response.JsonData,response.EtagData.resultscarry the persisted ids/etags.public enum PostbackStatus { Success = 0, SuccessNoContent = 1, Error = 2, NoOperation = 3, ValidationException = 4, ErrorHandled = 5, InvalidEtag = 6, SuccessEtag = 7, SuccessWithWarning = 8 }Fires: at the end of
OnAfterSave, after the Core builds the response and before it is returned.If it throws: the post-save response build fails. Guard side effects so a best-effort failure (e.g. a Kafka hiccup) does not fail an already-committed save — downgrade to
SuccessWithWarninginstead (seePostSaveChain/SaveTransactionContext).Use case: post-save side effects — Kafka publish, external sync, cache invalidation, audit — or augmenting the response.
public sealed class MovieFormComponent : FormComponent
{
protected override void OnAfterSaveResponseBuilt(PostbackComponentResponse response,
List<PersistenceComponentResult> results)
{
if (response.Status == PostbackStatus.Error) return; // don't act on a failed save
_kafka.PublishMovieChanged(Context.IDs.First());
if (MissingArtwork(results))
response.Status = PostbackStatus.SuccessWithWarning; // soft warning, save still succeeds
}
}
7. Delete — before & after persist
protected virtual void OnDeleteBuilt(IPersistenceDeleteData deleteData,
PostbackComponentRequest request) { } // before persist
protected virtual void OnAfterDeleteResponseBuilt(PostbackComponentResponse response,
List<PersistenceComponentResult> results) { } // after persist
- What you can touch: the built
IPersistenceDeleteDatabefore it runs, and thePostbackComponentResponseafterwards (same fields as the save response). - Fires:
OnDeleteBuilton every delete-model theDeletemethod produces (theMissingIdthrow path is naturally excluded);OnAfterDeleteResponseBuiltat the end ofOnAfterDelete. - If it throws:
OnDeleteBuilt→ the delete fails;OnAfterDeleteResponseBuilt→ guard your side effect (the rows are already gone). - Use case: external cleanup on delete, cascade to another system, audit, or adjusting the delete model (etags, ids).
public sealed class AssetRelatedListComponent : SimpleRelatedListComponent
{
protected override void OnAfterDeleteResponseBuilt(PostbackComponentResponse response,
List<PersistenceComponentResult> results)
{
if (response.Status != PostbackStatus.Error)
_blobStore.DeleteOrphans(Context.IDs); // clean external storage after the delete
}
}
8. Fetch shaping — OnSearchCriteriaBuilt (List)
protected virtual void OnSearchCriteriaBuilt(SearchCriteria criteria) { }
- What you can touch:
criteria.GlobalFilter,criteria.Order,criteria.DisplayFields(string[]),criteria.Conditions(Condition[]),criteria.Limit/criteria.Page. - Fires: inside
ListComponentBase.GetSecondaryInformationRequests, on the resolvedSearchCriteria(freshly built or restored from saved state), before it drives the data request. - If it throws: caught →
Errored(page survives). - Use case: shape what the list fetches — a server-side filter, the sort order, the display fields — on every fetch.
public sealed class ActiveOnlyListComponent : ListComponent
{
protected override void OnSearchCriteriaBuilt(SearchCriteria criteria)
{
criteria.GlobalFilter = string.IsNullOrEmpty(criteria.GlobalFilter)
? "STATUS=ACTIVE"
: $"({criteria.GlobalFilter}) AND STATUS=ACTIVE";
criteria.Order = "DATEINS,desc";
}
}
Why List only? SimpleRelatedList and OrderedRelatedList build their V2 fetch request inside the related-list workflow (
CreateGetRelatedsRequest); their onlySearchCriterialives on the legacy refresh path, which is frozen. Shape related-list rendering withTransformData/ContributeSchemainstead.
9. Data source — OnFetchData (List · SimpleRelatedList · OrderedRelatedList)
protected virtual bool ProvidesExternalData => false; // opt-in; default off
protected virtual Task<ListV2ViewData> OnFetchData(FetchContext context, CancellationToken ct)
=> Task.FromResult<ListV2ViewData>(null);
What you can touch: everything about where the rows come from. Set
ProvidesExternalData => trueand return the page of rows yourself —OnSearchCriteriaBuilt/TransformDataonly shape the MibApi fetch; this one replaces it.context(aFetchContext) carriesParentIds(the parent id(s) for a related list; empty for a top-level list),Page(zero-based),Limit,Order(e.g."DATEINS,desc"),MediaType, and two filter fields:Filter(the raw free-text value, astring) andFilterCondition(aConditioncarryingField+Operator+Value). ReadFilterCondition— not justFilter— when you need to honour a structured comparison likeid < 2versusname == 2; the related/ordered drawer sends the field and operator there. Return aListV2ViewData { Items, Total }(declare the columns inContributeSchema); returnnullfor an empty list.Which
FetchContextfields are populated depends on the archetype. A List fills all of them from its resolvedSearchCriteria(Page/Limit/Order/Filter). A SimpleRelatedList / OrderedRelatedList always fillsParentIds+MediaType, plusPage/Limitand a structuredFilterConditionon the async-data endpoint — butOrderis never carried for related/ordered (the related criteria has no sort field), and the async-refresh path (the Refresh button) sends no filter. Always null-guardOrder/Filter/FilterCondition.Fires across the whole render surface. When
ProvidesExternalDatais true the Core skips its MibApi fetch (returnsNoInformationRequest) and callsOnFetchDatainstead — on the initial render and on server-side refresh / paging / sort / filter (including the Refresh button). For a List the refresh path threads the postback criteria intocontext; for a related / ordered list the async data/refresh endpoints resolve the component and call it with the requested page/limit/filter. All three archetypes are wired end-to-end.If it throws: render seam → caught →
Errored(page survives).Use case: the data lives somewhere else — an external service, a job/scheduler API, an aggregate over several backends. Today that forces a full custom component; with this seam it's a stock list/related-list subclass that overrides one method. (This is the seam that retires components like the DMM Importer Scheduler, which reads the external CWF job API — verified on a dev environment: the Importer Schedules page renders ~179 live CWF jobs through the stock
listwidget, no custom component, paging and Refresh intact.)
// A stock list whose rows come from an external jobs API instead of MibApi.
public sealed class ImporterJobsListComponent : ListComponent
{
private readonly IJobsApi _jobs;
protected override bool ProvidesExternalData => true;
protected override void ContributeSchema(ListSchema schema) =>
schema.Columns = new() {
new(){ DataIndex="ID", Title="Job" }, new(){ DataIndex="STATUS", Title="Status" },
new(){ DataIndex="CREATED", Title="Created" },
};
protected override async Task<ListV2ViewData> OnFetchData(FetchContext ctx, CancellationToken ct)
{
var page = await _jobs.ListAsync(ctx.Page, ctx.Limit, ct); // external API
return new ListV2ViewData(new ListViewData()) {
Total = page.Total,
Items = page.Jobs.Select(j => new ItemV2ViewData {
Id = j.Id,
Fields = new() { ["STATUS"] = j.Status, ["CREATED"] = j.CreatedAt }
}).ToList()
};
}
}
flowchart LR
GR["GetRenderData<br/>(self-fetch: skip MibApi,<br/>return the list view path)"] --> MS["MapSchema<br/>ContributeSchema<br/>(Filters + Configuration non-null)"] --> MD{"MapData<br/>ProvidesExternalData?"}
MD -- "true" --> OF[["OnFetchData → your rows"]]
MD -- "false" --> CORE[["MibApi fetch (default)"]]
Gotcha — the seam is multi-method.
OnFetchDatasupplies the data, but the V2 render pipeline runsGetRenderDataandMapSchemabeforeMapData, and both reach MibApi meta/page code that a self-fetch component has skipped. The base classes already guard all of them for you — so overriding onlyOnFetchData(plusContributeSchemafor the columns) is enough. If you do reimplementMapSchema, keep the contract: it must emit a non-nullFiltersandConfiguration— the React list widget callsfilters.flatMap()and readsconfiguration.*, and anullthere renders "An unexpected error happened." (Chartsmay be null.)Related / ordered paging is partial, by design. The related/ordered live render path is workflow-driven and does not instantiate the component; the async data/refresh endpoints resolve it via
IDisplayWorkflow.TryGetExternalListDataand callFetchExternalData(int page, int limit, Condition filter, …), which threads the request into yourFetchContext. The match to List is not total: paging always reachesOnFetchData; a structuredFilterConditionreaches it on the async-data endpoint; but the async-refresh endpoint sends no filter, and sort /Orderis never carried for related/ordered (the related criteria has no order field). If your external source needs sort, or refresh-time filtering, on a related/ordered list, that is not wired yet — track it on MEDIAIBOX-12033. (A top-level List has all of paging, sort, and filter end-to-end.)Permissions on a self-fetch related/ordered list. A self-fetch related list never runs the MibApi relateds fetch, so it has no relateds view-data — the copy-flow and remove-button permission inputs (
RelatedCopyType,RemoveButtonBehavior) don't apply and are skipped.GetPermissionsresolves from the user's media-type permission alone. If you overrideOnPermissionResolvedon a self-fetch list, do not assume those relateds-only inputs were considered.The seam is byte-identical by default — a component that leaves
ProvidesExternalDatafalse fetches from MibApi exactly as before.
Error & resilience contract
The seam stage determines what a thrown exception does. This is deliberate — a buggy render override degrades one panel; a save override is allowed to reject the write.
| Seam stage | Seams | If your override throws |
|---|---|---|
| Render | TransformData, ContributeSchema, ShouldHideComponent, OnSearchCriteriaBuilt, OnPermissionResolved |
ComponentEvaluator catches it → the component's outcome is Errored. The page still renders; only this panel is affected. Nothing crashes. |
| Save / Delete (before persist) | OnRelatedSaveReconciled, OnFormSaveBuilt, OnContentCriteriaSaveBuilt, OnDeleteBuilt |
The write fails with your exception. Throw PersistenceValidationException(TemplateComponentKey, "{DICT:KEY}") for a clean, localized user message; other exceptions surface as a generic error. |
| Save / Delete (after persist) | OnAfterSaveResponseBuilt, OnAfterDeleteResponseBuilt |
The entity is already committed. A throw here only breaks the response build, not the data. Guard best-effort side effects and downgrade to SuccessWithWarning rather than throwing. |
For after-persist side effects that must be observable or rolled back, use
PostSaveChain / PostSaveResult
(aggregate to the worst status) or
SaveTransactionContext.OnCompensate
(reverse-order compensation).
The golden rule. A seam you do not override changes nothing — the component is byte-identical to the stock one. Keep your override that way: guard side effects on success, and only throw from a before-persist seam when you actually mean to reject the write.
Which seam do I need?
| I want to… | Seam(s) | Notes |
|---|---|---|
| Add a computed/derived column | TransformData + ContributeSchema |
value in Fields, column in schema |
| Hide / reorder / relabel columns | ContributeSchema |
use ListSchemaBuilder |
| Mark a form field read-only / change its variant | ContributeSchema (Form) |
use FormSchemaBuilder.OverrideWhere(...) |
| Filter / sort what a list fetches | OnSearchCriteriaBuilt |
List only |
| Hide the whole panel | ShouldHideComponent |
honoured for all archetypes |
| Grant/deny a permission for this component | OnPermissionResolved |
Deny wins |
| Block a save with a message | before-persist save seam → throw PersistenceValidationException |
or a Validation rule |
| Tweak the change set (drop/add/dedupe relations, set a field) | OnRelatedSaveReconciled / OnFormSaveBuilt |
mutate the save model in place |
| Run a side effect after save/delete (Kafka, sync, cache, audit) | OnAfterSaveResponseBuilt / OnAfterDeleteResponseBuilt |
guard on success; downgrade to SuccessWithWarning |
| Validate the whole change set with ordered rules | Validation registry, called from a save seam |
building blocks |
| Reconcile a collection (create/update/delete diff) | CrudReconciler from a save seam |
building blocks |
| Swap the data source entirely (external API instead of MibApi) | ProvidesExternalData + OnFetchData |
List / SimpleRelated / OrderedRelated — see §9 |
Testing your override
A seam is a tiny, isolated method, so test your logic directly — subclass the Core component, reach the protected seam, and assert default-vs-override behavior. This mirrors the framework's own seam test suite.
[TestFixture]
public class MovieListSeamTests
{
// Expose the protected seam for the test.
private class Probe : MovieListComponent
{
public void Invoke(ListV2ViewData data, ListViewData raw) => TransformData(data, raw);
}
[Test]
public void TransformData_AddsAvailabilityColumn()
{
var data = new ListV2ViewData { Items = { /* an item with AVAILABLE_FROM/TO */ } };
new Probe().Invoke(data, new ListViewData());
Assert.That(data.Items[0].Fields.ContainsKey("AVAILABILITY"), Is.True);
}
}
For save seams whose host method needs heavy collaborators, contract-test the
seam directly (a Probe that calls the protected method and asserts the model
was mutated) — the surrounding Core path is already covered by the framework
tests. NSubstitute + reflection field injection covers the cases where you do
want the seam exercised through the real Save/OnAfterSave.
Seam availability matrix
| Capability | Seam | List | Related | Form | Ordered | ContentCriteria |
|---|---|---|---|---|---|---|
| Render data | TransformData |
✅ | ✅ | ✅ | ✅ | ✅ |
| Schema | ContributeSchema |
✅ | ✅ | ✅ | ✅ | ✅ |
| Visibility | ShouldHideComponent |
✅ | ✅ | ✅ | ✅ | ✅ |
| Permission | OnPermissionResolved |
✅ | ✅ | ✅ | ✅ | ✅ |
| Save · before persist | OnRelatedSaveReconciled / OnFormSaveBuilt / OnContentCriteriaSaveBuilt |
— | ✅ | ✅ | ✅ | ✅ |
| Save · after persist | OnAfterSaveResponseBuilt |
— | ✅ | ✅ | ✅ | ✅ |
| Delete · before persist | OnDeleteBuilt |
— | ✅ | ✅ | ✅ | ✅ |
| Delete · after persist | OnAfterDeleteResponseBuilt |
— | ✅ | ✅ | ✅ | ✅ |
| Fetch shaping | OnSearchCriteriaBuilt |
✅ | — | — | — | — |
| Data source (swap fetch) | ProvidesExternalData + OnFetchData |
✅ | ✅ | — | ✅ | — |
(List has no entity save/delete of its own — it is a read surface — so the save/delete seams do not apply to it.)
Reusable building blocks
Beyond the per-component seams, a toolbox under
MediaiBox.Cms.FrontEnd.Server.Component.Extensions removes the copy-pasted
plumbing the old custom classes carried — CRUD reconciliation, schema builders,
JSON helpers, validation, tree building, date/timezone math, a resilience
wrapper, a request-scoped fetch cache, and more.
Full reference: Extension Building Blocks — all 27 helpers, grouped by family, each with signatures and a usage example.
| Family | Headliners |
|---|---|
| CRUD reconciliation | CrudReconciler, ICrudReconciler<TRow,TKey>, BulkReconciler, OrderedReconciler |
| Save / persistence | RelatedSaveBuilder, EtagPolicy, IdCodec, SaveTransactionContext, PostSaveChain |
| Schema building | ListSchemaBuilder, FormSchemaBuilder |
| Validation | ValidationRuleRegistry<T>, IValidationRule<T>, ValidationErrors |
| Data / JSON / trees | JsonHelpers, TreeBuilder, DateConverter |
| Lifecycle & state | LifecycleContext, ComponentRequestState |
| Runtime / resilience | Resilience, RequestFetchCache, CorrelationContext, RenderBudget |
Before & after
MovieFormComponent is ~269 lines — a full MapData / MapSchema / Save
/ OnAfterSave reimplementation — to add three behaviors: a Kafka publish on
save, a parent status update, and a metadata-language validation. As a Core
subclass it becomes the stock FormComponent plus:
public sealed class MovieFormComponent : FormComponent
{
protected override void OnFormSaveBuilt(PersistenceComponentSaveSingleEntityData saveData,
PostbackComponentRequest request)
=> ValidateMetadataLanguage(saveData); // ~15 lines of real logic
protected override void OnAfterSaveResponseBuilt(PostbackComponentResponse response,
List<PersistenceComponentResult> results)
{
if (response.Status == PostbackStatus.Error) return;
_kafka.PublishMovieChanged(Context.IDs.First());
UpdateParentSeriesStatus(Context);
}
}
Everything else — fetch, schema, the entire save pipeline, permissions, response shaping — is inherited and unchanged.
Worked, real examples: the Migration Cookbook walks three actual customer components (SubscriptionRelation, MovieForm, DescriptionList) from their wrapper implementations to a Core subclass + seam, with line-count deltas and the adoption trade-offs.
What is not yet a seam
These were deliberately deferred; track them on MEDIAIBOX-12033 before relying on them:
- Richer data-fetch helpers. The data-source swap itself shipped for all
three list archetypes, including the related/ordered async data/refresh path —
OnFetchData(§9). Still pending around it: the additive request builders (BuildPrimary/Secondary/TertiaryRequest), a typedPagedFetch, and polymorphicRegisterEntityTypeHandler. OnRenderErrordegraded render. Resilience already exists — a throwing component is caught and markedErrored(the page never crashes). A future seam would let a component supply a visible placeholder instead of being dropped.- Field-level schema —
ComputeFieldValue, async/cascadingIOptionProvider,ResolveFieldState. For now do field-level work insideContributeSchema(you have the whole schema) andTransformData. - ContentCriteria restriction internals —
BuildRestriction,ConvertOperator,ValidateSelection. - Custom-action pipeline (
RegisterAction) and the non-CRUDViewComponentBasearchetype.
Rules of the road
- Don't break the default. A subclass that overrides nothing must behave identically to the Core component. Guard side effects on success; don't throw from a render or after-persist seam unless you mean it.
- V2 only. Seams are the V2/React path. The legacy Razor
Index()viewers are frozen; don't route legacy behavior through a seam. - Test the override, not the framework. See Testing your override.
- Reuse the building blocks. Reach for
ListSchemaBuilder, the CRUD reconciler, and the JSON helpers before writing plumbing — that plumbing is exactly what they were built to delete.