Hi,
Starting with 10.4.0, Volo.Abp.Account.Pro.Public.Application uses Volo.Abp.Imaging.SkiaSharp instead of Volo.Abp.Imaging.ImageSharp. So upgrading to 10.4.0 or later (10.7.0 is the latest stable) removes the ImageSharp dependency that comes from Account Pro. After the upgrade, run dotnet list package --include-transitive for your solution (including the AuthServer and HttpApi.Host projects) to make sure nothing else still pulls in SixLabors.ImageSharp.
If you need to stay on 10.3.0 for now, you can add a direct reference to the patched version:
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.2" />
Add it to every project where dotnet list package --include-transitive shows SixLabors.ImageSharp, then run the command again to make sure it resolves to 4.1.2.
Note that ImageSharp 4.1.2 checks for a Six Labors license at build time. Without one, Debug builds show a warning and Release builds fail. You can set the license with the SixLaborsLicenseKey / SixLaborsLicenseFile MSBuild properties or by adding a sixlabors.lic file. See https://sixlabors.com/pricing/
Also, in 10.3.0 Account Pro only calls ImageSharp when AbpProfilePictureOptions.EnableImageCompression is enabled, and it's off by default. If you enabled it, test profile picture upload after the change and check the server logs for compression errors.
Thanks
Hi,
This is a bug in the CMS Kit Pro Blazor admin UI. The edit page doesn't keep the page status (and the layout name) when it loads the page, so every save sends Draft back. The MVC UI has Save as draft and Publish buttons, but they were never added to the Blazor UI.
We'll fix this in the next patch release. For now, you can replace the page edit component.
Blazor.Client project, for example under Pages/Cms:MyPageUpdate.razor
@using Blazorise.Markdown
@using Volo.Abp.AspNetCore.Components.Web
@using Volo.Abp.AspNetCore.Components.Web.Theming.Layout
@using Volo.Abp.BlazoriseUI.Components.ObjectExtending
@using Volo.Abp.DependencyInjection
@using Volo.CmsKit.Admin.Pages
@using Volo.CmsKit.Localization
@using Volo.CmsKit.Pro.Admin.Blazor.Pages.CmsKit
@inherits PageUpdate
@attribute [ExposeServices(typeof(PageUpdate))]
@attribute [Dependency(ReplaceServices = true)]
@inject AbpBlazorMessageLocalizerHelper<CmsKitResource> LocalizerHelper
<PageHeader Title="@L["Pages"]" BreadcrumbItems="@BreadcrumbItems">
</PageHeader>
@if (EditingPage != null)
{
<Card>
<CardBody>
<Form>
<Validations @ref="@ValidationsRef" Model="@EditingPage" ValidateOnLoad="false">
<Validation MessageLocalizer="@LocalizerHelper.Localize">
<Field>
<FieldLabel>@L["Title"] *</FieldLabel>
<TextInput @bind-Value="EditingPage.Title" Autofocus="true">
<Feedback>
<ValidationError/>
</Feedback>
</TextInput>
</Field>
</Validation>
<Validation MessageLocalizer="@LocalizerHelper.Localize">
<Field>
<FieldLabel>@L["Slug"] *</FieldLabel>
<Tooltip Text="@L["PageSlugInformation"]">
<TextInput @bind-Value="EditingPage.Slug" Autofocus="false">
<Feedback>
<ValidationError/>
</Feedback>
</TextInput>
</Tooltip>
</Field>
</Validation>
<ExtensionProperties TEntityType="UpdatePageInputDto" TResourceType="CmsKitResource" Entity="@EditingPage" LH="@LocalizerHelper" ModalType="ExtensionPropertyModalType.EditModal" />
</Validations>
<Tabs @bind-SelectedTab="@SelectedTab" RenderMode="TabsRenderMode.LazyLoad">
<Items>
<Tab Name="content">@L["Content"]</Tab>
<Tab Name="script">@L["Script"]</Tab>
<Tab Name="style">@L["Style"]</Tab>
</Items>
<Content>
<TabPanel Name="content">
<DynamicWidgetMarkdown @ref="@MarkdownRef" @bind-Value="@EditingPage.Content"></DynamicWidgetMarkdown>
</TabPanel>
<TabPanel Name="script">
<Markdown @bind-Value="@EditingPage.Script" LineNumbers="true" HideIcons="@HideIcons" AutoDownloadFontAwesome="false" UploadImage="false">
<Toolbar>
<MarkdownToolbarButton Action="MarkdownAction.Bold"/>
</Toolbar>
</Markdown>
</TabPanel>
<TabPanel Name="style">
<Markdown @bind-Value="@EditingPage.Style" LineNumbers="true" HideIcons="@HideIcons" AutoDownloadFontAwesome="false" UploadImage="false">
<Toolbar>
<MarkdownToolbarButton Action="MarkdownAction.Bold"/>
</Toolbar>
</Markdown>
</TabPanel>
</Content>
</Tabs>
</Form>
</CardBody>
<CardFooter>
<Button Color="Color.Primary" Outline Class="me-1" Clicked="@SaveAsDraftAsync">@L["SaveAsDraft"]</Button>
<Button Color="Color.Primary" Clicked="@PublishAsync">@L["Publish"]</Button>
</CardFooter>
</Card>
}
MyPageUpdate.razor.cs
using System.Threading.Tasks;
using Volo.Abp.ObjectExtending;
using Volo.CmsKit.Admin.Pages;
using Volo.CmsKit.Pages;
namespace YourProjectName.Blazor.Client.Pages.Cms;
public partial class MyPageUpdate
{
protected override async Task GetEditingPageAsync()
{
var page = await PageAdminAppService.GetAsync(Id);
EditingPage = new UpdatePageInputDto
{
ConcurrencyStamp = page.ConcurrencyStamp,
Content = page.Content,
Script = page.Script,
Style = page.Style,
Title = page.Title,
Slug = page.Slug,
LayoutName = page.LayoutName,
Status = page.Status
};
page.MapExtraPropertiesTo(EditingPage);
}
protected virtual async Task SaveAsDraftAsync()
{
EditingPage.Status = PageStatus.Draft;
await UpdatePageAsync();
}
protected virtual async Task PublishAsync()
{
EditingPage.Status = PageStatus.Publish;
await UpdatePageAsync();
}
}
ConfigureServices method of your server Blazor project module as well. The server project doesn't scan the client module, so without this line the server side still renders the old page:context.Services.AddTransient<Volo.CmsKit.Pro.Admin.Blazor.Pages.CmsKit.PageUpdate>(
_ => new YourProjectName.Blazor.Client.Pages.Cms.MyPageUpdate());
After this, the edit page shows Save as draft and Publish instead of Submit. Click Publish to save and keep the page published. Save as draft saves it and unpublishes it. New pages are still created as draft, so create the page first, then open it and click Publish.
Thanks
Hi,
10.7.0 is the stable release. It was published on October 6 for both the open source and commercial packages, on NuGet and npm. You can upgrade your solution now.
The docs version selector and the pro-releases page haven't been updated to 10.7 yet. We'll fix both soon.
You can follow the migration guide for the upgrade:
https://abp.io/docs/10.7/release-info/migration-guides/abp-10-7
Thanks
Hi,
Here is the second file, AIManagementBreadcrumbs.cs. Put it next to ModuleBreadcrumbs.cs in your *.Blazor.Client project:
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Volo.Abp.AspNetCore.Components.Web.Theming.MudBlazor.Layout;
using Volo.Abp.UI.Navigation.Localization.Resource;
using Volo.AIManagement.Blazor.MudBlazor.Pages.AIManagement.Chat;
using Volo.AIManagement.Blazor.MudBlazor.Pages.AIManagement.McpServers;
using Volo.AIManagement.Blazor.MudBlazor.Pages.AIManagement.WorkspaceDataSources;
using Volo.AIManagement.Blazor.MudBlazor.Pages.AIManagement.Workspaces;
namespace MyProjectName.Blazor.Client.Breadcrumbs;
public class MyWorkspaces : Workspaces
{
[Inject] protected PageLayout PageLayout { get; set; } = default!;
[Inject] protected IStringLocalizer<AbpUiNavigationResource> LUiNavigation { get; set; } = default!;
private readonly BreadcrumbState _breadcrumbs = new();
protected override void OnInitialized()
{
base.OnInitialized();
ApplyBreadcrumbs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
ApplyBreadcrumbs();
}
private void ApplyBreadcrumbs()
{
if (IsDisposed) return;
_breadcrumbs.Apply(PageLayout,
ModuleBreadcrumbs.Item(LUiNavigation["Menu:Administration"]),
ModuleBreadcrumbs.Item(L["Menu:AIManagement"]),
ModuleBreadcrumbs.Item(L["Menu:Workspaces"]));
}
}
public class MyMcpServers : McpServers
{
[Inject] protected PageLayout PageLayout { get; set; } = default!;
[Inject] protected IStringLocalizer<AbpUiNavigationResource> LUiNavigation { get; set; } = default!;
private readonly BreadcrumbState _breadcrumbs = new();
protected override void OnInitialized()
{
base.OnInitialized();
ApplyBreadcrumbs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
ApplyBreadcrumbs();
}
private void ApplyBreadcrumbs()
{
if (IsDisposed) return;
_breadcrumbs.Apply(PageLayout,
ModuleBreadcrumbs.Item(LUiNavigation["Menu:Administration"]),
ModuleBreadcrumbs.Item(L["Menu:AIManagement"]),
ModuleBreadcrumbs.Item(L["Menu:McpServers"]));
}
}
// Blazor reuses these pages when only the workspace in the URL changes (for example, picking
// an older entry from the browser's back menu), but they only load the workspace once.
// Reloading keeps the page and its breadcrumb on the workspace in the URL.
public class MyChat : Chat
{
[Inject] protected PageLayout PageLayout { get; set; } = default!;
[Inject] protected IStringLocalizer<AbpUiNavigationResource> LUiNavigation { get; set; } = default!;
[Inject] protected NavigationManager ReloadNavigationManager { get; set; } = default!;
private readonly BreadcrumbState _breadcrumbs = new();
private string? _initialWorkspaceName;
private bool _reloading;
protected override void OnInitialized()
{
base.OnInitialized();
_initialWorkspaceName = WorkspaceName;
ApplyBreadcrumbs();
}
protected override async Task OnParametersSetAsync()
{
if (!_reloading && _initialWorkspaceName != null && WorkspaceName != _initialWorkspaceName)
{
_reloading = true;
ReloadNavigationManager.NavigateTo(ReloadNavigationManager.Uri, forceLoad: true);
return;
}
await base.OnParametersSetAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
ApplyBreadcrumbs();
}
private void ApplyBreadcrumbs()
{
if (IsDisposed || _reloading) return;
_breadcrumbs.Apply(PageLayout,
ModuleBreadcrumbs.Item(LUiNavigation["Menu:Administration"]),
ModuleBreadcrumbs.Item(L["Menu:AIManagement"]),
ModuleBreadcrumbs.Item(L["Menu:Workspaces"], ModuleBreadcrumbs.Url(Culture, "ai-management/workspaces")),
ModuleBreadcrumbs.Item(WorkspaceName),
ModuleBreadcrumbs.Item(L["ChatPlayground"]));
}
}
public class MyWorkspaceDataSources : WorkspaceDataSources
{
[Inject] protected PageLayout PageLayout { get; set; } = default!;
[Inject] protected IStringLocalizer<AbpUiNavigationResource> LUiNavigation { get; set; } = default!;
[Inject] protected NavigationManager ReloadNavigationManager { get; set; } = default!;
private readonly BreadcrumbState _breadcrumbs = new();
private Guid? _initialWorkspaceId;
private bool _reloading;
protected override void OnInitialized()
{
base.OnInitialized();
_initialWorkspaceId = WorkspaceId;
}
protected override async Task OnParametersSetAsync()
{
if (!_reloading && _initialWorkspaceId != null && WorkspaceId != _initialWorkspaceId)
{
_reloading = true;
ReloadNavigationManager.NavigateTo(ReloadNavigationManager.Uri, forceLoad: true);
return;
}
await base.OnParametersSetAsync();
ApplyBreadcrumbs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
ApplyBreadcrumbs();
}
private void ApplyBreadcrumbs()
{
if (IsDisposed || _reloading || Workspace == null) return;
_breadcrumbs.Apply(PageLayout,
ModuleBreadcrumbs.Item(LUiNavigation["Menu:Administration"]),
ModuleBreadcrumbs.Item(L["Menu:AIManagement"]),
ModuleBreadcrumbs.Item(L["Menu:Workspaces"], ModuleBreadcrumbs.Url(Culture, "ai-management/workspaces")),
ModuleBreadcrumbs.Item(Workspace.Name, ModuleBreadcrumbs.Url(Culture, $"ai-management/workspaces/{Uri.EscapeDataString(Workspace.Name)}")),
ModuleBreadcrumbs.Item(L["DataSources"]));
}
protected override void Dispose(bool disposing)
{
// This page has no PageHeader, so nothing else clears its items when leaving it.
_breadcrumbs.Release(PageLayout);
base.Dispose(disposing);
}
}
Thanks
Hi,
These MudBlazor pages don't set their breadcrumb items, so LeptonX only shows the page title. The Data Sources page has no page header at all, so it shows nothing. This is a bug and we will fix it in the next release. Your ticket has been refunded.
You can use this workaround for now. It replaces those pages with small subclasses that set the full menu path.
*.Blazor.Client project: ModuleBreadcrumbs.cs below, and AIManagementBreadcrumbs.cs in my next answer (both together are too long for one post). Change the namespace to yours.AddModuleBreadcrumbs() in both your *.Blazor.Client module and your *.Blazor (server) module. The server module doesn't depend on the client module, so without the second call the pages that render on the server still show only the title. After this, the server app logs a warning that the *.Blazor.Client assembly is not in the [DependsOn] chain. You can ignore it, these pages only use [Inject] properties.using MyProjectName.Blazor.Client.Breadcrumbs;
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddModuleBreadcrumbs();
// ...
}
A few notes:
Administration > AI Management > Workspaces > WorkspaceName > Chat Playground (or Data Sources).ModuleBreadcrumbs.cs:
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Localization;
using MudBlazor;
using Volo.Abp.AspNetCore.Components.Web.Theming.MudBlazor.Layout;
using Volo.Abp.UI.Navigation.Localization.Resource;
using Volo.Abp.AuditLogging.Blazor.MudBlazor.Pages.AuditLogging;
using Volo.Abp.Identity;
using Volo.Abp.Identity.Pro.Blazor.MudBlazor.Pages.Identity;
using Volo.Abp.LanguageManagement.Blazor.MudBlazor.Pages.LanguageManagement;
using Volo.Abp.TextTemplateManagement.Blazor.MudBlazor.Pages.TextTemplateManagement;
using Volo.AIManagement.Blazor.MudBlazor.Pages.AIManagement.Chat;
using Volo.AIManagement.Blazor.MudBlazor.Pages.AIManagement.McpServers;
using Volo.AIManagement.Blazor.MudBlazor.Pages.AIManagement.WorkspaceDataSources;
using Volo.AIManagement.Blazor.MudBlazor.Pages.AIManagement.Workspaces;
namespace MyProjectName.Blazor.Client.Breadcrumbs;
public static class ModuleBreadcrumbs
{
public static IServiceCollection AddModuleBreadcrumbs(this IServiceCollection services)
{
services.Replace(ServiceDescriptor.Transient<LanguageManagement, MyLanguageManagement>());
services.Replace(ServiceDescriptor.Transient<LanguageTextManagement, MyLanguageTextManagement>());
services.Replace(ServiceDescriptor.Transient<AuditLogsManagement, MyAuditLogsManagement>());
services.Replace(ServiceDescriptor.Transient<TextTemplateManagement, MyTextTemplateManagement>());
services.Replace(ServiceDescriptor.Transient<TextTemplateContentManagement, MyTextTemplateContentManagement>());
services.Replace(ServiceDescriptor.Transient<TextTemplateInlineContentManagement, MyTextTemplateInlineContentManagement>());
services.Replace(ServiceDescriptor.Transient<OrganizationUnitsManagement, MyOrganizationUnitsManagement>());
services.Replace(ServiceDescriptor.Transient<Workspaces, MyWorkspaces>());
services.Replace(ServiceDescriptor.Transient<McpServers, MyMcpServers>());
services.Replace(ServiceDescriptor.Transient<Chat, MyChat>());
services.Replace(ServiceDescriptor.Transient<WorkspaceDataSources, MyWorkspaceDataSources>());
return services;
}
public static BreadcrumbItem Item(string text, string? href = null)
{
return new BreadcrumbItem(text, href, disabled: href == null);
}
public static string Url(string? culture, string path)
{
return string.IsNullOrEmpty(culture) ? path : $"{Uri.EscapeDataString(culture)}/{path}";
}
}
// Remembers the items a page put into the shared PageLayout, so it can re-apply them
// when another page's cleanup wiped them, and only clear them if they are still its own.
public class BreadcrumbState
{
private BreadcrumbItem[] _items = Array.Empty<BreadcrumbItem>();
private string? _key;
public void Apply(PageLayout pageLayout, params BreadcrumbItem[] items)
{
var key = string.Join("|", items.Select(x => x.Text + ">" + x.Href));
if (key == _key && IsOwner(pageLayout))
{
return;
}
_key = key;
_items = items;
pageLayout.BreadcrumbItems.Clear();
foreach (var item in items)
{
pageLayout.BreadcrumbItems.Add(item);
}
}
public void Release(PageLayout pageLayout)
{
if (IsOwner(pageLayout))
{
pageLayout.BreadcrumbItems.Clear();
}
}
private bool IsOwner(PageLayout pageLayout)
{
return _items.Length > 0 &&
pageLayout.BreadcrumbItems.Count == _items.Length &&
pageLayout.BreadcrumbItems.Zip(_items).All(x => ReferenceEquals(x.First, x.Second));
}
}
// The previous page's PageHeader resets PageLayout after this page initializes during in-app
// navigation, so the items are applied again after each render (a no-op when unchanged).
public class MyLanguageManagement : LanguageManagement
{
[Inject] protected PageLayout PageLayout { get; set; } = default!;
[Inject] protected IStringLocalizer<AbpUiNavigationResource> LUiNavigation { get; set; } = default!;
private readonly BreadcrumbState _breadcrumbs = new();
protected override void OnInitialized()
{
base.OnInitialized();
ApplyBreadcrumbs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
ApplyBreadcrumbs();
}
private void ApplyBreadcrumbs()
{
if (IsDisposed) return;
_breadcrumbs.Apply(PageLayout,
ModuleBreadcrumbs.Item(LUiNavigation["Menu:Administration"]),
ModuleBreadcrumbs.Item(L["Menu:Languages"]),
ModuleBreadcrumbs.Item(L["Languages"]));
}
}
public class MyLanguageTextManagement : LanguageTextManagement
{
[Inject] protected PageLayout PageLayout { get; set; } = default!;
[Inject] protected IStringLocalizer<AbpUiNavigationResource> LUiNavigation { get; set; } = default!;
private readonly BreadcrumbState _breadcrumbs = new();
protected override void OnInitialized()
{
base.OnInitialized();
ApplyBreadcrumbs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
ApplyBreadcrumbs();
}
private void ApplyBreadcrumbs()
{
if (IsDisposed) return;
_breadcrumbs.Apply(PageLayout,
ModuleBreadcrumbs.Item(LUiNavigation["Menu:Administration"]),
ModuleBreadcrumbs.Item(L["Menu:Languages"]),
ModuleBreadcrumbs.Item(L["LanguageTexts"]));
}
}
public class MyAuditLogsManagement : AuditLogsManagement
{
[Inject] protected PageLayout PageLayout { get; set; } = default!;
[Inject] protected IStringLocalizer<AbpUiNavigationResource> LUiNavigation { get; set; } = default!;
private readonly BreadcrumbState _breadcrumbs = new();
protected override void OnInitialized()
{
base.OnInitialized();
ApplyBreadcrumbs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
ApplyBreadcrumbs();
}
private void ApplyBreadcrumbs()
{
if (IsDisposed) return;
_breadcrumbs.Apply(PageLayout,
ModuleBreadcrumbs.Item(LUiNavigation["Menu:Administration"]),
ModuleBreadcrumbs.Item(L["Menu:AuditLogging"]));
}
}
public class MyTextTemplateManagement : TextTemplateManagement
{
[Inject] protected PageLayout PageLayout { get; set; } = default!;
[Inject] protected IStringLocalizer<AbpUiNavigationResource> LUiNavigation { get; set; } = default!;
private readonly BreadcrumbState _breadcrumbs = new();
protected override void OnInitialized()
{
base.OnInitialized();
ApplyBreadcrumbs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
ApplyBreadcrumbs();
}
private void ApplyBreadcrumbs()
{
if (IsDisposed) return;
_breadcrumbs.Apply(PageLayout,
ModuleBreadcrumbs.Item(LUiNavigation["Menu:Administration"]),
ModuleBreadcrumbs.Item(L["Menu:TextTemplates"]));
}
}
public class MyTextTemplateContentManagement : TextTemplateContentManagement
{
[Inject] protected PageLayout PageLayout { get; set; } = default!;
[Inject] protected IStringLocalizer<AbpUiNavigationResource> LUiNavigation { get; set; } = default!;
private readonly BreadcrumbState _breadcrumbs = new();
protected override void OnInitialized()
{
base.OnInitialized();
ApplyBreadcrumbs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
ApplyBreadcrumbs();
}
private void ApplyBreadcrumbs()
{
if (IsDisposed) return;
if (TemplateDefinitionName == null) return;
_breadcrumbs.Apply(PageLayout,
ModuleBreadcrumbs.Item(LUiNavigation["Menu:Administration"]),
ModuleBreadcrumbs.Item(L["Menu:TextTemplates"], ModuleBreadcrumbs.Url(Culture, "text-templates")),
ModuleBreadcrumbs.Item(TemplateDefinitionName));
}
}
public class MyTextTemplateInlineContentManagement : TextTemplateInlineContentManagement
{
[Inject] protected PageLayout PageLayout { get; set; } = default!;
[Inject] protected IStringLocalizer<AbpUiNavigationResource> LUiNavigation { get; set; } = default!;
private readonly BreadcrumbState _breadcrumbs = new();
protected override void OnInitialized()
{
base.OnInitialized();
ApplyBreadcrumbs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
ApplyBreadcrumbs();
}
private void ApplyBreadcrumbs()
{
if (IsDisposed) return;
if (TemplateDefinitionName == null) return;
_breadcrumbs.Apply(PageLayout,
ModuleBreadcrumbs.Item(LUiNavigation["Menu:Administration"]),
ModuleBreadcrumbs.Item(L["Menu:TextTemplates"], ModuleBreadcrumbs.Url(Culture, "text-templates")),
ModuleBreadcrumbs.Item(TemplateDefinitionName));
}
}
public class MyOrganizationUnitsManagement : OrganizationUnitsManagement
{
// The base page already injects PageLayout / LUiNavigation as private members.
[Inject] protected PageLayout BreadcrumbPageLayout { get; set; } = default!;
[Inject] protected IStringLocalizer<AbpUiNavigationResource> BreadcrumbUiNavigation { get; set; } = default!;
private readonly BreadcrumbState _breadcrumbs = new();
public MyOrganizationUnitsManagement(IOrganizationUnitAppService organizationUnitAppService)
: base(organizationUnitAppService)
{
}
protected override void OnInitialized()
{
base.OnInitialized();
ApplyBreadcrumbs();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
ApplyBreadcrumbs();
}
private void ApplyBreadcrumbs()
{
if (IsDisposed) return;
_breadcrumbs.Apply(BreadcrumbPageLayout,
ModuleBreadcrumbs.Item(BreadcrumbUiNavigation["Menu:Administration"]),
ModuleBreadcrumbs.Item(L["Menu:IdentityManagement"]),
ModuleBreadcrumbs.Item(L["OrganizationUnits"]));
}
}
Thanks
Hi,
I'll close this question now. If you run into anything else with the Low-Code page or the order editor, feel free to open a new question.
Thanks
Hi,
Here is a page that uses the orderEditor.ts helper from my previous reply, built with the shadcn components of the React template. It shows the order header and an editable line items grid, like your screenshot.
src/pages/orders/OrderEditorPage.tsx
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from '@tanstack/react-router'
import { Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { isAxiosError } from 'axios'
import { api } from '@/lib/api/axios'
import { createOrderEditorApi } from '@/lib/api/orderEditor'
import type { GetOrderWithLineItems, OrderFormState, OrderWithLineItems } from '@/lib/api/orderEditor'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { DatePicker } from '@/components/ui/date-picker'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
const { load, onSubmit } = createOrderEditorApi(api)
type LineItem = OrderFormState['lineItems'][number]
const emptyForm: OrderFormState = {
orderNumber: '',
orderDate: '',
customerName: '',
lineItems: [],
}
function toForm(data: OrderWithLineItems): OrderFormState {
return {
id: data.order.Id,
orderNumber: data.order.OrderNumber,
orderDate: data.order.OrderDate,
customerName: data.order.CustomerName,
lineItems: data.lineItems.map((line) => ({
id: line.Id,
productName: line.ProductName,
quantity: line.Quantity,
unitPrice: line.UnitPrice,
})),
}
}
export function OrderEditorPage() {
const { orderId } = useParams({ strict: false }) as { orderId?: string }
// Remount when the route changes, so a new order never keeps the state of the previous one.
return <OrderEditor key={orderId ?? 'new'} orderId={orderId} />
}
function OrderEditor({ orderId }: { orderId?: string }) {
const navigate = useNavigate()
const [form, setForm] = useState<OrderFormState>(emptyForm)
const [loading, setLoading] = useState(!!orderId)
const [loadError, setLoadError] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!orderId) {
return
}
let cancelled = false
const run = async () => {
try {
const queryParams: GetOrderWithLineItems = { id: orderId }
const form = await load(queryParams)
if (!cancelled) setForm(form)
} catch {
if (!cancelled) setLoadError(true)
} finally {
if (!cancelled) setLoading(false)
}
}
void run()
return () => {
cancelled = true
}
}, [orderId])
const updateLine = (index: number, patch: Partial<LineItem>) =>
setForm((f) => ({
...f,
lineItems: f.lineItems.map((line, i) => (i === index ? { ...line, ...patch } : line)),
}))
const addLine = () =>
setForm((f) => ({
...f,
lineItems: [...f.lineItems, { productName: '', quantity: 1, unitPrice: 0 }],
}))
const removeLine = (index: number) =>
setForm((f) => ({ ...f, lineItems: f.lineItems.filter((_, i) => i !== index) }))
const validate = (): string | undefined => {
if (!form.orderNumber.trim()) return 'Order number is required.'
if (!form.orderDate) return 'Order date is required.'
for (const [i, line] of form.lineItems.entries()) {
const row = `Line ${i + 1}`
if (!line.productName.trim()) return `${row}: product is required.`
if (!Number.isInteger(line.quantity) || line.quantity < 0 || line.quantity > 2147483647) {
return `${row}: quantity must be a whole number, 0 or more.`
}
if (!Number.isFinite(line.unitPrice) || line.unitPrice < 0) {
return `${row}: unit price must be 0 or more.`
}
}
return undefined
}
const lineAmount = (line: LineItem) => line.quantity * line.unitPrice
const total = form.lineItems.reduce(
(sum, line) => (Number.isFinite(lineAmount(line)) ? sum + lineAmount(line) : sum),
0,
)
const save = async () => {
const validationError = validate()
if (validationError) {
toast.error(validationError)
return
}
setSaving(true)
try {
const result = await onSubmit(form)
setForm(toForm(result))
toast.success('Order saved.')
if (!form.id) {
await navigate({ to: '/order-editor/$orderId', params: { orderId: result.order.Id } })
}
} catch (error) {
const remoteError = isAxiosError(error) ? error.response?.data?.error : undefined
const details = (remoteError?.validationErrors as { message: string }[] | undefined)
?.map((x) => x.message)
.join(' ')
toast.error(details || remoteError?.message || 'Could not save the order.')
} finally {
setSaving(false)
}
}
if (loading) {
return <p className="p-6 text-muted-foreground">Loading...</p>
}
if (loadError) {
return (
<div className="mx-auto max-w-5xl p-6">
<Card>
<CardHeader>
<CardTitle>Could not load the order</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
The order doesn't exist or you don't have permission to view it.
</p>
<Button variant="outline" onClick={() => navigate({ to: '/' })}>
Back
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<h1 className="text-2xl font-semibold">{form.id ? 'Edit order' : 'New order'}</h1>
{/* Lock the form while saving, so edits made during the request are not lost. */}
<fieldset disabled={saving} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Order</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="orderNumber">Order number</Label>
<Input
id="orderNumber"
value={form.orderNumber}
onChange={(e) => setForm({ ...form, orderNumber: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="orderDate">Order date</Label>
<DatePicker
id="orderDate"
disabled={saving}
value={form.orderDate}
onChange={(value) => setForm({ ...form, orderDate: value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="customerName">Customer</Label>
<Input
id="customerName"
value={form.customerName ?? ''}
onChange={(e) => setForm({ ...form, customerName: e.target.value })}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>Line items</CardTitle>
<Button variant="outline" size="sm" onClick={addLine}>
<Plus /> Add line
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Product</TableHead>
<TableHead className="w-32">Quantity</TableHead>
<TableHead className="w-40">Unit price</TableHead>
<TableHead className="w-32 text-right">Amount</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{form.lineItems.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No line items yet.
</TableCell>
</TableRow>
)}
{form.lineItems.map((line, index) => (
<TableRow key={line.id ?? `new-${index}`}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<Input
aria-label={`Product ${index + 1}`}
value={line.productName}
onChange={(e) => updateLine(index, { productName: e.target.value })}
/>
</TableCell>
<TableCell>
<Input
aria-label={`Quantity ${index + 1}`}
type="number"
min={0}
step={1}
value={Number.isNaN(line.quantity) ? '' : line.quantity}
onChange={(e) => updateLine(index, { quantity: e.target.valueAsNumber })}
/>
</TableCell>
<TableCell>
<Input
aria-label={`Unit price ${index + 1}`}
type="number"
min={0}
step="0.01"
value={Number.isNaN(line.unitPrice) ? '' : line.unitPrice}
onChange={(e) => updateLine(index, { unitPrice: e.target.valueAsNumber })}
/>
</TableCell>
<TableCell className="text-right">
{Number.isFinite(lineAmount(line)) ? lineAmount(line).toFixed(2) : '—'}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
aria-label={`Remove line ${index + 1}`}
onClick={() => removeLine(index)}
>
<Trash2 />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 flex justify-end text-sm font-medium">Total: {total.toFixed(2)}</div>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => navigate({ to: '/' })}>
Cancel
</Button>
<Button onClick={save} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</fieldset>
</div>
)
}
Register the routes in src/routes/router.tsx, one for a new order and one for editing:
import { OrderEditorPage } from '@/pages/orders/OrderEditorPage'
const orderEditorNewRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/order-editor',
component: OrderEditorPage,
beforeLoad: authGuard,
})
const orderEditorEditRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/order-editor/$orderId',
component: OrderEditorPage,
beforeLoad: authGuard,
})
const routeTree = rootRoute.addChildren([
orderEditorNewRoute,
orderEditorEditRoute,
// ...your existing routes
])
Open /order-editor to create an order and /order-editor/<order-id> to edit one. Link to these pages from your own menu or order list.
On the edit page the order ID comes from the route, and load({ id }) reads the order with all lines. A new order starts with an empty form and doesn't call load. After saving, the page refreshes the form from the returned data, so new lines get their IDs before the next save.
Thanks
Hi,
A custom page is a good way to do this. The samples below are for ABP 10.6.0 and the React UI template.
Calling the Low-Code API directly
These are the endpoints for your orders page:
GET / PUT /api/low-code/pages/orders/data/{id} and POST /api/low-code/pages/orders/dataGET / POST /api/low-code/pages/orders/children/DemoApp.Orders.OrderLineItem/data, and PUT / DELETE .../data/{lineId}Property values are sent as strings (or null). Creating an order:
{
"properties": {
"OrderNumber": "SO-1001",
"OrderDate": "2026-10-06",
"CustomerName": "Contoso"
}
}
Creating a line item, with the Id of the order you created:
{
"properties": {
"OrderId": "<order-id>",
"ProductName": "Keyboard",
"Quantity": "2",
"UnitPrice": "12.34"
}
}
To read the lines of one order, filter by OrderId with these query parameters: filters[0].name=OrderId, filters[0].value={orderId}, filters[0].filterType=1. The result is paged.
The problem is that saving the page this way needs several requests: one for the order and one for each line. They don't run in one transaction, so a later request can fail after the order or some lines have already been saved.
Saving the order and its lines in one request
The /api/low-code/... create and update endpoints above save one record per request. A better way is your own small endpoint, /api/app/order, that saves the order and all its lines in one request. When database transactions are enabled (they are in a SQL Server solution), the order and all lines are saved or rolled back together. The SQLite template disables transactions, so there the save isn't atomic.
It uses IDynamicPageAppService, so the Low-Code validation, unique checks, permissions and interceptors still run as they do on the generated pages.
Add range rules to the OrderLineItem class from my previous reply. Low-Code validates them on every save, so negative values are rejected even if a client skips its own checks:
[Range(0, int.MaxValue)]
public int Quantity { get; set; }
[Range(0, double.MaxValue)]
[DynamicPropertyType(EntityPropertyType.Money)]
public decimal UnitPrice { get; set; }
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Volo.Abp;
using Volo.Abp.LowCode;
using Volo.Abp.LowCode.Pages;
using Volo.Abp.Uow;
namespace DemoApp.Services.Orders;
public class OrderWithLineItemsDto
{
public DynamicEntityDto Order { get; set; } = new();
public List<DynamicEntityDto> LineItems { get; set; } = new();
}
public class OrderLineInput
{
public Guid? Id { get; set; }
[Required]
public Dictionary<string, string?> Properties { get; set; } = null!;
}
public class SaveOrderWithLineItemsInput
{
[Required]
public Dictionary<string, string?> Properties { get; set; } = null!;
[Required]
public List<OrderLineInput> LineItems { get; set; } = null!;
}
[Authorize]
public class OrderAppService : DemoAppAppService
{
private const string PageName = "orders";
private const string ChildEntityName = "DemoApp.Orders.OrderLineItem";
private readonly IDynamicPageAppService _pages;
public OrderAppService(IDynamicPageAppService pages)
{
_pages = pages;
}
public virtual async Task<OrderWithLineItemsDto> GetAsync(Guid id)
{
return new OrderWithLineItemsDto
{
Order = await _pages.GetRecordAsync(PageName, id),
LineItems = await GetLinesAsync(id)
};
}
[UnitOfWork(true)]
public virtual async Task<OrderWithLineItemsDto> CreateAsync(
SaveOrderWithLineItemsInput input)
{
ValidateInput(input);
if (input.LineItems.Any(x => x.Id.HasValue))
{
throw new UserFriendlyException("New order lines must not contain an ID.");
}
var order = await _pages.CreateAsync(PageName, new DynamicEntityCreateInput
{
Properties = input.Properties
});
var orderId = GetId(order);
foreach (var line in input.LineItems)
{
await _pages.CreateChildAsync(PageName, ChildEntityName,
new DynamicEntityCreateInput
{
Properties = GetLineProperties(orderId, line)
});
}
return await GetAsync(orderId);
}
[UnitOfWork(true)]
public virtual async Task<OrderWithLineItemsDto> UpdateAsync(
Guid id, SaveOrderWithLineItemsInput input)
{
ValidateInput(input);
await _pages.GetRecordAsync(PageName, id);
var existingIds = (await GetLineIdsAsync(id)).ToHashSet();
var suppliedIds = input.LineItems
.Where(x => x.Id.HasValue)
.Select(x => x.Id!.Value)
.ToList();
if (suppliedIds.Distinct().Count() != suppliedIds.Count ||
suppliedIds.Any(x => !existingIds.Contains(x)))
{
throw new UserFriendlyException("Invalid or duplicate order line ID.");
}
await _pages.UpdateAsync(PageName, id, new DynamicEntityUpdateInput
{
EntityId = id,
Properties = input.Properties
});
foreach (var line in input.LineItems)
{
var properties = GetLineProperties(id, line);
if (line.Id.HasValue)
{
await _pages.UpdateChildAsync(PageName, ChildEntityName, line.Id.Value,
new DynamicEntityUpdateInput
{
EntityId = line.Id.Value,
Properties = properties
});
}
else
{
await _pages.CreateChildAsync(PageName, ChildEntityName,
new DynamicEntityCreateInput { Properties = properties });
}
}
foreach (var removedId in existingIds.Except(suppliedIds))
{
await _pages.DeleteChildAsync(PageName, ChildEntityName, removedId);
}
await CurrentUnitOfWork!.SaveChangesAsync();
return await GetAsync(id);
}
private async Task<List<DynamicEntityDto>> GetLinesAsync(Guid orderId)
{
// Read full records, so fields hidden on a line item DataGrid page are still returned.
var lines = new List<DynamicEntityDto>();
foreach (var lineId in await GetLineIdsAsync(orderId))
{
lines.Add(await _pages.GetChildRecordAsync(PageName, ChildEntityName, lineId));
}
return lines;
}
private async Task<List<Guid>> GetLineIdsAsync(Guid orderId)
{
var result = new List<DynamicEntityDto>();
while (true)
{
var page = await _pages.GetChildDataAsync(PageName, ChildEntityName,
new DynamicEntityListRequestDto
{
SkipCount = result.Count,
MaxResultCount = 100,
Filters = new()
{
new()
{
Name = "OrderId",
Value = orderId.ToString(),
FilterType = DynamicEntityFilterType.Equal
}
},
Sortings = new() { new() { Name = "Id", IsDescending = false } }
});
result.AddRange(page.Items);
if (result.Count >= page.TotalCount)
{
return result.Select(GetId).ToList();
}
if (page.Items.Count == 0)
{
throw new UserFriendlyException("Order lines changed. Reload the order.");
}
}
}
private static Dictionary<string, string?> GetLineProperties(
Guid orderId, OrderLineInput line)
{
var properties = new Dictionary<string, string?>(line.Properties);
properties["OrderId"] = orderId.ToString();
return properties;
}
private static Guid GetId(DynamicEntityDto row)
{
return Guid.Parse(Convert.ToString(row["Id"], CultureInfo.InvariantCulture)!);
}
private static void ValidateInput(SaveOrderWithLineItemsInput input)
{
if (input.Properties == null || input.LineItems == null ||
input.LineItems.Any(x => x == null || x.Properties == null))
{
throw new UserFriendlyException("Order properties and line items are required.");
}
}
}
Change PageName, ChildEntityName (the full name of your line item class) and the base class DemoAppAppService to match your project. ABP exposes it as GET /api/app/order/{id}, POST /api/app/order and PUT /api/app/order/{id}.
TypeScript
Put this in src/lib/api/orderEditor.ts. It's for the React template and its axios instance. The base URL of that instance already ends with /api, so the paths start with /app/order. If you use another UI, call the same /api/app/order endpoints with that UI's own HTTP client.
import type { AxiosInstance } from 'axios';
export interface GetOrderWithLineItems {
id: string;
}
export interface DynamicOrder {
Id: string;
OrderNumber: string;
OrderDate: string;
CustomerName?: string | null;
}
export interface DynamicOrderLine {
Id: string;
OrderId: string;
ProductName: string;
Quantity: number;
UnitPrice: number;
}
export interface OrderWithLineItems {
order: DynamicOrder;
lineItems: DynamicOrderLine[];
}
export interface OrderFormState {
id?: string;
orderNumber: string;
orderDate: string;
customerName?: string | null;
lineItems: Array<{
id?: string;
productName: string;
quantity: number;
unitPrice: number;
}>;
}
export function createOrderEditorApi(api: AxiosInstance) {
async function load(queryParams: GetOrderWithLineItems): Promise<OrderFormState> {
const { data } = await api.get<OrderWithLineItems>(
`/app/order/${encodeURIComponent(queryParams.id)}`,
);
return {
id: data.order.Id,
orderNumber: data.order.OrderNumber,
orderDate: data.order.OrderDate,
customerName: data.order.CustomerName,
lineItems: data.lineItems.map((line) => ({
id: line.Id,
productName: line.ProductName,
quantity: line.Quantity,
unitPrice: line.UnitPrice,
})),
};
}
async function onSubmit(form: OrderFormState): Promise<OrderWithLineItems> {
const input = {
properties: {
OrderNumber: form.orderNumber,
OrderDate: form.orderDate,
CustomerName: form.customerName ?? null,
},
lineItems: form.lineItems.map((line) => ({
id: line.id,
properties: {
ProductName: line.productName,
Quantity: String(line.quantity),
UnitPrice: String(line.unitPrice),
},
})),
};
const response = form.id
? await api.put<OrderWithLineItems>(
`/app/order/${encodeURIComponent(form.id)}`, input,
)
: await api.post<OrderWithLineItems>('/app/order', input);
return response.data;
}
return { load, onSubmit };
}
The next reply has a full React page that uses this helper, with the order header and the editable line items grid.
A few things to keep in mind:
lineItems: [] removes all lines.About the preview: the Low-Code System is still in preview. The 10.6 documentation doesn't give a stable release date.
Thanks
Hi,
This is a bug in the feature management modal. It also breaks built-in groups like Setting Management, since their display names differ from their names too.
You can use this workaround for now. Create src/app/feature-management.patch.ts:
import { FeatureManagementComponent } from '@abp/ng.feature-management';
const proto = FeatureManagementComponent.prototype as any;
proto.getCurrentGroup = function (this: any) {
const group = this.groups().find((g: { name: string; displayName: string }) => g.displayName === this.selectedGroupDisplayName());
return group ? (this.features()[group.name] ?? []) : [];
};
Then import it at the top of src/main.ts:
import './app/feature-management.patch';
The fix is in https://github.com/abpframework/abp/pull/26241. Please remove the patch when you upgrade to a version that includes it, because the fix renames the field the patch reads.
Thanks