Hi,
This is a bug in Suite's template processing on Windows, not something wrong with your templates — your changes from the previous round were fine.
To fix the output now: remove %%main-entity-tab%% from both customized templates and put these placeholders directly in your panel body instead.
CreateModal:
%%<if:IsChildEntity>%%<abp-input asp-for="%%child-master-entity-name%%Id" />%%</if:IsChildEntity>%%
%%input-properties%%
%%navigation-property-lookup-inputs%%
EditModal:
%%<if:IsChildEntity>%%<abp-input asp-for="%%entity-name%%.%%child-master-entity-name%%Id" type="hidden" />%%</if:IsChildEntity>%%
%%<if:CheckConcurrency>%%<abp-input asp-for="%%entity-name%%.ConcurrencyStamp" hidden="true" suppress-label="true"/>%%</if:CheckConcurrency>%%
%%input-properties%%
%%navigation-property-lookup-inputs%%
These cover everything %%main-entity-tab%% produced (including the hidden master-id input for child entities and the concurrency stamp on the edit modal). After this change the full template is written — markers, footer and closing tags included.
One note for later: if you add a navigation connection to an entity, %%navigation-connection-lookup-tabs%% emits <abp-tab> sections that expect a tabs container, so that part needs its own markup in an off-canvas layout.
Thanks
Hi,
Your JavaScript is actually updating the DOM — it's just updating a copy you can't see, and the badge itself never becomes visible. Three separate things are going on here:
id attributes, the ids get duplicated and document.getElementById(...) always resolves to the hidden mobile copy — the notification list is injected into the invisible panel while the visible one stays empty.data-count based CSS rules, so the inline style="opacity:0;visibility:hidden;" on your badges is never undone. Besides that, the theme only gives .menu-item-badge its badge styling (position, background, size) when the parent link has the active-menu-item class — without that class it's just an unstyled <small> element.<li class="outer-menu-item">. Your component's own outer <li> can't be nested inside it, so the browser closes the theme's <li> early and you end up with an extra empty <li> in the toolbar.Here is a corrected Default.cshtml: no ids (all DOM lookups go through classes and update both rendered copies), active-menu-item toggled for the badge styling and d-none for its visibility, no outer <li>, and a guard at the top so the inline script (which is also included twice) only runs once per page:
<a href="javascript:;" class="lpx-menu-item-link notification-bell" data-lpx-ctx-toggle="notification-bar">
<span class="lpx-menu-item-icon" style="position:relative;">
<i class="lpx-icon action-icon bi bi-bell-fill" aria-hidden="true"></i>
<small class="menu-item-badge notification-count d-none">0</small>
</span>
</a>
<div class="lpx-context-menu" data-lpx-context-menu="notification-bar">
<ul class="lpx-nav-menu-notification notification-menu">
<li class="outer-menu-item">
<a class="lpx-menu-item-link lpx-menu-item">
<span class="lpx-menu-item-icon">
<i class="lpx-icon outer-icon bi bi-bell-fill" aria-hidden="true"></i>
</span>
<span class="lpx-menu-item-text">Notification
<span class="lpx-badge notification-new-badge d-none">0 NEW</span>
</span>
<span data-lpx-close="notification-bar">
<i class="lpx-icon bi bi-x outer-icon dd-icon" aria-hidden="true"></i>
</span>
</a>
</li>
</ul>
<div class="lpx-button-container">
<button class="lpx-button notif-btn-outline notification-mark-all" type="button">Mark all as read</button>
<a class="lpx-button notif-btn-primary" href="/notification/private-messages">See All Notifications</a>
</div>
</div>
<script>
(function() {
'use strict';
// The theme renders each toolbar item twice (toolbar + mobile menu),
// so this script is also included twice per page - run it only once.
if (window.__notificationBadgeInitialized) return;
window.__notificationBadgeInitialized = true;
var currentUnread = 0;
function unwrap(response) {
if (response && typeof response === 'object' && 'result' in response) return response.result;
return response;
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
}
function isPanelOpen() {
return !!document.querySelector('[data-lpx-context-menu="notification-bar"].show');
}
function updateBadge(count) {
var num = parseInt(count, 10) || 0;
currentUnread = num;
document.querySelectorAll('.notification-bell').forEach(function(link) {
link.classList.toggle('active-menu-item', num > 0);
var badge = link.querySelector('.notification-count');
if (badge) {
badge.textContent = num > 99 ? '99+' : String(num);
badge.classList.toggle('d-none', num <= 0);
}
});
document.querySelectorAll('.notification-new-badge').forEach(function(badge) {
badge.textContent = num + ' NEW';
badge.classList.toggle('d-none', num <= 0);
});
}
function startOfDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); }
function groupLabel(date) {
var today = startOfDay(new Date());
var yest = new Date(today);
yest.setDate(today.getDate() - 1);
if (date >= today) return 'TODAY';
if (date >= yest) return 'YESTERDAY';
var dd = String(date.getDate()).padStart(2, '0');
var mm = String(date.getMonth() + 1).padStart(2, '0');
return dd + '.' + mm + '.' + date.getFullYear();
}
function formatTime(date) {
var hh = String(date.getHours()).padStart(2, '0');
var mm = String(date.getMinutes()).padStart(2, '0');
var today = startOfDay(new Date());
var yest = new Date(today);
yest.setDate(today.getDate() - 1);
if (date >= today) return hh + ':' + mm;
if (date >= yest) return 'Yesterday ' + hh + ':' + mm;
var dd = String(date.getDate()).padStart(2, '0');
var mm2 = String(date.getMonth() + 1).padStart(2, '0');
return mm2 + '/' + dd + ' ' + hh + ':' + mm;
}
function renderGroups(items) {
document.querySelectorAll('.notification-menu').forEach(function(menu) {
menu.querySelectorAll('.notif-group').forEach(function(g) { g.remove(); });
if (!items || !items.length) {
var emptyLi = document.createElement('li');
emptyLi.className = 'outer-menu-item notif-group';
emptyLi.innerHTML = '<div class="lpx-notification-empty">No notifications</div>';
menu.appendChild(emptyLi);
return;
}
var groups = [];
items.forEach(function(it, idx) {
var d = new Date(it.creationTime);
var label = groupLabel(d);
var g = null;
for (var j = 0; j < groups.length; j++) {
if (groups[j].label === label) { g = groups[j]; break; }
}
if (!g) { g = { label: label, items: [] }; groups.push(g); }
g.items.push({ data: it, unread: idx < currentUnread, date: d });
});
groups.forEach(function(g) {
var li = document.createElement('li');
li.className = 'outer-menu-item notif-group';
var html = '<div class="lpx-menu-item-title">' + esc(g.label) + '</div>'
+ '<ul class="lpx-notification-items lpx-inner-menu">';
g.items.forEach(function(it) {
html += '<li class="lpx-notification-item' + (it.unread ? ' unread' : '') + '" data-id="' + esc(it.data.id) + '">'
+ '<a class="lpx-notification-item-link">'
+ '<div class="lpx-notification-member-container"><i class="lpx-notification-member-icon bi bi-bell-fill"></i></div>'
+ '<div><p class="lpx-notification-text"><span class="lpx-notification-text-highlight">' + esc(it.data.title)
+ '</span> ' + esc(it.data.content) + '</p>'
+ '<small class="lpx-notification-text-small">' + esc(formatTime(it.date)) + '</small></div>'
+ '</a></li>';
});
html += '</ul>';
li.innerHTML = html;
menu.appendChild(li);
});
});
}
function fetchLatest() {
if (typeof abp === 'undefined' || !abp || !abp.ajax) return;
abp.ajax({
url: (abp.appPath || '') + 'api/notification/private-message?maxResultCount=5&sorting=CreationTime%20DESC',
type: 'GET',
success: function(response) {
var data = unwrap(response);
var items = (data && data.items) ? data.items : [];
renderGroups(items);
},
error: function() {}
});
}
function fetchCount() {
if (typeof abp === 'undefined' || !abp || !abp.ajax) return;
abp.ajax({
url: (abp.appPath || '') + 'api/notification/private-message/unread-count',
type: 'GET',
success: function(response) {
var num = parseInt(unwrap(response), 10) || 0;
updateBadge(num);
if (isPanelOpen()) fetchLatest();
},
error: function() {}
});
}
function markOneRead(id) {
if (typeof abp === 'undefined' || !abp || !abp.ajax) return;
abp.ajax({
url: (abp.appPath || '') + 'api/notification/private-message/' + id + '/mark-as-read',
type: 'POST',
success: function() {
document.querySelectorAll('.lpx-notification-item[data-id="' + id + '"]').forEach(function(el) {
el.classList.remove('unread');
});
fetchCount();
},
error: function() {}
});
}
function markAllRead() {
if (typeof abp === 'undefined' || !abp || !abp.ajax) return;
abp.ajax({
url: (abp.appPath || '') + 'api/notification/private-message/mark-all-as-read',
type: 'POST',
success: function() {
document.querySelectorAll('.notification-menu .lpx-notification-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
updateBadge(0);
},
error: function() {}
});
}
window.refreshNotificationBadge = fetchCount;
window.refreshNotificationList = fetchLatest;
document.addEventListener('click', function(e) {
if (!e.target || !e.target.closest) return;
if (e.target.closest('[data-lpx-ctx-toggle="notification-bar"]')) {
fetchLatest();
return;
}
if (e.target.closest('.notification-mark-all')) {
markAllRead();
return;
}
var itemEl = e.target.closest('.lpx-notification-item');
if (itemEl && itemEl.classList.contains('unread') && itemEl.getAttribute('data-id')) {
itemEl.classList.remove('unread');
markOneRead(itemEl.getAttribute('data-id'));
}
});
function tryInit() {
if (typeof abp === 'undefined' || !abp || !abp.ajax) return false;
fetchCount();
setInterval(fetchCount, 30000);
return true;
}
if (!tryInit()) {
var attempts = 0;
var readyTimer = setInterval(function() {
attempts++;
if (tryInit()) {
clearInterval(readyTimer);
} else if (attempts > 40) {
clearInterval(readyTimer);
}
}, 500);
}
})();
</script>
Keep data-lpx-ctx-toggle="notification-bar" / data-lpx-context-menu="notification-bar" as they are — notification-bar is the context menu name LeptonX binds automatically inside the toolbar, so the dropdown toggling in the toolbar keeps working. Note this covers the desktop toolbar; the copy inside the mobile menu is kept in sync data-wise, but the dropdown there is not wired up by the theme.
One more detail for the footer: the theme's lpx-button-container styles a single full-width button (that's also why your notif-btn-outline / notif-btn-primary classes have no effect — they don't exist in LeptonX), so your two buttons overlap. Add this to your styles to stack them properly:
.lpx-context-menu .lpx-button-container .lpx-button {
display: block;
text-align: center;
text-decoration: none;
box-sizing: border-box;
}
.lpx-context-menu .lpx-button-container .lpx-button + .lpx-button {
margin-top: 8px;
}
Thanks
Hi,
This is a limitation of how Suite processes the Create/Edit modal templates. Besides placeholder replacement, it runs two extra steps during generation that assume the default template structure, and a customized layout can lose content to them:
When the entity's "Preserve custom code" option is not enabled, Suite removes each @*//<suite-custom-code-block-N>*@ ... @*//</suite-custom-code-block-N>*@ pair together with everything between the markers. In the default template these blocks are empty, so you never notice. If your footer and closing tags are inside a marker pair, they are removed on every generate — that's exactly the truncation you're seeing.
When the entity has no navigation connections, Suite removes every line that starts with <abp-tab or </abp-tab (leading whitespace is ignored, so indentation doesn't matter). This is line-based, so a multi-line <abp-tab ...> opening tag leaves an orphan line with only the attributes (the corrupted markup near the top), and anything that follows </abp-tab> on the same line is removed together with it.
Suite doesn't validate the generated markup at the moment, so a missing closing form tag doesn't fail the generate step — the file is written as-is.
So for a custom off-canvas/panel layout:
suite-custom-code-block markers. Content inside the markers is only preserved when "Preserve custom code" is enabled for the entity.suite-custom-code-block-N index only once per file.<abp-tabs>, <abp-tab ...>, </abp-tab> and </abp-tabs> each on their own line, keep the opening tag on a single line, and don't put other markup on those lines.##...## line in the files under .suite/customized-suite-templates — Suite strips the first line of those files when loading, so if you delete the hash header, the first real template line is lost instead.After updating the two templates this way, save them and regenerate — the off-canvas layout keeps everything after the property fields, including the footer and closing tags.
If it still truncates after these changes, share your customized CreateModal/EditModal .cshtml.txt files and we can point out the exact line that triggers it.
Thanks
Hi,
Yes — it's already fixed and merged, and it'll ship in the next patch release. Thanks again for the detailed report.
Thanks
Hi,
Thanks, that confirms it. In SCMQuartzBackgroundJobManager the Quartz JobKey is built from BackgroundJobNameAttribute.GetName<TArgs>() (which falls back to the args type full name when it has no [BackgroundJobName]), plus _{TenantId} for IMultiTenant args and _{UniqueKey} for IUniqueBackgroundJob args. That works for your own jobs, but the module's IndexDocumentJobArgs implements neither interface, it's just a class with a DataSourceId.
So every reindex job ends up with the same key, Volo.AIManagement.BackgroundJobs.IndexDocumentJobArgs. ReIndex All enqueues one job per document, but while the first job and its trigger still exist in Quartz, your CheckExists branch hits "a job already exists and is not in error, let's not schedule a new job" and returns without scheduling. Nothing throws, so ReIndexAllAsync finishes normally and resets every document (which the UI then shows as Processing), but only the first one actually has a job.
The fix is to give jobs that don't opt into your identity scheme a unique key per enqueue, like stock ABP does. Where you add it matters, it has to be the fallback of the IUniqueBackgroundJob check, not the tenant else, otherwise a non-tenant IUniqueBackgroundJob would get a GUID and its UniqueKey and lose its stable identity:
if (args is IUniqueBackgroundJob uniqueBackgroundJob && !string.IsNullOrEmpty(uniqueBackgroundJob.UniqueKey))
{
jobName = $"{jobName}_{uniqueBackgroundJob.UniqueKey}";
}
else
{
jobName = $"{jobName}_{Guid.NewGuid():N}";
}
This makes anything that isn't an IUniqueBackgroundJob run on its own, like ABP's default manager. If some of your own jobs relied on the CheckExists dedup to avoid running twice, mark those with IUniqueBackgroundJob (with a stable UniqueKey) so they keep that behaviour. These jobs aren't durable, so the GUID keys don't accumulate, Quartz removes each one once it finishes and its trigger is gone.
A few practical notes:
Unrelated, but I noticed it in the config you sent: quartz.scheduler.instanceId is set to a fixed value. If you run more than one node against this clustered store, each node needs a distinct instanceId (usually AUTO), a shared one interferes with Quartz's cluster and failover handling.
Thanks
Hi,
The DeleteTrigger and Deleting job lines are just Quartz's normal cleanup after a one-shot job finishes, so they don't mean the other documents' jobs were deleted.
The real signal is the JobKey:
DEFAULT.Volo.AIManagement.BackgroundJobs.IndexDocumentJobArgs
That key is the job arguments type name. ABP's stock QuartzBackgroundJobManager never sets a job identity, so Quartz gives every enqueue a fresh GUID key and each document gets its own job. Your log uses the args type name as a fixed key instead, and with one shared key only a single IndexDocumentJob can live in the scheduler at a time, so ReIndex All (which enqueues one job per document) only runs the first.
That fixed key isn't ABP's default, so something in your app is setting it. To pin down exactly where and give you the precise fix, could you share either:
IBackgroundJobManager, plus your AddQuartz / background-job registration code, orOnce I can see where the key is set, the fix is to let each enqueue get its own job (a unique key per enqueue), with a per-document lock or dedup check if the fixed key was added to avoid indexing the same document twice.
Thanks
Hi,
Thanks for the details. Since everything is already on 10.4.1, my earlier upgrade suggestion was wrong — sorry about that. This is a bug in ABP Suite's Angular proxy generation: it names the navigation-property lookups differently from your backend controllers, so the generated URLs don't match and some lookups get dropped.
For now, generate the proxy from the running backend instead of Suite's built-in generation:
angular folder, run (replace the URL with your host's URL):abp generate-proxy -t ng -u https://localhost:44300 -m app
This reads your actual API, so employee.service.ts matches your controllers exactly and all the lookups are there. The method names follow the backend (getGradeLookup instead of getEmployeeGradeLookup), so update those call sites once. If you regenerate the entity in Suite later, run this command again afterward.
We'll fix this in Suite's Angular generation. Your ticket has been refunded.
Thanks
Hi,
The differences in employee.service.ts come from how ABP names the navigation-property "lookup" endpoints, and that convention changed in ABP 10.3:
Grade → GetEmployeeGradeLookup / employee-grade-lookup), and multiple relations pointing to the same entity are merged into one — that's why your self-reference GetEmployeeLookup was dropped.GradeId → GetGradeLookup / grade-lookup), and each relation, including each self-reference, keeps its own lookup.Your committed file has the FK-style routes (grade-lookup), but the regenerated one came out in the old entity-name style (employee-grade-lookup) with the self-reference gone. That's the pre-10.3 convention, so one of the tools you regenerate with is older than your project's ABP version (10.4).
To pin down which one, could you share a few details:
abp --version.abp generate-proxy (or an @abp/ng.schematics command) yourself?@abp/ng.core and @abp/ng.schematics versions from your Angular package.json.navigationProperties section of .suite/entities/Employee.json — that shows exactly what the generator will produce for these lookups.Once we see which piece is behind, the fix is to bring it up to your project's version (for example abp suite update for Suite, or aligning the @abp/ng.* packages) and regenerate. The lookup routes then come back as grade-lookup, the self-reference lookups aren't dropped, and the generated code stays consistent. One thing to expect: the method names line up with the routes too (getEmployeeGradeLookup → getGradeLookup), so you'll update those call sites in your Angular code once.
On the "no selective generation" point — the proxy is regenerated as a whole, not per method, so regenerating always rewrites the file. Keeping the proxy folder in source control and reviewing the diff after each generation is the practical way to manage that.
Thanks
Hi,
The conflict isn't on the refresh token itself. When several tabs renew at the same time - the silent /connect/authorize calls in your screenshot - each one creates a new token under the same authorization. ABP currently treats that foreign key as a navigation change and updates the shared authorization's ConcurrencyStamp on every one of those token inserts.
The first request updates the authorization and wins; the concurrent ones still hold the previous stamp, so their update affects 0 rows and EF Core throws the DbConcurrencyException you're seeing.
The framework already excludes OpenIddictApplication from this, but not OpenIddictAuthorization. You can add it in your *EntityFrameworkCoreModule (the .EntityFrameworkCore project):
using Volo.Abp.Domain.Entities.Events;
using Volo.Abp.OpenIddict.Authorizations;
// inside ConfigureServices
Configure<AbpEntityChangeOptions>(options =>
{
options.IgnoredNavigationEntitySelectors.Add(
"DisableOpenIddictAuthorization",
type => type == typeof(OpenIddictAuthorization));
});
After this, creating a token no longer touches the shared authorization, so concurrent renewals from multiple tabs stop colliding. Nothing needs to change on the Angular side.
We'll also add OpenIddictAuthorization to the framework defaults.
Thanks