Filters in collection view

I am working on a project, where we have a collection, where we want a few filter based on data in child nodes.

In Umbraco 13 we did remore the default list view content app and added our own.

I guess we could implement something similar in Umbraco 17 using a custom API endpoint querying the Examine InternalIndex.

We can create a custom collection view, but can we include filters in this?

It seems items is the data in paged results for current page.

I saw @madsrasmussen worked on some of the core filtering like free text search and filters for users and members, but not sure how much is in place yet.

Alternatively I guess we will implement a custom workspace view to show a similar table, but with additional filters.

Hi @bjarnef

I can’t see anything in the docs about customising the collection view to add your own filters. It looks like you have to create your own workspace view if you want to achieve that.

I asked AI to come up with a basic example:

Here’s a basic but complete workspace view scoped to your collection’s doc type, with filter controls, a custom management API endpoint querying Examine, and a table.

I’ll show the four pieces: the manifest, the Lit view element, the C# controller, and a note on wiring. Let me keep the client code TypeScript-flavoured since that’s the backoffice norm.

1. The manifest

Register a workspaceView against the document workspace, then narrow it to your specific collection doc type with WorkspaceContentTypeAlias. Without that second condition it shows on every document.

typescript

// umbraco-package.json (or registered via your entry point)
import { UMB_WORKSPACE_CONDITION_ALIAS } from "@umbraco-cms/backoffice/workspace";

const manifest: UmbExtensionManifest = {
  type: "workspaceView",
  alias: "My.WorkspaceView.FilteredCollection",
  name: "Filtered Collection Workspace View",
  element: () => import("./filtered-collection-view.element.js"),
  weight: 250, // higher weight = further left in the tab strip
  meta: {
    label: "Items",
    pathname: "items",
    icon: "icon-list",
  },
  conditions: [
    { alias: UMB_WORKSPACE_CONDITION_ALIAS, match: "Umb.Workspace.Document" },
    { alias: "Umb.Condition.WorkspaceContentTypeAlias", match: "myCollectionDocType" },
  ],
};

export const manifests = [manifest];

The WorkspaceContentTypeAlias condition is confirmed in the 17 docs — it requires the current workspace to be based on a Content Type whose Alias matches the one specified. Combined with the Umb.Workspace.Document workspace condition, that pins the view to exactly your collection parent. Umbraco

2. The Lit view element

This is the core of it. It grabs the current node’s key from the document workspace context (so it can pass the parent id to your endpoint), renders your filter controls, calls your custom endpoint, and renders a table. I’m using the UUI components since they match the backoffice look and give you sorting/inputs for free.

import { css, customElement, html, state } from "@umbraco-cms/backoffice/external/lit";
import { UmbLitElement } from "@umbraco-cms/backoffice/lit-element";
import { UMB_DOCUMENT_WORKSPACE_CONTEXT } from "@umbraco-cms/backoffice/document";
import { tryExecute } from "@umbraco-cms/backoffice/resources";

interface FilteredItem {
  key: string;
  name: string;
  status: string;
  updateDate: string;
}

@customElement("filtered-collection-view")
export class FilteredCollectionViewElement extends UmbLitElement {
  @state() private _parentKey?: string;
  @state() private _items: FilteredItem[] = [];
  @state() private _loading = false;

  // filter state
  @state() private _status = "";
  @state() private _query = "";

  constructor() {
    super();
    this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, (ctx) => {
      // observe the unique (the node's GUID key) so we know which parent to query
      this.observe(ctx?.unique, (unique) => {
        this._parentKey = unique ?? undefined;
        if (this._parentKey) this.#load();
      });
    });
  }

  async #load() {
    if (!this._parentKey) return;
    this._loading = true;

    const params = new URLSearchParams({
      parentKey: this._parentKey,
      status: this._status,
      query: this._query,
    });

    // tryExecute wraps fetch with the backoffice auth + error handling
    const { data } = await tryExecute(
      this,
      fetch(`/umbraco/management/api/v1/filtered-collection?${params}`, {
        headers: { "Content-Type": "application/json" },
      }).then((r) => r.json())
    );

    this._items = data?.items ?? [];
    this._loading = false;
  }

  #onStatusChange(e: Event) {
    this._status = (e.target as HTMLSelectElement).value;
    this.#load();
  }

  #onQueryInput(e: InputEvent) {
    this._query = (e.target as HTMLInputElement).value;
    // debounce in real code; immediate here for brevity
    this.#load();
  }

  override render() {
    return html`
      <umb-body-layout>
        <uui-box>
          <div class="filters">
            <uui-input
              label="Search"
              placeholder="Search…"
              .value=${this._query}
              @input=${this.#onQueryInput}>
            </uui-input>

            <uui-select
              label="Status"
              .value=${this._status}
              @change=${this.#onStatusChange}>
              <option value="">All statuses</option>
              <option value="active">Active</option>
              <option value="archived">Archived</option>
            </uui-select>
          </div>

          ${this._loading
            ? html`<uui-loader></uui-loader>`
            : this.#renderTable()}
        </uui-box>
      </umb-body-layout>
    `;
  }

  #renderTable() {
    if (!this._items.length) return html`<p>No items match.</p>`;
    return html`
      <uui-table>
        <uui-table-head>
          <uui-table-head-cell>Name</uui-table-head-cell>
          <uui-table-head-cell>Status</uui-table-head-cell>
          <uui-table-head-cell>Updated</uui-table-head-cell>
        </uui-table-head>
        ${this._items.map(
          (item) => html`
            <uui-table-row>
              <uui-table-cell>
                <a href=${`/umbraco/section/content/workspace/document/edit/${item.key}`}>
                  ${item.name}
                </a>
              </uui-table-cell>
              <uui-table-cell>${item.status}</uui-table-cell>
              <uui-table-cell>${item.updateDate}</uui-table-cell>
            </uui-table-row>
          `
        )}
      </uui-table>
    `;
  }

  static override styles = css`
    .filters {
      display: flex;
      gap: var(--uui-size-space-4);
      margin-bottom: var(--uui-size-space-5);
    }
  `;
}

export default FilteredCollectionViewElement;

A couple of notes on the context bits, because these are the parts that change between versions and are easy to get wrong. The 24 Days In Umbraco 2025 article confirms that if you only need to consume the document workspace context, the token is UMB_DOCUMENT_WORKSPACE_CONTEXT (there’s also UMB_CONTENT_WORKSPACE_CONTEXT if you need the broader content abstraction). I’m reading unique off it for the node key — confirm the exact observable name against your installed version’s typings, as the content/document context split has shifted recently. 24days

3. The management API controller

This is where your Examine query lives. Inherit from the management API base so it sits under /umbraco/management/api, picks up backoffice auth, and shows in the Swagger UI.

using Asp.Versioning;
using Examine;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Controllers;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Core.Security.Authorization;

[ApiController]
[VersionedApiBackOfficeRoute("filtered-collection")]
[ApiExplorerSettings(GroupName = "Filtered Collection")]
[Authorize(Policy = AuthorizationPolicies.SectionAccessContent)]
public class FilteredCollectionController : ManagementApiControllerBase
{
    private readonly IExamineManager _examineManager;

    public FilteredCollectionController(IExamineManager examineManager)
        => _examineManager = examineManager;

    [HttpGet]
    [MapToApiVersion("1.0")]
    [ProducesResponseType(typeof(FilteredCollectionResponse), StatusCodes.Status200OK)]
    public IActionResult Get(
        [FromQuery] Guid parentKey,
        [FromQuery] string? status = null,
        [FromQuery] string? query = null,
        [FromQuery] int skip = 0,
        [FromQuery] int take = 100)
    {
        if (!_examineManager.TryGetIndex("InternalIndex", out var index))
            return NotFound("InternalIndex not available");

        var searcher = index.Searcher;
        var criteria = searcher.CreateQuery("content");

        // children of this parent — parentID is indexed in InternalIndex
        var q = criteria.Field("parentID", parentKey.ToString());

        if (!string.IsNullOrWhiteSpace(status))
            q = q.And().Field("status", status); // your child property alias

        if (!string.IsNullOrWhiteSpace(query))
            q = q.And().GroupedOr(new[] { "nodeName", "status" }, query.Trim());

        var results = q.Execute(new QueryOptions(skip, take));

        var items = results.Select(r => new FilteredItem(
            Key: Guid.TryParse(r.Id, out var g) ? g : Guid.Empty,
            Name: r.Values.GetValueOrDefault("nodeName") ?? "",
            Status: r.Values.GetValueOrDefault("status") ?? "",
            UpdateDate: r.Values.GetValueOrDefault("updateDate") ?? ""
        )).ToArray();

        return Ok(new FilteredCollectionResponse(items, (int)results.TotalItemCount));
    }
}

public record FilteredItem(Guid Key, string Name, string Status, string UpdateDate);
public record FilteredCollectionResponse(FilteredItem[] Items, int Total);

I’ve not tried this but it should get you most of the way there.

Justin

@bjarnef

The collectionView extension only controls rendering items is whatever the data source already fetched. Filters live in the context/data source layer, not the view, so you can’t influence what gets fetched from inside a custom view.

There’s no documented way to add custom filter fields to the collection toolbar right now, and there’s an unanswered thread from last year asking the same thing: Changing the backoffice collection toolbar (adding additional filters) - #2 by WittySailboat

The workspace view approach Justin outlined above is the right direction +1

I was thinking something similar to add a custom workspace view and management API to populate filters and handle filtering of the results.

I had an eye on the WIP for filter extensions. Perhaps it will be available in a future minor version of v17: Collection: select filter extension by engijlr · Pull Request #22052 · umbraco/Umbraco-CMS · GitHub

1 Like