Hi punta,
Great to hear it's working cleanly. When each table has its own different columns, your ViewComponent + per-repository approach is a solid path — different columns means a different entity, and ABP's standard generic repositories handle that well while keeping UoW, audit logs, soft-delete and data filters automatic.
Alper's ADO.NET suggestion is also a great option, especially for read-only / reporting-style access where you don't need those cross-cutting features. Both routes are widely used in ABP apps — the trade-off is just "stay inside ABP infrastructure" vs "lighter raw access". Your call here makes total sense.
Side note for completeness: ABP 10.0 added an IRepository.EntityName / SetEntityName(...) API (docs) for a different scenario — multiple tables that share the same schema, where you want a single entity type to point at different tables at runtime (EF Core shared-type entity types under the hood). Not applicable to your case, but worth knowing in case it ever fits a future feature.
Optional refinement if your template count grows: resolve the right repository through IServiceProvider based on the template key (a small factory/strategy), so ViewComponents stay focused on rendering and the dispatch logic lives in one place. Totally optional — what you have already works.
Thanks!
: )
Hi,
You're not missing anything on the backend. The Object Extension + MapEfCoreProperty you wrote already pushes PaymentReference all the way through: the value is saved to the column, loaded back into ExtraProperties, and the AppService returns it as part of the DTO. If you call GET /api/payment-admin/requests you'll see the value in the JSON like this:
{
"items": [
{
"id": "...",
"currency": "USD",
"extraProperties": { "PaymentReference": "your-value" }
}
]
}
The reason it doesn't show up in the Payment Requests table is purely a UI thing: that page's column list is hardcoded in index.js, it does not iterate over object extensions the way Identity does. So you just need to add the column yourself via the Virtual File System.
Pull the original index.js from the Payment module source (abp get-source Volo.Payment or via ABP Suite / ABP Studio "Get Source Code" — the file is at Volo.Payment.Admin.Web/Pages/Payment/Requests/index.js), then drop a copy into your own MVC web project at the same relative path Pages/Payment/Requests/index.js, and add one entry to columnDefs:
{
title: l("DisplayName:PaymentReference"),
orderable: false,
data: "extraProperties.PaymentReference"
}
Two things worth noting:
extraProperties.PaymentReference is the same format ABP's own UI extension system uses (see ui-extensions.js), and extraProperties is camelCase while the dictionary key stays Pascal-case — that's the actual JSON shape, so the path is extraProperties.PaymentReference, not extraProperties.paymentReference.index.js under Pages/Payment/Requests/ overrides the module's embedded one through the VFS automatically, no extra registration needed.We'll look into wiring the Payment Requests datatable into the UI extension system in a future version so this becomes automatic like Identity, but for now the override above is the way.
Thanks.
Hi,
ABP doesn't add anything special on top of the ASP.NET Core ViewComponent pipeline here, so the standard approach works: an MVC/Razor Page endpoint picks the ViewComponent at runtime and returns ViewComponent(type, args). The result is a pure HTML fragment (no _Layout), perfect to inject via AJAX.
The good news is ABP itself already does exactly this in two places, so you can copy the pattern directly:
1) SettingManagement page — the closest match to your scenario. Modules register a SettingPageGroup carrying an Id, a ViewComponent Type, and an arbitrary Parameter. A Razor Page handler resolves the group at runtime and renders the corresponding ViewComponent. The front-end posts to that handler via AJAX and appends the returned HTML.
OnPostRenderViewAsyncId + Type ComponentType + object Parameter): SettingPageGroup.csThe core of the server side is just this:
public virtual async Task<IActionResult> OnPostRenderViewAsync(string id)
{
var context = await SettingPageContributorManager.ConfigureAsync();
var view = context.Groups.FirstOrDefault(x => x.Id == id);
if (view != null)
{
return ViewComponent(view.ComponentType, view.Parameter);
}
return NoContent();
}
And the client side:
abp.ajax({
type: 'POST',
url: 'SettingManagement?handler=RenderView&id=' + id,
dataType: 'html'
}).done(function (response) {
$('#tab-content').append(response);
});
The id lookup against a pre-registered dictionary is your whitelist — clients can never request an arbitrary type.
2) CmsKit widgets controller — a plain AbpController action returning a VC, picked by Type: CmsKitCommonWidgetsController.cs.
If you'd rather use an MVC controller (instead of a Razor Page handler), here's the equivalent — I verified it locally:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Volo.Abp.AspNetCore.Mvc;
public class DynamicComponentController : AbpController
{
// Server-side whitelist mapping client keys to VC types.
private static readonly Dictionary<string, Type> Allowed =
new(StringComparer.OrdinalIgnoreCase)
{
["OrderSummary"] = typeof(OrderSummaryViewComponent),
["CustomerCard"] = typeof(CustomerCardViewComponent)
};
[HttpGet]
public IActionResult Render(string component, string id)
{
if (!Allowed.TryGetValue(component, out var type))
{
return BadRequest("Component is not allowed.");
}
// The keys on the anonymous args object must match the
// Invoke / InvokeAsync parameter names on the VC.
return ViewComponent(type, new { id });
}
}
A few things worth knowing:
ViewComponent(type, args) returns a ViewComponentResult that renders only the component template — no _Layout, no theme chrome — which is exactly what AJAX wants.Invoke / InvokeAsync parameter names exactly. If they don't, the VC still renders but parameters arrive as null/default — easy gotcha.Type.GetType(userInput).Thanks,
Hi punta,
Thanks! Before we suggest a concrete approach, just two quick questions so we point you to the right pattern:
If you can also drop a small snippet of your current Dictionary<TKey, TValue> of delegates and how the controller uses it, that'd help us match the advice to your real code.
Thanks!
Hi,
Great that Option 2 worked! Your asp-route-handle="@Model.Handle" fix is exactly the right pattern. Here's the why behind it, so the same gotcha doesn't surprise you elsewhere.
When you click from /@Punta/About to Home via <a asp-action="Index">, ASP.NET Core walks the URL generation algorithm:
{ controller = PrivateSite, action = About, handle = Punta }{ controller = PrivateSite, action = Index }The required values for controllers are controller + action, processed left-to-right. Rule 2.4 in the URL generation reference → Route value invalidation algorithm says:
If the ambient value and the explicit value are present, and the two values are different, reject the ambient value and all subsequent ambient values.
So when action = Index (explicit) ≠ action = About (ambient), the algorithm rejects the ambient action and every ambient value after it — including handle. The template @{handle} then has no value, URL generation fails, and the anchor tag helper emits an empty href. Same-action self-links work because there's no action mismatch, so the ambient handle survives.
This is by design — the algorithm intentionally avoids mixing route values that belong to a different endpoint. Passing handle explicitly via asp-route-handle is the standard way to opt back in.
You can drop the @Model.Handle dependency (so the layout doesn't require every page model to expose a Handle property) by reading the route value directly:
@{
var handle = (string?)ViewContext.RouteData.Values["handle"];
}
<a class="nav-link" asp-route-handle="@handle" asp-action="Index">Home</a>
<a class="nav-link" asp-route-handle="@handle" asp-action="About">About</a>
The handle variable is scoped to the layout file, and views no longer need to plumb it through their models.
Heads-up: if anywhere in your controllers you call Url.Action("About") or RedirectToAction("About") to jump between actions of PrivateSiteController, you'll hit the same invalidation and get an empty URL / a redirect to nowhere. Pass the handle explicitly there too:
return RedirectToAction("About", new { handle = RouteData.Values["handle"] });
Thanks
Great : )
Hi punta,
Since your solution is tiered, I'll call out where each file goes.
LastName column on AbpUsersIn *.Domain.Shared/YourProjectNameModuleExtensionConfigurator.cs:
using System.ComponentModel.DataAnnotations;
using Volo.Abp.Identity;
using Volo.Abp.Localization;
using Volo.Abp.ObjectExtending;
ObjectExtensionManager.Instance.Modules()
.ConfigureIdentity(identity =>
{
identity.ConfigureUser(user =>
{
user.AddOrUpdateProperty<string>(
"LastName",
property =>
{
property.Attributes.Add(new StringLengthAttribute(64));
property.DisplayName = new FixedLocalizableString("Last Name");
property.UI.OnCreateForm.IsVisible = true;
property.UI.OnEditForm.IsVisible = true;
property.UI.OnTable.IsVisible = true;
property.Configuration[IdentityModuleExtensionConsts.ConfigurationNames.AllowUserToEdit] = true;
});
});
});
In *.EntityFrameworkCore/YourProjectNameEfCoreEntityExtensionMappings.cs:
ObjectExtensionManager.Instance
.MapEfCoreProperty<IdentityUser, string>(
"LastName",
(entityBuilder, propertyBuilder) =>
{
propertyBuilder.HasMaxLength(64);
});
Then Add-Migration Added_LastName_To_AbpUsers + run DbMigrator.
The AllowUserToEdit = true line is what makes LastName automatically render on /Account/Manage (Personal info tab) — no extra code needed for that page.
Surname as "Middle Name" only on the UIThe Surname label is read from two different localization resources:
AccountResource — used on /Account/Manage (Pro Account module)IdentityResource — used on /Identity/Users admin pagesOverride both for the change to be consistent. Don't translate or rename the property in code, just override the localization keys.
Create Localization/AccountOverrides/en.json and Localization/IdentityOverrides/en.json in your *.AuthServer project, both with:
{
"Culture": "en",
"Texts": {
"DisplayName:Surname": "Middle Name"
}
}
Mark them as EmbeddedResource in the .csproj, then in YourProjectNameAuthServerModule.ConfigureServices:
Configure<AbpLocalizationOptions>(options =>
{
options.Resources
.Get<Volo.Abp.Account.Localization.AccountResource>()
.AddVirtualJson("/Localization/AccountOverrides");
options.Resources
.Get<Volo.Abp.Identity.Localization.IdentityResource>()
.AddVirtualJson("/Localization/IdentityOverrides");
});
For other languages just add zh-Hans.json, de.json, etc. in the same folders.
The RegisterModel you'll be subclassing is Volo.Abp.Account.Public.Web.Pages.Account.RegisterModel from the Pro Account module (NuGet package Volo.Abp.Account.Pro.Public.Web). Its default PostInput only contains UserName/EmailAddress/Password/Code — Name and Surname aren't collected during registration out of the box, so you need to extend it.
In *.AuthServer/Pages/Account/Register.cshtml.cs:
using System.ComponentModel.DataAnnotations;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Volo.Abp.Account.ExternalProviders;
using Volo.Abp.Account.Public.Web;
using Volo.Abp.Account.Public.Web.Pages.Account;
using Volo.Abp.Data;
using Volo.Abp.Identity;
using Volo.Abp.Security.Claims;
using IdentityUser = Volo.Abp.Identity.IdentityUser;
namespace YourCompany.YourProject.AuthServer.Pages.Account;
public class CustomRegisterModel : RegisterModel
{
[BindProperty]
public new CustomPostInput Input { get; set; } = new();
public CustomRegisterModel(
IAuthenticationSchemeProvider schemeProvider,
IOptions<AbpAccountOptions> accountOptions,
IAccountExternalProviderAppService accountExternalProviderAppService,
ICurrentPrincipalAccessor currentPrincipalAccessor,
IHttpClientFactory httpClientFactory)
: base(schemeProvider, accountOptions, accountExternalProviderAppService, currentPrincipalAccessor, httpClientFactory)
{
}
public override Task<IActionResult> OnPostAsync()
{
// Sync the extended input back to the base Input so base.OnPostAsync sees UserName/Email/Password/Code.
base.Input = new PostInput
{
UserName = Input.UserName,
EmailAddress = Input.EmailAddress,
Password = Input.Password,
Code = Input.Code
};
return base.OnPostAsync();
}
protected override async Task<IdentityUser> RegisterLocalUserAsync()
{
// Base call creates the user via AccountAppService.RegisterAsync (UserName/Email/Password only).
var user = await base.RegisterLocalUserAsync();
user.Name = Input.Name;
user.Surname = Input.Surname;
user.SetProperty("LastName", Input.LastName);
(await UserManager.UpdateAsync(user)).CheckErrors();
return user;
}
public class CustomPostInput : PostInput
{
[Required]
[StringLength(64)]
public string Name { get; set; }
[StringLength(64)]
public string Surname { get; set; }
[StringLength(64)]
public string LastName { get; set; }
}
}
Then *.AuthServer/Pages/Account/Register.cshtml (copy the original from Volo.Abp.Account.Pro.Public.Web/Pages/Account/Register.cshtml and adjust). The important parts:
@model YourCompany.YourProject.AuthServer.Pages.Account.CustomRegisterModel
...
@* keep the default UserName / Email / Password blocks here *@
@* === Custom fields in the order you want === *@
<div class="form-floating mb-2">
<input asp-for="Input.Name" type="text" class="form-control" placeholder="Name">
<label asp-for="Input.Name">Name</label>
<span asp-validation-for="Input.Name" />
</div>
<div class="form-floating mb-2">
<input asp-for="Input.Surname" type="text" class="form-control" placeholder="Middle Name">
<label asp-for="Input.Surname">Middle Name</label>
<span asp-validation-for="Input.Surname" />
</div>
<div class="form-floating mb-3">
<input asp-for="Input.LastName" type="text" class="form-control" placeholder="Last Name">
<label asp-for="Input.LastName">Last Name</label>
<span asp-validation-for="Input.LastName" />
</div>
ABP's Razor Pages override is path-based: a same-path .cshtml in the AuthServer project takes precedence over the one shipped in Volo.Abp.Account.Pro.Public.Web.
| File | Project |
|---|---|
| ModuleExtensionConfigurator (LastName property) | *.Domain.Shared |
| EfCoreEntityExtensionMappings + Migration | *.EntityFrameworkCore |
| Register.cshtml + Register.cshtml.cs | *.AuthServer |
| AccountOverrides/*.json + IdentityOverrides/*.json + Configure<AbpLocalizationOptions> | *.AuthServer |
Thanks