excelFilter does not show blank/empty values in dropdown

@Natalia_Shilova , @Listopad , @webix_B_123

I am using Webix 11 Pro(@xbs/webix-pro) with a DataTable column filter excelFilter.
In my data, some rows have empty strings for the “Publisher” field.
When I click on the excelFilter dropdown, the blank values are not included in the selectable options.
I would like to allow users to filter rows where Publisher is blank.

  • What I expected: dropdown should also allow filtering for empty/blank rows.
  • What actually happens: blank values are missing, cannot filter them.

Hello @Abhishek_Reddy ,

To filter rows with blank values you can create a custom Datatable and attach the onCollectValues handler right in the prototype, iterate column data and define logic for blank values.
Also if you want to check datatable data for null values using excelFilter you can use scheme property.

Please check the example: Code Snippet

@Natalia_Shilova , Thanks a lot. it worked

Hi @Natalia_Shilova , @Listopad

I have a few doubts/clarifications regarding protoUI and filter customization in Webix:

  1. Naming a custom component
  • If I define name: "datatable" inside webix.protoUI while extending this: webix.ui.datatable, is this considered valid?
  • Or should I always give a new name (e.g., "customdatatable") instead of re-using "datatable"?
  1. Effect of keeping the name "datatable"
  • If I still keep name: "datatable", what differences or side-effects should I expect?
  • Will it override the global datatable defaults, or only affect the extended version?
  1. Overriding onCollectValues
  • If I override onCollectValues in protoUI for datatable, will the same logic automatically apply to treetable as well (since it extends datatable)?
  • Or do I need to override it separately for treetable?
  1. Issue with blank values + customExcelFilter
  • In my overridden onCollectValues, I am pushing values like:
values.push({ id: val, value: val });

For blank cells, this becomes { id: "", value: "" }.

  • Later, when I use a customExcelFilter (extending excelFilter), I split values with a separator (,).
  • After parsing, the list contains { id: 1759130643308, value: "" } (the id is auto-generated).
  • When comparing row data vs checked item, the row data is "", but the selected item’s id is 1759130643308.
  • This causes the comparison to fail.

:point_right: Could you please clarify:

  • Is this expected behavior when dealing with blank values?
  • What’s the recommended approach to handle empty cells consistently so that filters (like ExcelFilter / customExcelFilter) work correctly?

Please find below code snippet

/**
 * datatableCollectValuesOverride.ts
 *
 * This file globally overrides the onCollectValues event for all Webix datatables.
 * It ensures that blank values are normalized, deduplicated, and always appear as (Blank) at the top of filter dropdowns.
 * The logic is applied to every datatable instance via protoUI extension.
 *
 * Usage: Import this file before any datatable is created to ensure the override is active.
 */
import * as webix from '@xbs/webix-pro';

// Represents a value in the filter dropdown
interface FilterValue {
  id: string;
  value: string;
}

webix.protoUI(
  {
    name: 'datatable',
    $init: function (this: webix.ui.datatable) {
      this.attachEvent(
        //  Extend the datatable UI to globally override the onCollectValues event
        'onCollectValues',
        function (
          this: webix.ui.datatable,
          colId: string,
          obj: { values: FilterValue[] }
        ) {
          const seen: Record<string, boolean> = {};
          const values: FilterValue[] = [];

          this.data.each((item: Record<string, unknown>) => {
            let val = item[colId] as string | null | undefined;
            if (val == null || val === '') {
              val = '';
            }
            if (!seen[val]) {
              seen[val] = true;
              values.push({ id: val, value: val });
            }
          });

          values.sort((firstValue, secondValue) => {
            if (firstValue.id === '' && secondValue.id !== '') return -1;
            if (firstValue.id !== '' && secondValue.id === '') return 1;
            return firstValue.value.localeCompare(
              secondValue.value,
              undefined,
              { sensitivity: 'base' }
            );
          });

          obj.values = values;
        }
      );
    },
  },
  webix.ui.datatable
);

/*
  Custom Webix DataFilter: customExcelFilter
  --------------------------------------------------
  - Extends the built-in Webix excelFilter to provide custom filtering logic and UI.
  - Supports multi-select filtering, custom label rendering, and advanced filter logic.
  - Handles columns with custom templates and collections.
  - Designed to be used as 'content: customExcelFilter' in Webix datatable column headers.

  Key Features:
  - Inherits all excelFilter functionality via webix.copy.
  - Adds support for custom label rendering in filter popup (filterLabelTemplate).
  - Ensures unique filter list values for multi-value columns.
  - Provides custom compare logic for multi-select and advanced filter types.
  - Handles getValue/setValue for selected filter items.
  - Can be extended to support more Excel-like filter conditions.
*/

import * as webix from '@xbs/webix-pro';

webix.ready(() => {
  webix.ui.datafilter.customExcelFilter = webix.extend(
    {
      _list: null as webix.ui.list | null,

      // Main render function for the custom filter
      render: function (master: webix.obj, config: webix.obj) {
        // eslint-disable-next-line @typescript-eslint/no-this-alias
        const _this2 = this;

        if (!config.filter) {
          const separator = config.separator ?? ',';
          // Prepare the template for the filter popup
          config.template =
            typeof config.template == 'string'
              ? webix.template(config.template)
              : config.template;

          // Build the filter config for the popup
          const filterConfig = webix.extend(
            config.filterConfig || {},
            {
              view: 'filter',
              mode: config.mode,
              field: 'value',
              filterLabelTemplate: config.filterLabelMode,
            },
            true
          );

          // If the column has a template, assign it to the filter list for custom rendering
          const columnConfig = master.getColumnConfig?.(config.columnId);

          if (columnConfig && typeof columnConfig.template === 'function') {
            filterConfig.list = filterConfig.list || {};
            if (filterConfig.filterLabelTemplate) {
              // Use the column's template for rendering filter labels
              filterConfig.template = function (
                obj: webix.obj,
                type: webix.obj
              ) {
                return columnConfig.template(
                  obj,
                  type,
                  void 0,
                  void 0,
                  void 0,
                  filterConfig.filterLabelTemplate
                );
              };
            }
          }

          // Create the filter popup
          const suggest = webix.ui({
            view: 'popup',
            body: filterConfig,
          }) as webix.ui.popup;

          // Get the filter and list components from the popup
          const filter = suggest.getBody() as webix.ui.filter;
          const list = filter.queryView(
            { view: 'list' },
            'all'
          )[0] as webix.ui.list;

          // Ensure the filter list only contains unique values
          if (list) {
            // Custom display for filter options
            list.define('template', function (obj: webix.obj) {
              if (obj.value === '') {
                return "<span style='color:gray;font-style:italic;'>(Blank)</span>";
              }
              return obj.value;
            });
            let isInternalUpdate = false;
            list.attachEvent('onAfterLoad', function (this: webix.ui.list) {
              // Only process if not already done and data exists
              if (isInternalUpdate || !this.data || !this.count()) return;

              const unique: Record<string, boolean> = {};
              this.data.each(function (obj: webix.obj) {
                let values: string[] = [];
                if (typeof obj.value === 'string') {
                  values = obj.value
                    .split(separator)
                    .map((v: string) => v.trim());
                }
                values.forEach((val) => {
                  if (!unique[val]) {
                    unique[val] = true;
                  }
                });
              });

              const newListData = Object.keys(unique).map((val) => ({
                id: val,
                value: val,
              }));
              isInternalUpdate = true;
              this.clearAll();
              this.parse(newListData);
              isInternalUpdate = false;
            });
          }
          // Save reference to the list for later use (getValue/setValue)
          this._list = filter.queryView(
            { view: 'list' },
            'all'
          )[0] as webix.ui.list;

          config.originText = config.text || '';
          config.filter = suggest.config.id;
          config.css = (config.css || '') + ' webix_ss_excel_filter';
          master._destroy_with_me.push(suggest);

          // Attach filter logic to the filter popup's onChange event
          filter.attachEvent('onChange', () => {
            const separator = config.separator ?? ',';

            // Get the list reference and checked items
            const list = filter.queryView(
              { view: 'list' },
              'all'
            )[0] as webix.ui.list;
            const checkedItems = list.data
              .serialize()
              .filter((item: webix.obj) => item.$checked);
            const checkedValues = checkedItems.map((item: webix.obj) =>
              item.value.toString()
            );

            // Custom compare logic for filtering rows
            config.compare = function (
              _val: webix.obj,
              _f: webix.obj,
              obj: webix.obj
            ) {
              const cellValue = obj[config.columnId];
              // Normalize cell value to array of strings
              const values =
                typeof cellValue == 'string'
                  ? cellValue.split(separator).map((v) => v.trim())
                  : Array.isArray(cellValue)
                    ? cellValue.map((v) => String(v).trim())
                    : [String(cellValue).trim()];

              // Return true if any value in cell matches a checked id
              return checkedValues.some((id) => values.includes(id));
            };

            master.filterByAll();

            // Set config.value.includes: null if all checked, else array of checked
            const allIds = list.data
              .serialize()
              .map((item: webix.obj) => item.id.toString());
            config.value = {
              condition: { filter: '', type: 'contains' },
              includes:
                checkedValues.length === allIds.length ? null : checkedValues,
            };

            //@ts-ignore
            if (config.value) _this2._mark_column(config.value, config.node);
          });

          // Hide the filter popup on horizontal scroll
          master.attachEvent('onScrollX', () => suggest.hide());
        }

        // Render the filter icon in the column header
        return (
          "<span class='webix_excel_filter webix_icon wxi-filter'></span>" +
          config.originText
        );
      },

      // Get selected filter values from the list
      getValue(_node: HTMLElement): {
        condition: { filter: string; type: string };
        includes: string[] | null;
      } {
        // Check if list exists and has data with serialize method
        if (
          !this._list ||
          !this._list.data ||
          typeof this._list.data.serialize !== 'function' ||
          typeof this._list.getSelectedId !== 'function'
        ) {
          return {
            condition: { filter: '', type: 'contains' },
            includes: null,
          };
        }

        // Get all available IDs
        const serializedData = this._list.data.serialize();
        if (!Array.isArray(serializedData)) {
          return {
            condition: { filter: '', type: 'contains' },
            includes: null,
          };
        }

        const allIds = serializedData
          .map((item: webix.obj) => (item && item.id ? item.id.toString() : ''))
          .filter(Boolean);

        // Get selected IDs
        const selectedIds = this._list.getSelectedId(true);
        const selected = Array.isArray(selectedIds)
          ? selectedIds
              .map((id: webix.obj) => (id ? id.toString() : ''))
              .filter(Boolean)
          : [];

        // If all or none are selected, includes should be null
        const includes =
          selected.length === 0 || selected.length === allIds.length
            ? null
            : selected;

        return {
          condition: { filter: '', type: 'contains' },
          includes,
        };
      },

      // Set selected filter values in the list
      setValue(
        _node: HTMLElement,
        value: string[] | { includes: string[] | null }
      ): void {
        // Check if list exists and has required methods
        if (
          !this._list ||
          !this._list.data ||
          typeof this._list.data.serialize !== 'function' ||
          typeof this._list.data.each !== 'function' ||
          typeof this._list.unselectAll !== 'function' ||
          typeof this._list.select !== 'function'
        ) {
          return;
        }

        let includes: string[] = [];

        // Parse the value parameter
        if (Array.isArray(value)) {
          includes = value;
        } else if (value && Array.isArray(value.includes)) {
          includes = value.includes;
        } else if (value && value.includes === null) {
          // If includes is null, select all items (default filter behavior)
          const serializedData = this._list.data.serialize();
          if (Array.isArray(serializedData)) {
            includes = serializedData
              .map((item: webix.obj) =>
                item && item.id ? item.id.toString() : ''
              )
              .filter(Boolean);
          }
        }

        // Clear current selection
        this._list.unselectAll();

        // Apply new selection
        if (Array.isArray(includes) && includes.length > 0) {
          includes.forEach((id) => {
            if (id && this._list) {
              this._list.select(id);
            }
          });
        } else if (includes === null || includes.length === 0) {
          // If includes is null or empty, select all (default filter behavior)
          this._list.data.each((obj: webix.obj) => {
            if (obj && obj.id && this._list) {
              this._list.select(obj.id);
            }
          });
        }
      },

      // Default compare logic for the filter (used by Webix)
      compare(
        cellValue: string[] | string,
        filterValue: string[] | null
      ): boolean {
        // If filter is cleared (null or empty), show all rows (including empty)
        if (
          filterValue == null ||
          (Array.isArray(filterValue) && filterValue.length === 0)
        ) {
          return true;
        }
        const values = Array.isArray(cellValue) ? cellValue : [cellValue];
        return filterValue.some((val) => values.includes(val));
      },
    },

    webix.copy(webix.ui.datafilter.excelFilter)
  );
});

Please correct me if any of my thoughts is wrong.

Thanks in advance!

Hello @Abhishek_Reddy ,

  • If I define name: "datatable" inside webix.protoUI while extending this: webix.ui.datatable, is this considered valid?

  • Or should I always give a new name (e.g., "customdatatable") instead of re-using "datatable"?

  • If I still keep name: "datatable", what differences or side-effects should I expect?

  • Will it override the global datatable defaults, or only affect the extended version?

Ideally, the name of a custom component has to be unique (a “customtable” that inherits the webix.ui.datatable)
All instances created under the same view name get the features of the prototype. If you declare a protoUI with an existing name, it’ll extend the existing component and rewrite matching methods.

Webix reuses various widgets within others: for example, the Gridsuggest includes the Datatable, and complex widgets are built with basic UI components. With that said, rewriting original widget could cause unwanted side-effects and not recommended (but possible, depending on the desired result).
Please read the article Creating a Custom Component to learn more information.

  • If I override onCollectValues in protoUI for datatable, will the same logic automatically apply to treetable as well (since it extends datatable)?
  • Or do I need to override it separately for treetable?

When you override onCollectValues in protoUI for datatable, the same logic will apply to treetable as well since treetable extends datatable. This means that any modifications you make to onCollectValues in the datatable prototype will automatically be inherited by the treetable.
Please check this example: Code Snippet

However, if you need specific behavior for treetable that differs from datatable, you will need to override onCollectValues separately for treetable. This allows you to customize its functionality without affecting the base datatable behavior.

  • Is this expected behavior when dealing with blank values?
  • What’s the recommended approach to handle empty cells consistently so that filters (like ExcelFilter / customExcelFilter) work correctly?

In Webix, the default comparison for filtering is done by value , not by ID . This means that when the Excel filter is applied, it compares the value fields in your data with the value of the selected options. Therefore, if the values are the same (both empty strings in this case), the comparison will succeed.
The Excel filter uses a more complex filtering mechanism that allows for flexible comparisons. In this setup, it compares the value fields directly, which means you don’t have to worry about the IDs when dealing with blank values.
In contrast, filters like richSelectFilter compare the value directly to the ID of the selected option. This means that if you have a blank value, and the selected option has a non-blank ID, the comparison will fail.
To ensure that empty cells are handled consistently across different filters always ensure that your filters are set up to compare the appropriate fields. For Excel filters, focus on the value fields.
If necessary, implement custom logic to handle blank values before they are pushed into your filters.