Admin UI
The Admin UI module holds the admin building blocks that the Al-Mohaseb modules share. Today that is the list filter pattern: the search box of an AdminList page parses typed terms (status:queued, queued:this-quarter, type:import) into YesSql index queries, other modules can add terms to a list, and an Available filters dialog documents the terms of each list.
| Feature ID | Name | EnabledByDependencyOnly | Dependencies |
|---|---|---|---|
CrestApps.Almohaseb.Admin.UI | Almohaseb Admin UI | true | None |
The feature is enabled by the features that depend on it, such as Jobs (CrestApps.Almohaseb.Jobs) and Accounting (CrestApps.Almohaseb.Ledger, see Ledger). It has no permissions, menus or configuration of its own.
The code is split as usual (see Architecture):
| Project | Contents |
|---|---|
CrestApps.Almohaseb.Admin.UI.Core (namespace CrestApps.Almohaseb.Admin.UI.Filters) | IAdminListFilterProvider<T>, IAdminListFilterParser<T> and its default AdminListFilterParser<T>, AdminListFilterModelBinder<T>, AdminListQueryContext<T>, AdminListFilterDescriptor, AdminListFilterCapability, the DateRangeTerm and AmountRangeTerm parsers, and the AddAdminListFilterProvider registration extension. It depends on YesSql.Filters.Query only. |
CrestApps.Almohaseb.Admin.UI (module) | The manifest and the AlmohasebFilterHelp shape (the Available filters button and dialog). |
How people use the search box is described in the User Manual: Searching and Filtering Lists.
How a filtered list works
- Each module registers one or more
IAdminListFilterProvider<T>for its document typeT. A provider adds terms to a YesSqlQueryEngineBuilder<T>and describes them for the dialog. AdminListFilterParser<T>(a singleton perT) builds the query parser from every provider the first time it is used, and keeps the combined list ofAdminListFilterDescriptorfor the dialog.- The list action binds the
qquery-string value to a parsedQueryFilterResult<T>withAdminListFilterModelBinder<T>. - The action executes the filter against the module's
IQuery<T>inside anAdminListQueryContext<T>, which carries the request'sIServiceProviderso asynchronous terms can resolve services such asILocalClock. - The view renders the search box, the
AlmohasebFilterHelpshape, any drop-downs mapped to terms, and theadmin-listtag helper. - A POST of the search form redirects to the list with the normalized search text in
q, so a filtered list has a bookmarkable address.
The reference consumer is Tools > Jobs (JobsAdminListFilterProvider and the Jobs AdminController). Books > Chart of accounts (AccountsAdminListFilterProvider) follows the same pattern.
Add filters to a list
1. Write a provider
public interface IAdminListFilterProvider<T>
where T : class
{
void Build(QueryEngineBuilder<T> builder);
IEnumerable<AdminListFilterDescriptor> Describe();
}
Providers are singletons, and Build runs once, when the list's parser is first used. Terms must filter through YesSql indexes, never through loaded documents.
From JobsAdminListFilterProvider: a default term (text typed without a term name), a named term mapped to a drop-down, an asynchronous date term and a sort term.
builder
.WithDefaultTerm(TypeTerm, term => term
.ManyCondition(
(value, query) => query.With<JobIndex>(index => index.JobType.Contains(value)),
(value, query) => query.With<JobIndex>(index => index.JobType.NotContains(value))))
.WithNamedTerm(StatusTerm, term => term
.OneCondition((value, query) => FilterByStatus(value, query))
.MapTo<JobIndexOptions>((value, model) => model.Status = TryParseStatus(value, out var status) ? status : null)
.MapFrom<JobIndexOptions>(model => model.Status is { } status
? (true, ToTermValue(status))
: (false, string.Empty)))
.WithNamedTerm(QueuedTerm, term => term
.OneCondition(FilterByQueuedDateAsync))
.WithNamedTerm(SortTerm, term => term
.OneCondition((value, query) => value?.ToLowerInvariant() switch
{
"oldest" => query.With<JobIndex>().OrderBy(index => index.CreatedUtc),
"priority" => query.With<JobIndex>().OrderByDescending(index => index.Priority).ThenByDescending(index => index.CreatedUtc),
_ => query.With<JobIndex>().OrderByDescending(index => index.CreatedUtc),
}));
A term whose value it does not recognize should return the query unchanged, as FilterByStatus does, rather than throw.
MapTo copies a parsed term onto the list's options model (so the drop-down shows the typed value); MapFrom writes the drop-down back into the search text, returning (false, string.Empty) to remove the term.
2. Describe the terms
Describe returns one AdminListFilterDescriptor(Name, Title, Description, Example, Capability) per term, with localized Title and Description:
public IEnumerable<AdminListFilterDescriptor> Describe() =>
[
new(TypeTerm, S["Job type"], S["Shows jobs whose type contains the text. Text without a term name searches the type."], "type:import", AdminListFilterCapability.Default),
new(StatusTerm, S["Status"], S["Shows jobs in one status: queued, running, paused, completed, completed-with-warnings, failed, needs-attention or canceled. Use 'active' for every job that has not finished."], "status:needs-attention"),
new(QueuedTerm, S["Queued"], S["Shows jobs queued in a period, in the time zone of the site: a day, a month or a year, a range such as 2026-01..2026-03, or today, yesterday, this-month, last-month, this-year."], "queued:this-month"),
new(SortTerm, S["Sort"], S["Orders the jobs: newest (the default), oldest or priority."], "sort:oldest"),
];
AdminListFilterCapability selects the icon on the card: Default (the term for text without a term name), Single (one value, the default) or Multiple (supports AND, OR, NOT and groups).
3. Register the provider
services.AddAdminListFilterProvider<Job, JobsAdminListFilterProvider>();
AddAdminListFilterProvider<T, TProvider> registers the provider as a singleton IAdminListFilterProvider<T> (with TryAddEnumerable) and the shared IAdminListFilterParser<T> if it is not registered yet. The module's feature must depend on CrestApps.Almohaseb.Admin.UI.
4. Bind and execute the filter in the controller
From the Jobs AdminController.Index:
[Admin("Almohaseb/Jobs", "AlmohasebJobsIndex")]
public async Task<IActionResult> Index(
[ModelBinder(BinderType = typeof(AdminListFilterModelBinder<Job>), Name = "q")] QueryFilterResult<Job> filterResult,
PagerParameters pagerParameters)
{
// Authorization omitted.
var options = new JobIndexOptions
{
FilterResult = filterResult,
};
// Lets the status drop-down show the status typed in the search box.
options.FilterResult.MapTo(options);
IQuery<Job> query = _jobStore.Query();
query = await options.FilterResult.ExecuteAsync(new AdminListQueryContext<Job>(HttpContext.RequestServices, query));
if (!options.FilterResult.Any(term => string.Equals(term.TermName, JobsAdminListFilterProvider.SortTerm, StringComparison.OrdinalIgnoreCase)))
{
query = query.With<JobIndex>().OrderByDescending(index => index.CreatedUtc);
}
options.SearchText = options.FilterResult.ToString();
options.OriginalSearchText = options.SearchText;
options.RouteValues.TryAdd("q", options.SearchText);
options.FilterDescriptors = _filterParser.Descriptors;
// Paging and the view model follow.
}
The options model binds the posted search text to a second QueryFilterResult<T>:
[ModelBinder(BinderType = typeof(AdminListFilterModelBinder<Job>), Name = nameof(SearchText))]
public QueryFilterResult<Job> FilterResult { get; set; }
The filter POST gives typed text priority over the drop-downs. When the search text changed, it is used as typed; otherwise the drop-downs are written back into it with MapFrom:
[HttpPost]
[ActionName(nameof(Index))]
[FormValueRequired("submit.Filter")]
public ActionResult IndexFilterPost(JobIndexViewModel model)
{
var options = model?.Options ?? new JobIndexOptions();
if (!string.Equals(options.SearchText, options.OriginalSearchText, StringComparison.OrdinalIgnoreCase) || options.FilterResult is null)
{
return RedirectToAction(nameof(Index), new RouteValueDictionary { { "q", options.SearchText } });
}
options.FilterResult.MapFrom(options);
return RedirectToAction(nameof(Index), new RouteValueDictionary { { "q", options.FilterResult.ToString() } });
}
Every submit button named submit.Filter must carry a value: a button without one posts an empty string, which FormValueRequired treats as missing.
5. Render the search box and the dialog
In the list view, next to the search box:
@await DisplayAsync(await New.AlmohasebFilterHelp(ListName: "AlmohasebJobs", Descriptors: Model.Options.FilterDescriptors))
The shape renders a question-mark button and a Bootstrap modal (almohaseb-filters-{ListName}) titled Available filters, with one card per descriptor (title, capability icon, example and description) and a legend for the icons. A drop-down mapped to a term submits the form on change by clicking the hidden submit.Filter button, so the POST above writes it into the search text.
Add terms to another module's list
Because the parser collects every registered IAdminListFilterProvider<T>, a module can extend a list it does not own. Register its own provider for the same T, in a startup that only runs when the list's feature is enabled:
[RequireFeatures("CrestApps.Almohaseb.Jobs")]
public sealed class JobsStartup : StartupBase
{
public override void ConfigureServices(IServiceCollection services)
=> services.AddAdminListFilterProvider<Job, MyJobFilterProvider>();
}
Term names must be unique across the providers of a list, and the new terms appear in its Available filters dialog automatically. The provider can only filter on indexes the list's documents have.
Term parsers
Dates
DateRangeTerm.TryParse(string value, DateOnly today, out DateRange range) turns a term value into a DateRange(DateOnly? From, DateOnly? To). Both ends are inclusive and a null end is open.
| Value | Range |
|---|---|
2026, 2026-03, 2026-03-15 | The whole year, month or day. |
2026-01..2026-03, 2026-03.., ..2026-03 | From the start of the first period to the end of the second; either end may be open. A range whose start is after its end is rejected. |
>2026-03, >=2026-03, <2026-03, <=2026-03 | After the end, from the start, before the start, or up to the end of the period. |
today, yesterday, this-month, last-month, this-quarter, last-quarter, this-year, last-year | Relative to today, with calendar quarters. |
The caller supplies today as a business date. Jobs resolves it, and converts the range to UTC bounds, with ILocalClock (the site time zone):
private static async ValueTask<IQuery<Job>> FilterByQueuedDateAsync(string value, IQuery<Job> query, QueryExecutionContext<Job> context)
{
if (context is not AdminListQueryContext<Job> listContext)
{
return query;
}
var localClock = listContext.ServiceProvider.GetRequiredService<ILocalClock>();
var today = DateOnly.FromDateTime((await localClock.GetLocalNowAsync()).DateTime);
if (!DateRangeTerm.TryParse(value, today, out var range))
{
return query;
}
if (range.From is { } from)
{
var fromUtc = await localClock.ConvertToUtcAsync(from.ToDateTime(TimeOnly.MinValue));
query = query.With<JobIndex>(index => index.CreatedUtc >= fromUtc);
}
if (range.To is { } to)
{
var toUtc = await localClock.ConvertToUtcAsync(to.AddDays(1).ToDateTime(TimeOnly.MinValue));
query = query.With<JobIndex>(index => index.CreatedUtc < toUtc);
}
return query;
}
A list that filters a stored business date (a DateOnly posting date, for example) can compare range.From and range.To directly instead of converting to UTC.
Amounts
AmountRangeTerm.TryParse(string value, out AmountRange range) turns a term value into an AmountRange(decimal? Min, bool MinInclusive, decimal? Max, bool MaxInclusive).
| Value | Range |
|---|---|
500, =500 | Exactly 500. |
>500, >=500, <500, <=500 | An open range with an exclusive or inclusive bound. |
100..200 | Inclusive on both ends. A range whose minimum is above its maximum is rejected. |
Amounts are parsed with the invariant culture (a dot for decimals); a leading $ and thousands separators are ignored. AmountRange.Contains(decimal) tests a value in memory; a term translates Min, Max and the inclusive flags into index predicates on a decimal column.
Editor layout
Every editor and settings page lists its fields one under the other with Orchard Core's ocat-* classes, like Orchard Core's own editors:
<div class="ocat-wrapper" asp-validation-class-for="Name">
<label asp-for="Name" class="ocat-label ocat-label-required">@T["Name"]</label>
<div class="ocat-end">
<input asp-for="Name" class="form-control" required />
<span asp-validation-for="Name"></span>
<span class="hint">@T["What the field is for."]</span>
</div>
</div>
- A check box goes in
<div class="ocat-wrapper"><div class="ocat-end ocat-offset">, so it lines up with the fields. - A description or a memo is a
textarea(three rows); a short line memo on a grid stays an input. - Fields are grouped in cards. A content editor places its shapes in Orchard Core cards (
Parts:N%Card name;order): one shape per group, withplacement.jsonplacing the type's fields (AddressField_Edit,PhoneField_Edit, differentiator{type}-{field}) and part wrappers (ContentPart_Edit, differentiator{type}-{part}). Every shape of the type must be in a card, or Orchard Core adds an empty card named Content. See the Employee editor in Payroll, the trip in Mileage and the vendor and customer in Contacts. - An MVC editor, a settings page or a single-shape part editor groups its fields in Bootstrap cards with the same look:
<div class="card mb-3"><div class="card-header"><h5 class="mb-0">Title</h5></div><div class="card-body">…</div></div>. An editor with lines has a card for its fields and one for its lines (BillandLines). - A list filtered by dates uses the CrestApps
DateRangePickerview component (featureCrestApps.OrchardCore.Resources, a foundation dependency) withfromName: "from",toName: "to"andselectedRangeName: "range". It posts date and time bounds (2026-10-31T23:59); the foundation'sBusinessDateModelBinderbinds them, and plain dates, toDateOnlyparameters, so actions keepDateOnly? from, DateOnly? to. - Tabs (
nav nav-tabs gap-1) sit on a bordered panel:<div class="border border-top-0 rounded-bottom p-3 mb-3">. - Bootstrap's grid (
row,col-*) is for grids of lines and for the filter bars of lists and reports, where the fields are meant to sit on one line.
Editable grid
The editable grid is a keyboard-first line editor for forms with repeating lines: journal lines today, bill lines, pay runs and the daily-close sheet later. It is a dependency-free ES module served from the module's wwwroot at /CrestApps.Almohaseb.Admin.UI/scripts/editable-grid.js; it needs no build step and no jQuery.
Include it in a view, which must import the OrchardCore.ResourceManagement tag helpers (and its project must reference that package, or the tag is not processed):
<script type="module" asp-src="~/CrestApps.Almohaseb.Admin.UI/scripts/editable-grid.js" at="Foot"></script>
Mark up the grid with data attributes:
| Attribute | On | Meaning |
|---|---|---|
data-almohaseb-grid | The container | Turns the container into a grid. |
data-grid-rows | The tbody | Holds the rows. |
data-grid-row | Each tr | One line. Field names contain [n], for example Lines[0].Debit; the grid renumbers them in row order so MVC binds a list. |
data-grid-row-template | A template | The markup of a new row, with [__index__] in its field names. |
data-grid-add | A button | Adds a row. |
data-grid-remove | A button in a row | Removes the row (the last row is cleared instead). |
data-grid-line-number | An element in a row | Shows the line number. |
data-grid-sum="Debit" | An input | Is added up into the total named Debit. Amounts are summed in cents. |
data-grid-total="Debit" | An element | Shows the total named Debit. |
data-grid-balance="Debit,Credit" | An element | Shows data-balanced-text when the two totals are equal and not zero, or data-unbalanced-text with {0} replaced by the difference. Optional: data-left-larger-text and data-right-larger-text replace the unbalanced text depending on which total is larger (the opening balances use them to say whether Opening Balance Equity is debited or credited), data-empty-text is shown while both totals are zero, and data-unbalanced-class replaces text-danger when a difference is not an error. |
Keys: Enter in the last row adds a row (Enter never submits the form from inside the grid); Ctrl+Delete removes the current row; Arrow up/down move to the same column of the previous or next row. A localized text that contains {0} for the script must be passed through the localizer with "{0}" as its argument (T["Out of balance by {0}", "{0}"]), because a localized string with a placeholder and no argument throws when it is formatted.
initializeEditableGrids(scope) is exported for content added after the page loaded.
Record panels
IRecordPanelProvider (CrestApps.Almohaseb.Admin.UI.Panels) lets a feature add a panel under the editors of another module's records without either referencing the other. BuildAsync(RecordPanelContext) gets the record type (such as Purchases.Bill), its id, the user and the editor's URL, and returns a shape or null. An editor's controller calls RecordPanels.BuildAsync(providers, context) with the IEnumerable<IRecordPanelProvider> it is given (none for a record not saved yet) and its view displays them after the form, so a panel can have forms of its own. The editors of bills, service tickets and other income do; Documents provides the attachments panel.
Rules for list filters
- Filter only through YesSql index predicates that translate to SQL; never load documents to filter them.
- Keep the default sort in the controller, and apply it only when the search has no
sortterm. - Ignore values a term does not recognize, so a typo narrows nothing instead of failing the page.
- Localize
TitleandDescription; keepName,Exampleand term values in English, since they are what users type.