Samples & Use Cases
Complete, realistic components for real needs — the kind you copy as a starting point. This page is deliberately different from its two neighbours:
| Page | What it gives you |
|---|---|
| Seam Reference → Recipes | minimal single-seam snippets (one task, a few lines) |
| Migration Cookbook | before/after re-platforms of four real customer components |
| This page | whole, realistic components — often combining several seams — with the config wiring, what the operator sees, and a test |
Every sample compiles against the real 7.0 contracts (see the Seam Reference for exact types). Names are illustrative; the shapes are real.
Grounded in the real codebases
Every use case and sample on this page is mined from the two production custom-component codebases:
- CMSE —
…/Agile.Platform.Cms.Frontend.Components/Custom/Pages/…(~17 behavior-overriding custom components) - tvopenplatform-mib3 —
src/Pages/…(~217 behavior-overriding custom components)
The tables below name real components (repo · path) for each seam, so you can open the original alongside the seam version. Paths are repo-relative.
The #1 finding — permission-gate boilerplate. Nearly every custom component opens
Save/MapDatawith aPermissions.Get(...) → early-returngate — it is universal across all 17 Voucher components and the large majority of the others. That gate is exactly what the inherited permission computation +OnPermissionResolvedremove. It is the single highest-volume thing the seams delete.
External data source — OnFetchData / ProvidesExternalData
Rows that come from a service/store, not the MibApi entity:
| Real component | Repo · path | What it really does |
|---|---|---|
UserFormComponent |
mib3 · GVP.Mib3.UsersReact/UserForm |
edits a GVP user through IUserService, not the MibApi DAO |
UserCrmHistoryComponent |
mib3 · GVP.Mib3.UsersReact/UserCrmHistory |
reads the DocumentStorage CRM log (LogUserCrm, limit 100) |
SubscriptionUserComponent |
mib3 · GVP.Mib3.SubscriptionsReact/SubscriptionUsers |
live active-subscriber count via external UserApi |
EmailResendingComponent |
mib3 · GVP.Mib3.UsersReact/EmailResending |
lists a user's sent mail from ProvisionApi |
MoviePurgeListComponent |
mib3 · MoviePurge/MoviePurgeList |
paged external GVP purge list with server-side search |
DrmProtectionComponent |
mib3 · CdnLives/DrmProtection |
DRM key profiles deserialized from external JSON |
UxTargetTreeViewComponent |
mib3 · …UxTargetsReact UxTargetTree |
a UX-target inheritance tree resolved by the shared inheritance engine |
| provider drawers | CMSE · Content/{DescriptionList,GenreList,CastList,ImagesList} |
overlay live provider-API data on the stored DB rows |
(~14 in the GVP React set + the CMSE provider-drawer family.)
Save-time validation / generated field — OnFormSaveBuilt
Reject a write with a message, or stamp a derived value:
| Real component | Repo · path | What it really does |
|---|---|---|
CodeGeneratorComponent |
mib3 · Vouchers/CodeGenerator |
generates a unique 6-digit voucher code (excludes active overlaps) |
BPointComponent |
mib3 · CdnLives/BPoint |
DB-driven regex validation + generates a CDN token via the external OrchAuth service, injecting upt= into the URL |
DiscountTypeComponent / VoucherDiscountComponent |
mib3 · Vouchers/DiscountType, VouchersProducts/VoucherDiscount |
percentage(0–100)-vs-fixed-positive validation (the same rule duplicated across two domains) |
VoucherCodeComponent |
mib3 · VouchersProducts/VoucherCode |
start≤end + non-empty + no active-overlap validation |
…ValidationComponent (×35) |
mib3 · */.../*Validation |
the dominant family — field/cross-field rules that throw PersistenceValidationException |
After-persist side effect — OnAfterSaveResponseBuilt
Kafka, external sync, cascade, audit — guarded so a hiccup never fails a committed save:
| Real component | Repo · path | What it really does |
|---|---|---|
MovieFormComponent |
mib3 · GVP.Mib3.MovieReact/MovieForm |
batched Kafka events per source (created vs updated topic), flag-gated by EnableMib3KafkaEvents |
XpvrComponent |
mib3 · GVP.Mib3.LiveChannelsReact/Xpvr |
two transition-detected Kafka events (channel-deactivated, CPVR-disabled) |
SubscriptionValidation |
mib3 · GVP.Mib3.SubscriptionsReact/SubscriptionsValidations |
Kafka events for removed CPVR live channels (a removed-relation diff) |
PurchaseTypeCriteriaComponent |
mib3 · VouchersReact/.../PurchaseTypeCriteria |
stashes the model, then on the created id calls an external Purchase API |
SubscriptionSyncComponent |
mib3 · Subscriptions/SubscriptionSync |
resyncs subscription grants to an external Task Manager |
BiUserCrmComponent |
mib3 · Users/BiUserCrm |
before/after snapshot diff → CRM audit documents to DocumentStorage |
CatchUpPodSettingsComponent |
mib3 · …LiveChannelsReact/CatchUpPodSettings |
marks the channel to re-sync only if a field actually changed |
UserRightsComponent |
mib3 · GVP.Mib3.UsersReact/UserRights |
bumps the user timestamp when a child save reports NoOperation/SuccessNoContent |
Relation reconcile rule — OnRelatedSaveReconciled
A rule on the reconciled add/remove/order set:
| Real component | Repo · path | What it really does |
|---|---|---|
SubscriptionRelationComponent |
mib3 · GVP.Mib3.CommercialOfferReact/SubscriptionRelation |
enforces one Defines relation per subscription across all offers |
MoviePricingModelComponent |
mib3 · GVP.Mib3.MovieReact/PricingModel |
composite-key dedupe + orphan instance-price cleanup + 4 bulk-edit modes |
RedemptionSelectionComponent |
mib3 · VouchersProductsReact/.../RedemptionSelection |
delete-guard (block removing a purchased row) + counter resequencing |
DeviceAvailabilityComponent |
mib3 · Vouchers/DeviceAvailability |
set-diff of a checkbox grid against existing rows |
NowOnTvChannelComponent |
mib3 · GVP.Mib3.ChannelsReact/ChannelNowOnTvChannels |
ordered reconcile with re-indexing |
GrcMappingsComponent / DisablersComponent |
mib3 · BlackoutsReact/GrcsMapping, GVP.Mib3.CxSettings/Disablers |
canonical diff/delete/upsert reconcile |
Visibility — ShouldHideComponent
| Real component | Repo · path | Hides when… |
|---|---|---|
SubscriptionUserComponent |
mib3 · …/SubscriptionUsers |
the parent is new / bulk / copy |
XpvrComponent |
mib3 · …LiveChannelsReact/Xpvr |
the user lacks XPVR read permission |
CdnDeliveryJobsComponent |
mib3 · GVP.Mib3.MediaFilesReact/CdnDeliveryJobs |
the media is CDN-ignored (data-derived) |
DmmCwfComponent |
mib3 · GVP.Mib3.MovieReact/DmmCwf |
the movie's commercialization type isn't in a config allow-list |
Permission / capability gating — OnPermissionResolved
| Real component | Repo · path | Capability rule |
|---|---|---|
UserPinsComponent |
mib3 · GVP.Mib3.UsersReact/UserPins |
obfuscates PINs and switches to a no-value schema unless UserPinsAccess |
DrmProtectionComponent |
mib3 · CdnLives/DrmProtection |
obfuscates DRM key values unless DrmProtectionEditorKeysAccess |
LabelsComponent |
mib3 · GVP.Mib3.ContentsReact/ContentLabels |
filters rows by per-source read permission |
OperationalTasksComponent |
mib3 · …LiveChannelsReact/OperationalTasks |
panel visible only to the Administrator group |
Computed value / schema tweak — TransformData · ContributeSchema
| Real component | Repo · path | What it really does |
|---|---|---|
UserHistoryComponent |
mib3 · GVP.Mib3.UsersReact/UserHistory |
derives a display purchase-status string from status × active × product type |
UsersCriteriaComponent |
mib3 · Vouchers/UsersCriteria |
UTC ↔ user-timezone conversion on read/write |
ContentRelatedListMibComponent |
CMSE · ImageEdit/ContentRelatedListMibComponent |
toggles add/edit/limit capabilities purely from config keys |
UxTargetsOverridesComponent |
mib3 · …UxTargetsReact/UxTargetsOverrides |
permission-conditional schema (editable only with write) |
Honestly blocks-only (no clean seam — use the building blocks, stay custom)
| Real component | Repo · path | Why no seam fits |
|---|---|---|
DescriptionListMibComponent / GenreListComponent |
CMSE · Content/… |
N strings inside one metadata_field JSON array → rows by composite id (CompositeIdCodec + JsonHelpers + CrudReconciler) |
SchedulerComponent |
CMSE · Jobs/SchedulerComponent |
DB ↔ AWS scheduler state machine (EventBridge create/update/delete by trigger transition) |
TimeshiftValidationComponent |
mib3 · …SubscriptionsReact/TimeshiftValidation |
maintains a shadow/clone "timeshift" subscription entirely on save |
JobsWorkflowComponent |
mib3 · CustomComponents/JobWorkflowReact |
a config-keyed strategy dispatch over the whole lifecycle |
UsersCriteriaUploadComponent / MultipleCopyComponent |
mib3 · Vouchers… |
base64-CSV ingestion / bulk N-copy clone — bespoke actions |
VoucherValidationComponent |
mib3 · VouchersProducts/VoucherValidation |
reaches into a sibling component's reconciled save data (cross-component) |
Use-case catalogue
"I want to…" → the seam(s) → a sample below (and see the real components above). Broader than the Which seam do I need? table — these are concrete product scenarios.
Lists
| Use case | Seam(s) | Sample |
|---|---|---|
| Show rows from an external service (jobs, billing, a 3rd-party catalogue) | OnFetchData + ContributeSchema |
1 |
| Server-side default filter the operator can't remove (tenant/status scoping) | OnSearchCriteriaBuilt |
6 |
| Add a computed/derived column (SLA, availability, age) | TransformData + ContributeSchema |
1 |
| Mask / redact a column for users without a capability (PII, secrets) | TransformData + OnPermissionResolved |
1 |
| Force a whole list read-only for a role | OnPermissionResolved |
6 |
| Hide a list panel when a feature flag is off | ShouldHideComponent |
3 |
| Re-label / hide / reorder columns per customer | ContributeSchema (ListSchemaBuilder) |
6 |
Forms
| Use case | Seam(s) | Sample |
|---|---|---|
| Reject a save with a clean localized message (business rule) | OnFormSaveBuilt → PersistenceValidationException |
2, 7 |
| Cross-field / multi-rule validation with ordering | OnFormSaveBuilt + ValidationRuleRegistry |
7 |
| Stamp a generated/derived field on save (slug, checksum, normalized value) | OnFormSaveBuilt |
2 |
| Publish to Kafka / sync to another system after save | OnAfterSaveResponseBuilt (+ PostSaveChain) |
2 |
| Cascade a status change to a parent entity after save | OnAfterSaveResponseBuilt |
2 |
| Make a field read-only / change its input variant | ContributeSchema (FormSchemaBuilder) |
6 |
| Revoke create/delete for users without a capability | OnPermissionResolved |
5 |
Related & ordered lists
| Use case | Seam(s) | Sample |
|---|---|---|
| Reject / dedupe a relation on save (no second "primary", no duplicate) | OnRelatedSaveReconciled |
3 |
| Enforce a cap on the number of related items | OnRelatedSaveReconciled |
4 |
| Clean up external storage when relations are deleted | OnAfterDeleteResponseBuilt |
4 |
| Computed column on related rows | TransformData + ContributeSchema |
3 |
| Hide the related panel by entity state | ShouldHideComponent |
3 |
| Self-fetch related rows from an external source | OnFetchData |
§9 |
Content criteria
| Use case | Seam(s) | Sample |
|---|---|---|
| Gate editing behind a capability | OnPermissionResolved |
5 |
| Add a computed/preview value to the rendered selection | TransformData |
5 |
Complete samples
Each sample is the entire custom class plus its wiring — everything else is inherited. Wire every one the same way (the type-key stays the stock widget):
| Config key | Value |
|---|---|
ASSEMBLY_NAME |
your assembly, e.g. Acme.Cms.Components |
CLASS_NAME |
the fully-qualified class, e.g. Acme.Cms.Components.JobsListComponent |
| (type-key) | unchanged — list / form / relatedlist / orderedrelatedlist / contentcriteria |
Full SQL is in the Authoring Guide → Database wiring.
Sample 1 — External-data list with a computed column and masked PII
Scenario. An Import Jobs page whose rows live in an external scheduler API
(not MibApi). Show id/status/created, plus a computed "Age" column, and
mask the submitter's email for operators without the pii.view capability.
Server-side paging, sort and search must work. (List combines OnFetchData +
ContributeSchema + TransformData + OnPermissionResolved.)
Modeled on real components: external-data lists like
MoviePurgeListComponent(mib3 ·MoviePurge/MoviePurgeList) andUserCrmHistoryComponent(mib3 ·GVP.Mib3.UsersReact/UserCrmHistory); the capability-gated masking is exactly whatUserPinsComponentandDrmProtectionComponentdo; the derived column mirrorsUserHistoryComponent's computed purchase-status.
public sealed class JobsListComponent : ListComponent
{
private readonly IJobsApi _jobs;
public JobsListComponent(IJobsApi jobs) => _jobs = jobs;
protected override bool ProvidesExternalData => true;
// columns — declare what the computed/fetched fields render as
protected override void ContributeSchema(ListSchema schema) =>
ListSchemaBuilder.For(schema)
.AddColumn("ID", "Job")
.AddColumn("STATUS", "Status")
.AddColumn("CREATED", "Created", c => c.Sorter = true)
.AddColumn("AGE", "Age") // computed in TransformData
.AddColumn("SUBMITTER", "Submitter");
// data — fetch the requested page from the external service (List threads paging/sort/filter)
protected override async Task<ListV2ViewData> OnFetchData(FetchContext ctx, CancellationToken ct)
{
var page = await _jobs.ListAsync(ctx.Page, ctx.Limit, ctx.Order, ctx.Filter, ct);
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.ToString("o"),
["SUBMITTER"] = j.SubmitterEmail,
}
}).ToList()
};
}
// derive + mask on the rows the user actually sees (runs on initial render AND every self-fetch)
protected override void TransformData(ListV2ViewData data, ListViewData viewData)
{
var canSeePii = Context.User.HasCapability("pii.view");
foreach (var item in data.Items)
{
var created = DateTime.Parse((string)item.Fields["CREATED"]);
item.Fields["AGE"] = $"{(DateTime.UtcNow - created).Days}d";
if (!canSeePii) item.Fields["SUBMITTER"] = "•••";
}
}
}
Operator sees: the stock list widget — sortable/searchable/paged — with a
live Age column and the submitter masked unless they hold pii.view. No custom
React, no MibApi entity behind it.
Why
TransformDataand not justOnFetchData?TransformDataalso runs on the live self-fetch path, so the mask/derive can't be bypassed once the widget re-fetches. See the render-pipeline note.
Sample 2 — Movie form: validate, stamp a slug, publish, cascade
Scenario. A movie edit form that (a) rejects an unknown type with a clean
message, (b) stamps a slug derived from the title, (c) publishes a Kafka
event after save, and (d) cascades a status change to the parent series —
without ever failing a committed save because a side-effect hiccuped. (Form
combines OnFormSaveBuilt + OnAfterSaveResponseBuilt + PostSaveChain.)
Modeled on the real
MovieFormComponent(mib3 ·GVP.Mib3.MovieReact/MovieForm): it really does run the type/game/duplicated-language validations, cascade the parent series/season status with a workflow-history write, and publish batched Kafka events per source (created vs updated topic) gated byEnableMib3KafkaEvents.XpvrComponentandSubscriptionValidationuse the same after-save Kafka pattern.
public sealed class MovieFormComponent : FormComponent
{
private readonly IKafkaWrapper _kafka;
private readonly ISeriesClient _series;
public MovieFormComponent(IKafkaWrapper kafka, ISeriesClient series) { _kafka = kafka; _series = series; }
// (a)+(b) before persist: reject bad input, stamp derived fields
protected override void OnFormSaveBuilt(PersistenceComponentSaveSingleEntityData saveData,
PostbackComponentRequest request)
{
var title = saveData.Fields.GetValueOrDefault("TITLE") as string;
var typeId = Convert.ToInt32(saveData.Fields.GetValueOrDefault("TYPE") ?? 0);
if (typeId == 0)
throw new PersistenceValidationException(TemplateComponentKey, "{DICT:ERROR/UNKNOWN_TYPE}");
if (string.IsNullOrWhiteSpace(title))
throw new PersistenceValidationException(TemplateComponentKey, "{DICT:ERROR/TITLE_REQUIRED}");
saveData.Fields["SLUG"] = Slugify(title); // generated, not entered
}
// (c)+(d) after persist: guarded side effects, aggregated to the worst status
protected override void OnAfterSaveResponseBuilt(PostbackComponentResponse response,
List<PersistenceComponentResult> results)
{
if (response.Status == PostbackStatus.Error) return; // never act on a failed save
var movieId = Context.IDs.First();
var outcome = new PostSaveChain()
.Add(async ct => { await _kafka.PublishMovieChangedAsync(movieId, ct); return PostSaveResult.Success(); })
.Add(async ct =>
{
var ok = await _series.RefreshStatusForMemberAsync(movieId, ct);
return ok ? PostSaveResult.Success() : PostSaveResult.Warning("series status not refreshed");
})
.RunAsync(CancellationToken.None).GetAwaiter().GetResult();
if (outcome.Status == PostSaveStatus.SuccessWithWarning)
response.Status = PostbackStatus.SuccessWithWarning; // soft warning; the movie is saved
}
private static string Slugify(string s) => new string(s.ToLowerInvariant()
.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
}
Operator sees: a normal save. Bad type → a clean localized error, nothing written. Good save → the slug is set, the event fires, the series re-evaluates; if the series refresh hiccups they get a saved-with-warning, not a failure.
Sample 3 — Subscription relations: dedupe rule, computed column, hide-by-flag
Scenario. A related list of subscription↔offer relations that (a) rejects a
duplicate "Defines" relation, (b) shows a computed "Active?" column, and (c)
hides itself entirely when the OFFERS_ENABLED flag is off. (SimpleRelatedList
combines OnRelatedSaveReconciled + TransformData + ContributeSchema +
ShouldHideComponent.)
Modeled on the real
SubscriptionRelationComponent(mib3 ·GVP.Mib3.CommercialOfferReact/SubscriptionRelation), whose actual rule is exactly this: a subscription may beDefines-related to only one commercial offer, validated on save across all offers.SubscriptionRelationshipsComponentenforces a similar typed-relation rule (rejecting TimeShift subscriptions as relations).
public sealed class SubscriptionRelationComponent : SimpleRelatedListComponent
{
private readonly ISubscriptionService _svc;
public SubscriptionRelationComponent(ISubscriptionService svc) => _svc = svc;
public override Task<bool> ShouldHideComponent(ComponentContext context, CancellationToken ct)
=> Task.FromResult(Configuration.GetFlag("OFFERS_ENABLED") == false);
protected override void ContributeSchema(RelatedListSchema schema) =>
ListSchemaBuilder.For(schema).AddColumn("ACTIVE", "Active?");
protected override void TransformData(ListV2ViewData data, ListViewData viewData)
{
foreach (var item in data.Items)
item.Fields["ACTIVE"] = _svc.IsActive(item.Id) ? "Yes" : "No";
}
protected override void OnRelatedSaveReconciled(PersistenceComponentSaveRelatedEntitiesData saveData,
PostbackComponentRequest request)
{
var offerId = Context.IsCopy ? Context.CopiedFrom : Context.IDs.FirstOrDefault();
foreach (var subscriptionId in saveData.AddedRelateds)
if (_svc.WouldDuplicateDefines(subscriptionId, offerId))
throw new PersistenceValidationException(TemplateComponentKey,
$"{{DICT:ERROR/ALREADY_DEFINED}} SubscriptionId: {subscriptionId}");
}
}
Operator sees: the relation table with an Active? column; adding a duplicate "Defines" is blocked with a localized message; the whole tab disappears where offers are disabled.
Sample 4 — Ordered playlist: enforce a cap, clean up on delete
Scenario. An ordered list of playlist entries that (a) rejects a save that
would exceed 50 items, and (b) deletes the rendered thumbnails from blob
storage after entries are removed. (OrderedRelatedList combines
OnRelatedSaveReconciled + OnAfterDeleteResponseBuilt.)
Modeled on real ordered-reconcile components:
NowOnTvChannelComponentandMenusComponent(ordered re-indexing on save); the cap + orphan-cleanup mirrorsMoviePricingModelComponent's composite-key dedupe and orphan instance-price deletion; the after-delete external cleanup follows the guarded side-effect rule.
public sealed class PlaylistComponent : OrderedRelatedListComponent
{
private const int MaxItems = 50;
private readonly IBlobStore _blobs;
public PlaylistComponent(IBlobStore blobs) => _blobs = blobs;
protected override void OnRelatedSaveReconciled(PersistenceComponentSaveRelatedEntitiesData saveData,
PostbackComponentRequest request)
{
// OrderedRelateds carries the final ordered set; cap the total.
if (saveData.OrderedRelateds.Count > MaxItems)
throw new PersistenceValidationException(TemplateComponentKey, "{DICT:ERROR/PLAYLIST_TOO_LONG}");
}
protected override void OnAfterDeleteResponseBuilt(PostbackComponentResponse response,
List<PersistenceComponentResult> results)
{
if (response.Status == PostbackStatus.Error) return;
try { _blobs.DeleteThumbnails(Context.IDs); } // best-effort external cleanup
catch (Exception ex) { Context.MibLog?.Exception(ex); } // never fail the committed delete
}
}
Operator sees: drag-and-drop ordering as usual; trying to save a 51-item playlist is blocked; removing entries also tidies up their external thumbnails.
Sample 5 — Content criteria: capability-gated edit + computed preview
Scenario. A content-criteria selector that is read-only for operators
without criteria.edit, and that surfaces a computed value on the rendered
selection. (ContentCriteria combines OnPermissionResolved + TransformData.)
Modeled on real capability-gating:
UserPinsComponent(UserPinsAccess) andDrmProtectionComponent(DrmProtectionEditorKeysAccess) both gate write/visibility on a custom capability exactly this way;LabelsComponentgates per source.
public sealed class CuratedListCriteriaComponent : ContentCriteriaComponent
{
protected override void OnPermissionResolved(ComponentPermission permission,
List<ResponseItemContent> responses)
{
if (!Context.User.HasCapability("criteria.edit"))
{
permission.Write = PermissionType.Deny; // Deny always wins
permission.Create = PermissionType.Deny;
permission.Delete = PermissionType.Deny;
}
}
protected override void TransformData(ContentCriteriaV2ViewData data, ContentCriteriaViewData viewData)
{
// e.g. annotate the operation so the widget can show "manual + rule"
if (data.Highlights?.Any() == true && data.Groups?.Any() == true)
data.Operation = $"{data.Operation} (+{data.Highlights.Count()} pinned)";
}
}
Operator sees: the criteria builder renders normally but the Save/affordances are gone without the capability; the header reflects pinned highlights.
Sample 6 — Read-only audit list: forced filter, relabeled columns, role lock
Scenario. An audit-log list that (a) is always scoped to the current
tenant regardless of what the operator types, (b) relabels and reorders
columns, and (c) is read-only for everyone (it's a log). (List combines
OnSearchCriteriaBuilt + ContributeSchema + OnPermissionResolved.)
Modeled on real components: the forced server-side scope mirrors the
Configuration.Filter→GlobalFiltershaping; the role lock followsOperationalTasksComponent's admin-group gate; per-source read filtering followsLabelsComponent(mib3 ·GVP.Mib3.ContentsReact/ContentLabels).
public sealed class AuditListComponent : ListComponent
{
protected override void OnSearchCriteriaBuilt(SearchCriteria criteria)
{
var tenant = Context.User.TenantId;
criteria.GlobalFilter = string.IsNullOrEmpty(criteria.GlobalFilter)
? $"TENANT={tenant}"
: $"({criteria.GlobalFilter}) AND TENANT={tenant}"; // non-removable scope
if (string.IsNullOrEmpty(criteria.Order)) criteria.Order = "WHEN,desc";
}
protected override void ContributeSchema(ListSchema schema) =>
ListSchemaBuilder.For(schema)
.Override("WHEN", c => { c.Title = "Timestamp"; c.Sorter = true; })
.SetOrder("WHEN", 0)
.Hide("INTERNAL_TRACE");
protected override void OnPermissionResolved(ComponentPermission permission,
List<ResponseItemContent> responses)
{
permission.Create = PermissionType.Deny; // a log is never edited
permission.Write = PermissionType.Deny;
permission.Delete = PermissionType.Deny;
}
}
Operator sees: a clean, timestamp-first audit list they can only read, always scoped to their tenant even if they try to search across tenants.
Sample 7 — Form with an ordered multi-rule validation registry
Scenario. A subscription form whose save must pass several validations, some
depending on others (don't run the price check until the currency is valid),
collecting all failures rather than stopping at the first. (Form combines
OnFormSaveBuilt + ValidationRuleRegistry.)
Modeled on the largest real family: ~35
…ValidationComponentclasses in tvopenplatform-mib3 (e.g.Subscriptions/SubscriptionValidation,LiveChannels/Validations,Movie/MovieValidation) are exactly this — save-time rule batteries thatthrow PersistenceValidationException.DiscountTypeComponentandVoucherCodeComponentshow the range/uniqueness rule shape. The registry turns the hand-orderedif-throw chains into declarative, dependency-ordered rules.
public sealed class SubscriptionFormComponent : FormComponent
{
protected override void OnFormSaveBuilt(PersistenceComponentSaveSingleEntityData saveData,
PostbackComponentRequest request)
{
var errors = new ValidationRuleRegistry<PersistenceComponentSaveSingleEntityData>()
.Add("currency", (m, e, ct) =>
{
if (!IsKnownCurrency(m.Fields.GetValueOrDefault("CURRENCY") as string))
e.Add("Unknown currency", field: "CURRENCY", code: "CURRENCY_UNKNOWN");
return Task.CompletedTask;
})
.Add("price", (m, e, ct) =>
{
if (Convert.ToDecimal(m.Fields.GetValueOrDefault("PRICE") ?? 0) <= 0)
e.Add("Price must be positive", field: "PRICE", code: "PRICE_INVALID");
return Task.CompletedTask;
}, dependsOn: "currency") // skipped if currency failed
.RunAsync(saveData, CancellationToken.None).GetAwaiter().GetResult();
if (errors.HasErrors)
throw new PersistenceValidationException(TemplateComponentKey,
$"{{DICT:ERROR/VALIDATION}} {string.Join("; ", errors.Items.Select(i => i.Message))}");
}
}
Operator sees: one save attempt surfaces every applicable problem at once, in a stable order, with the dependent price check skipped when the currency is wrong.
Testing the samples
Seams are tiny isolated methods — test your logic directly with a Probe that
exposes the protected seam. (Mirrors the framework's own seam test suite.)
A render/transform seam (no collaborators needed):
private sealed class Probe : JobsListComponent
{
public Probe() : base(new FakeJobsApi()) { }
public void Run(ListV2ViewData d) => TransformData(d, new ListViewData());
}
[Test]
public void Age_Is_Derived_And_Email_Masked_Without_Capability()
{
var c = new Probe { Context = new ContextViewData { User = UserWithout("pii.view") } };
var data = new ListV2ViewData(new ListViewData()) {
Items = { new ItemV2ViewData { Id = 1, Fields = new() {
["CREATED"] = DateTime.UtcNow.AddDays(-3).ToString("o"), ["SUBMITTER"] = "a@b.com" } } } };
c.Run(data);
Assert.That(data.Items[0].Fields["AGE"], Is.EqualTo("3d"));
Assert.That(data.Items[0].Fields["SUBMITTER"], Is.EqualTo("•••"));
}
A before-persist save seam (assert it rejects / mutates the model):
private sealed class Probe : MovieFormComponent
{
public Probe() : base(new NoopKafka(), new NoopSeries()) { }
public void Run(PersistenceComponentSaveSingleEntityData d) => OnFormSaveBuilt(d, new PostbackComponentRequest());
}
[Test]
public void UnknownType_Is_Rejected()
{
var d = new PersistenceComponentSaveSingleEntityData { Fields = new() { ["TITLE"] = "X", ["TYPE"] = 0 } };
Assert.Throws<PersistenceValidationException>(() => new Probe().Run(d));
}
[Test]
public void Slug_Is_Stamped_From_Title()
{
var d = new PersistenceComponentSaveSingleEntityData { Fields = new() { ["TITLE"] = "My Movie!", ["TYPE"] = 2 } };
new Probe().Run(d);
Assert.That(d.Fields["SLUG"], Is.EqualTo("my-movie-"));
}
For a seam you want exercised through the real Save/OnAfterSave, inject the
collaborators with NSubstitute and drive the public method — see the framework's
RelatedSaveSeamTests / OnFetchDataSeamTests for the pattern, and the
Testing your override section.
Related docs. Extending Core Components — the seam catalogue, the pipeline, the resilience contract. Seam Reference — exact signatures, the type glossary, and minimal recipes. Migration Cookbook — before/after re-platforms of four real components. Troubleshooting — when an override misbehaves.