Skip to content

Creating a Custom SearchDisplay Type

SearchKit Displays are AngularJS components responsible for rendering the results of a SavedSearch. While CiviCRM core includes standard displays such as Tables, Lists, and Grids, developers can build custom SearchDisplay types inside their own extensions to provide specialized styling (such as cards, maps, or interactive charts).


Architecture Overview

Creating a custom SearchDisplay type involves a hybrid of PHP and AngularJS configurations.

For performance and code hygiene, we recommend creating two separate AngularJS modules: 1. Display Module (Client/Public-facing): Loads the component that renders the search results. It does not specify any basePages declaration, as it is dynamically loaded by the SearchKit display wrapper on demand. 2. Admin Module (SearchKit Admin UI only): Loads the configuration form components in the admin interface. It requires the basePages option set to ['civicrm/admin/search'] so that administration logic is only loaded in the SearchKit console.

Non-viewable Display Types

If you are building a backend-only display that does not render visual markup on the client side (for example, a display that creates a SQL View or handles exports), you do not need the display module. You only need the OptionValue registration and the Admin module to manage its configuration.


Step 1: Register the Display Type (PHP Managed Entity)

First, register the custom display type in the search_display_type OptionGroup. The recommended way to do this is with a Managed Entity file inside your extension's managed/ directory.

Create managed/SearchDisplayType_card.mgd.php:

<?php
use CRM_Myextension_ExtensionUtil as E;

return [
  [
    'name' => 'SearchDisplayType:card',
    'entity' => 'OptionValue',
    'cleanup' => 'always',
    'update' => 'always',
    'params' => [
      'version' => 4,
      'values' => [
        'option_group_id.name' => 'search_display_type',
        'value' => 'card', // Unique identifier used in code
        'name' => 'crm-search-display-card', // Matches the Angular element directive name of your display component
        'label' => E::ts('Card Grid'),
        'description' => E::ts('Displays search results in a grid of stylized cards.'),
        'icon' => 'fa-id-card', // FontAwesome class
        'is_reserved' => FALSE,
        'is_active' => TRUE,
      ],
      'match' => ['option_group_id', 'name'],
    ],
  ],
];

Non-viewable display types

For a backend-only display type that doesn't render visual HTML components (like a DB Entity or Batch), append 'grouping' => 'non-viewable' to the OptionValue values array.


Step 2: Declare the AngularJS Modules (PHP)

Create the AngularJS module metadata files inside your extension's ang/ directory.

1. Display Module Metadata

Create ang/crmSearchDisplayCard.ang.php. This file does not specify basePages.

<?php
// Module metadata for rendering Card Search Displays.
return [
  'js' => [
    'ang/crmSearchDisplayCard.module.js',
    'ang/crmSearchDisplayCard/*.js',
  ],
  'partials' => [
    'ang/crmSearchDisplayCard',
  ],
  'css' => [
    'css/crmSearchDisplayCard.css', // Optional stylesheet
  ],
  'requires' => [
    'crmSearchDisplay',
    'crmUi',
    'crmSearchTasks',
  ],
  'exports' => [
    'crm-search-display-card' => 'E', // Directive element name matching the OptionValue's 'name'
  ],
];

2. Admin Module Metadata

Create ang/searchAdminDisplayCard.ang.php. This file requires basePages to load exclusively on the search admin dashboard.

<?php
// Module metadata for the Card Search Display configuration (Admin).
return [
  'js' => [
    'ang/searchAdminDisplayCard.module.js',
    'ang/searchAdminDisplayCard/*.js',
  ],
  'partials' => [
    'ang/searchAdminDisplayCard',
  ],
  'basePages' => [
    'civicrm/admin/search', // Only load when editing searches/displays
  ],
  'requires' => [
    'crmUi',
  ],
];

Step 3: Define the AngularJS Modules (JS)

Define both modules in your JavaScript source files.

Create ang/crmSearchDisplayCard.module.js:

(function(angular, $, _) {
  "use strict";

  angular.module('crmSearchDisplayCard', CRM.angRequires('crmSearchDisplayCard'));

})(angular, CRM.$, CRM._);

Create ang/searchAdminDisplayCard.module.js:

(function(angular, $, _) {
  "use strict";

  angular.module('searchAdminDisplayCard', CRM.angRequires('searchAdminDisplayCard'));

})(angular, CRM.$, CRM._);

Step 4: Create the Display Component (Client)

If your display is viewable, create the component file: ang/crmSearchDisplayCard/crmSearchDisplayCard.component.js.

This component uses traits (mixins) supplied by crmSearchDisplay to handle tasks, editable fields, and data-fetching.

By using lexical arrow functions (=>), the controller's functions correctly preserve this references without requiring a helper variable like ctrl = this.

(function(angular, $, _) {
  "use strict";

  angular.module('crmSearchDisplayCard').component('crmSearchDisplayCard', {
    bindings: {
      apiEntity: '@',
      search: '<',
      display: '<',
      apiParams: '<',
      settings: '<',
      filters: '<',
      totalCount: '=?'
    },
    require: {
      afFieldset: '?^^afFieldset'
    },
    templateUrl: '~/crmSearchDisplayCard/crmSearchDisplayCard.html',
    controller: function($scope, $element, searchDisplayBaseTrait, searchDisplayTasksTrait, searchDisplayEditableTrait) {
      $scope.ts = CRM.ts('org.civicrm.myextension');

      // Mix in SearchDisplay helper traits onto the controller instance
      angular.extend(this, _.cloneDeep(searchDisplayBaseTrait), _.cloneDeep(searchDisplayTasksTrait), _.cloneDeep(searchDisplayEditableTrait));

      this.$onInit = () => {
        this.initializeDisplay($scope, $element);
      };
    }
  });

})(angular, CRM.$, CRM._);

Display Template

Create ang/crmSearchDisplayCard/crmSearchDisplayCard.html:

<div class="crm-search-display crm-search-display-card {{:: $ctrl.settings.classes.join(' ') }}">
  <!-- Optional Description -->
  <div class="alert alert-info crm-search-display-description" ng-if="$ctrl.settings.description">
    {{:: $ctrl.settings.description }}
  </div>

  <!-- Action Toolbar / Header Buttons -->
  <div class="form-inline">
    <div class="btn-group" ng-include="'~/crmSearchDisplay/SearchButton.html'" ng-if="$ctrl.settings.button"></div>
    <span ng-if="$ctrl.settings.headerCount" ng-include="'~/crmSearchDisplay/ResultCount.html'"></span>
    <div class="form-group pull-right" ng-include="'~/crmSearchDisplay/toolbar.html'" ng-if="$ctrl.toolbar"></div>
  </div>

  <!-- Results Content -->
  <div class="row crm-search-display-card-grid" ng-if="!$ctrl.loading">
    <div class="col-sm-4" ng-repeat="row in $ctrl.results">
      <div class="thumbnail">
        <div class="caption">
          <div ng-repeat="col in $ctrl.settings.columns">
            <strong ng-if="col.label">{{ col.label }}: </strong>
            <span>{{ row[col.key] }}</span>
          </div>
        </div>
      </div>
    </div>
  </div>

  <!-- Loading State Indicator -->
  <div class="crm-loading-element" ng-if="$ctrl.loading"></div>

  <!-- Empty Results Fallback -->
  <div ng-if="$ctrl.rowCount === 0" class="crm-search-display-card-no-results">
    <p class="alert alert-info text-center">
      {{ $ctrl.settings.noResultsText || ts('None found.') }}
    </p>
  </div>

  <!-- Pagination Pager -->
  <div ng-include="'~/crmSearchDisplay/Pager.html'"></div>
</div>

Step 5: Create the Admin Configuration Component (Admin)

SearchKit compiles configuration UI components named <search-admin-display-[type]>, where [type] is the OptionValue value parameter (e.g. <search-admin-display-card>).

Create the component: ang/searchAdminDisplayCard/searchAdminDisplayCard.component.js on your admin module. Using arrow functions allows directly referencing the parent component context via this.parent:

(function(angular, $, _) {
  "use strict";

  angular.module('searchAdminDisplayCard').component('searchAdminDisplayCard', {
    bindings: {
      display: '<',
      apiEntity: '<',
      apiParams: '<'
    },
    require: {
      parent: '^crmSearchAdminDisplay'
    },
    templateUrl: '~/searchAdminDisplayCard/searchAdminDisplayCard.html',
    controller: function($scope, searchMeta, crmUiHelp) {
      $scope.ts = CRM.ts('org.civicrm.myextension');

      this.getColTypes = () => this.parent.colTypes;

      this.$onInit = () => {
        if (!this.display.settings) {
          this.display.settings = {
            limit: this.parent.getDefaultLimit(),
            sort: this.parent.getDefaultSort(),
            columns: [],
            pager: {}
          };
        }
        this.parent.initColumns({});
      };
    }
  });

})(angular, CRM.$, CRM._);

Admin Component Template

Create ang/searchAdminDisplayCard/searchAdminDisplayCard.html:

<!-- Load general display configuration header -->
<div ng-include="'~/crmSearchAdmin/crmSearchAdminDisplayHeader.html'"></div>

<details class="crm-accordion-bold" open>
  <summary>{{:: ts('Settings') }}</summary>
  <div class="crm-accordion-body">
    <!-- Configures Sorting -->
    <fieldset ng-include="'~/crmSearchAdmin/crmSearchAdminDisplaySort.html'"></fieldset>

    <fieldset>
      <!-- Configures No Results Fallback Text -->
      <div class="form-inline crm-search-admin-flex-row">
        <label for="crm-search-admin-display-no-results-text">{{:: ts('No Results Text') }}</label>
        <input class="form-control crm-flex-1" id="crm-search-admin-display-no-results-text" ng-model="$ctrl.display.settings.noResultsText" placeholder="{{:: ts('None found.') }}">
      </div>

      <!-- Pager configurations -->
      <search-admin-pager-config display="$ctrl.display"></search-admin-pager-config>

      <!-- Placeholder configurations -->
      <search-admin-placeholder-config display="$ctrl.display"></search-admin-placeholder-config>

      <!-- Action Toolbar configuration -->
      <search-admin-toolbar-config display="$ctrl.display" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams"></search-admin-toolbar-config>
    </fieldset>
  </div>
</details>

<!-- Column/Field Configurations -->
<fieldset class="crm-search-admin-edit-columns-wrapper">
  <legend>{{:: ts('Card Fields') }}</legend>

  <div ng-include="'~/crmSearchAdmin/displays/common/addColMenu.html'"></div>

  <fieldset class="crm-search-admin-edit-columns" ng-model="$ctrl.display.settings.columns" ui-sortable="$ctrl.parent.sortableOptions">
    <fieldset ng-repeat="col in $ctrl.display.settings.columns" class="crm-draggable">
      <i class="crm-i fa-arrows crm-search-move-icon" role="img" aria-hidden="true"></i>
      <button type="button" class="btn btn-xs pull-right" ng-click="$ctrl.parent.removeCol($index)" title="{{:: ts('Remove') }}">
        <i class="crm-i fa-ban" role="img" aria-hidden="true"></i>
      </button>

      <details>
        <summary>{{ $ctrl.parent.getColLabel(col) }}</summary>
        <div class="crm-accordion-body">
          <div class="form-inline crm-search-admin-flex-row">
            <label>
              <input type="checkbox" ng-checked="col.label" ng-click="col.label = col.label ? null : $ctrl.parent.getColLabel(col)" >
              {{:: ts('Label') }}
            </label>
            <input ng-if="col.label" class="form-control crm-flex-1" type="text" ng-model="col.label" ng-model-options="{updateOn: 'blur'}">
          </div>

          <!-- Field formatting options -->
          <div ng-include="'~/crmSearchAdmin/displays/colType/' + col.type + '.html'"></div>
        </div>
      </details>
    </fieldset>
  </fieldset>
</fieldset>