Skip to main content
Technical ManualLooking for step-by-step instructions? In the User Manual: Searching and Filtering Lists

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 IDNameEnabledByDependencyOnlyDependencies
CrestApps.Almohaseb.Admin.UIAlmohaseb Admin UItrueNone

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):

ProjectContents
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​

  1. Each module registers one or more IAdminListFilterProvider<T> for its document type T. A provider adds terms to a YesSql QueryEngineBuilder<T> and describes them for the dialog.
  2. AdminListFilterParser<T> (a singleton per T) builds the query parser from every provider the first time it is used, and keeps the combined list of AdminListFilterDescriptor for the dialog.
  3. The list action binds the q query-string value to a parsed QueryFilterResult<T> with AdminListFilterModelBinder<T>.
  4. The action executes the filter against the module's IQuery<T> inside an AdminListQueryContext<T>, which carries the request's IServiceProvider so asynchronous terms can resolve services such as ILocalClock.
  5. The view renders the search box, the AlmohasebFilterHelp shape, any drop-downs mapped to terms, and the admin-list tag helper.
  6. 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.

ValueRange
2026, 2026-03, 2026-03-15The whole year, month or day.
2026-01..2026-03, 2026-03.., ..2026-03From 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-03After 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-yearRelative 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).

ValueRange
500, =500Exactly 500.
>500, >=500, <500, <=500An open range with an exclusive or inclusive bound.
100..200Inclusive 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, with placement.json placing 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 (Bill and Lines).
  • A list filtered by dates uses the CrestApps DateRangePicker view component (feature CrestApps.OrchardCore.Resources, a foundation dependency) with fromName: "from", toName: "to" and selectedRangeName: "range". It posts date and time bounds (2026-10-31T23:59); the foundation's BusinessDateModelBinder binds them, and plain dates, to DateOnly parameters, so actions keep DateOnly? 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:

AttributeOnMeaning
data-almohaseb-gridThe containerTurns the container into a grid.
data-grid-rowsThe tbodyHolds the rows.
data-grid-rowEach trOne 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-templateA templateThe markup of a new row, with [__index__] in its field names.
data-grid-addA buttonAdds a row.
data-grid-removeA button in a rowRemoves the row (the last row is cleared instead).
data-grid-line-numberAn element in a rowShows the line number.
data-grid-sum="Debit"An inputIs added up into the total named Debit. Amounts are summed in cents.
data-grid-total="Debit"An elementShows the total named Debit.
data-grid-balance="Debit,Credit"An elementShows 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 sort term.
  • Ignore values a term does not recognize, so a typo narrows nothing instead of failing the page.
  • Localize Title and Description; keep Name, Example and term values in English, since they are what users type.