Hi @Natalia_Shilova , @Listopad
I have a few doubts/clarifications regarding protoUI and filter customization in Webix:
- 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"?
- 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?
- 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?
- 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.
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!