diff --git a/templates/bootstrap/_export.html.twig b/templates/bootstrap/_export.html.twig
index 1cf4472..0dca7d1 100644
--- a/templates/bootstrap/_export.html.twig
+++ b/templates/bootstrap/_export.html.twig
@@ -9,6 +9,7 @@
data-bs-toggle="dropdown"
aria-expanded="false"
>
+ {% if export_icon %} {% endif %}
{{ 'zhortein_datatable.export.label'|trans({}, 'zhortein_datatable') }}
@@ -16,6 +17,7 @@
{% for export_format in export_formats %}
{% if export_format in ['csv', 'xlsx'] %}
{% set format_export_url = export_urls[export_format] is defined ? export_urls[export_format] : (export_format == 'csv' ? default_export_url : '/_zhortein/datatable/' ~ definition.name ~ '/export/' ~ export_format) %}
+ {% set item_icon = export_format == 'csv' ? export_csv_icon : (export_format == 'xlsx' ? export_xlsx_icon : null) %}
+ {% if item_icon %} {% endif %}
{{ ('zhortein_datatable.export.' ~ export_format ~ '_current')|trans({}, 'zhortein_datatable') }}
+ {% if item_icon %} {% endif %}
{{ ('zhortein_datatable.export.' ~ export_format ~ '_full')|trans({}, 'zhortein_datatable') }}
{% endif %}
diff --git a/templates/bootstrap/_header.html.twig b/templates/bootstrap/_header.html.twig
index b6f3c8e..95a4227 100644
--- a/templates/bootstrap/_header.html.twig
+++ b/templates/bootstrap/_header.html.twig
@@ -5,6 +5,18 @@
+ {% if hasBulkActions|default(false) %}
+ |
+
+ |
+ {% endif %}
+
{% for column in visibleColumns %}
{% set is_current_sort = column.name == current_sort_field %}
{% set aria_sort = null %}
@@ -47,11 +59,11 @@
{% if is_current_sort and current_sort_direction == 'desc' %}
- ↓
+ {% if sort_desc is not empty %}{% else %}↓{% endif %}
{% elseif is_current_sort %}
- ↑
+ {% if sort_asc is not empty %}{% else %}↑{% endif %}
{% else %}
- ↕
+ {% if sort_neutral is not empty %}{% else %}↕{% endif %}
{% endif %}
@@ -71,7 +83,7 @@
filter: column_filter,
htmlId: htmlId,
filters: filters|default({})
- } only %}
+ } %}
{% endif %}
diff --git a/templates/bootstrap/_row.html.twig b/templates/bootstrap/_row.html.twig
index 42d044a..a0bff13 100644
--- a/templates/bootstrap/_row.html.twig
+++ b/templates/bootstrap/_row.html.twig
@@ -1,11 +1,27 @@
+ {% if hasBulkActions and row.identifier is defined %}
+ |
+
+ |
+ {% endif %}
+
{% for cell in row.cells %}
{% include '@ZhorteinDatatable/bootstrap/_cell.html.twig' with {
column: cell.column,
value: cell.value,
template: cell.template,
class_name: cell.className,
- boolean_display_mode: cell.booleanDisplayMode
+ boolean_display_mode: cell.booleanDisplayMode,
+ boolean_true_icon: cell.booleanTrueIcon,
+ boolean_false_icon: cell.booleanFalseIcon
} only %}
{% endfor %}
diff --git a/templates/bootstrap/_search_builder.html.twig b/templates/bootstrap/_search_builder.html.twig
new file mode 100644
index 0000000..d117d06
--- /dev/null
+++ b/templates/bootstrap/_search_builder.html.twig
@@ -0,0 +1,160 @@
+
+
+
+
+
+ {# Conditions and subgroups will be added here via JS #}
+
+
+
+
+
+
+
+
+ {# Template for a single condition #}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {# Template for a nested subgroup #}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/bootstrap/_toolbar.html.twig b/templates/bootstrap/_toolbar.html.twig
index 755c5bb..715e8f4 100644
--- a/templates/bootstrap/_toolbar.html.twig
+++ b/templates/bootstrap/_toolbar.html.twig
@@ -33,7 +33,7 @@
definition: definition,
htmlId: htmlId,
filters: filters|default({})
- } only %}
+ } %}
{% endif %}
{% if definition.filters is not empty %}
@@ -66,14 +66,31 @@
htmlId: htmlId,
runtime_visible_columns: runtime_visible_columns,
runtime_hidden_columns: runtime_hidden_columns
- } only %}
+ } %}
{% endif %}
{% if export_enabled %}
{% include '@ZhorteinDatatable/bootstrap/_export.html.twig' with {
definition: definition,
options: options
- } only %}
+ } %}
+ {% endif %}
+
+ {% if options.searchBuilder is defined and options.searchBuilder %}
+
{% endif %}
{% if not is_split_layout and page_size_selector_enabled %}
diff --git a/templates/bootstrap/cell/boolean.html.twig b/templates/bootstrap/cell/boolean.html.twig
index ba0b881..a269756 100644
--- a/templates/bootstrap/cell/boolean.html.twig
+++ b/templates/bootstrap/cell/boolean.html.twig
@@ -4,10 +4,18 @@
{% if display_mode == 'icon' %}
{% if value %}
- ✓
+ {% if boolean_true_icon %}
+
+ {% else %}
+ ✓
+ {% endif %}
{{ yes_label }}
{% else %}
- ×
+ {% if boolean_false_icon %}
+
+ {% else %}
+ ×
+ {% endif %}
{{ no_label }}
{% endif %}
{% elseif display_mode == 'switch' %}
diff --git a/templates/bootstrap/datatable.html.twig b/templates/bootstrap/datatable.html.twig
index f27030a..e61bc52 100644
--- a/templates/bootstrap/datatable.html.twig
+++ b/templates/bootstrap/datatable.html.twig
@@ -4,6 +4,7 @@
{% set sort_field = options.sortField|default('') %}
{% set sort_direction = options.sortDirection|default('asc') %}
{% set auto_load = options.autoLoad is defined ? options.autoLoad : true %}
+{% set search_builder = options.searchBuilder is defined ? options.searchBuilder : false %}
{% set controls_layout = options.controlsLayout|default('default') %}
{% set table_striped = options.tableStriped is defined ? options.tableStriped : true %}
{% set table_hover = options.tableHover is defined ? options.tableHover : true %}
@@ -67,6 +68,7 @@
data-zhortein--datatable-bundle--datatable-sort-direction-value="{{ sort_direction }}"
data-zhortein--datatable-bundle--datatable-auto-load-value="{{ auto_load ? 'true' : 'false' }}"
data-zhortein--datatable-bundle--datatable-filter-layout-value="{{ filter_layout }}"
+ data-zhortein--datatable-bundle--datatable-search-builder-value="{{ search_builder ? 'true' : 'false' }}"
data-zhortein--datatable-bundle--datatable-boolean-display-mode-value="{{ boolean_display_mode }}"
data-zhortein--datatable-bundle--datatable-pagination-size-value="{{ pagination_size }}"
data-zhortein--datatable-bundle--datatable-table-small-value="{{ table_small ? 'true' : 'false' }}"
@@ -79,7 +81,15 @@
options: options,
controlsLayout: controls_layout,
filters: filters|default({})
- } only %}
+ } %}
+
+ {% if search_builder %}
+ {% include '@ZhorteinDatatable/bootstrap/_search_builder.html.twig' %}
+ {% endif %}
+
+ {% include '@ZhorteinDatatable/bootstrap/_bulk_actions.html.twig' with {
+ bulkActions: bulkActions|default([])
+ } %}
+ `;
+}
+
+async function flushPromises() {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+function startApplication() {
+ const application = Application.start();
+ application.register(CONTROLLER_IDENTIFIER, DatatableController);
+
+ return application;
+}
+
+async function getController(application) {
+ await flushPromises();
+
+ const element = document.querySelector('#zhortein-datatable-users');
+ const controller = application.getControllerForElementAndIdentifier(element, CONTROLLER_IDENTIFIER);
+
+ return { element, controller };
+}
+
+function createPreventableEvent(target) {
+ return {
+ currentTarget: target,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ };
+}
+
+describe('datatable_controller bulk actions', () => {
+ let application = null;
+
+ afterEach(() => {
+ if (application) {
+ application.stop();
+ application = null;
+ }
+
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ document.body.innerHTML = '';
+ });
+
+ it('injects selected IDs into the form on submission', async () => {
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+ const { controller } = await getController(application);
+
+ // Select rows
+ controller.selectedIds.add('1');
+ controller.selectedIds.add('2');
+
+ const form = document.querySelector('#bulk-delete-form');
+ const event = createPreventableEvent(form);
+
+ controller.submitBulkAction(event);
+
+ const inputs = form.querySelectorAll('input[name="ids[]"]');
+ expect(inputs.length).toBe(2);
+ expect(inputs[0].value).toBe('1');
+ expect(inputs[1].value).toBe('2');
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ });
+
+ it('clears existing injected IDs before injecting new ones', async () => {
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+ const { controller } = await getController(application);
+
+ controller.selectedIds.add('1');
+
+ const form = document.querySelector('#bulk-delete-form');
+
+ // Add an existing input
+ const existingInput = document.createElement('input');
+ existingInput.type = 'hidden';
+ existingInput.name = 'ids[]';
+ existingInput.value = 'old';
+ form.appendChild(existingInput);
+
+ const event = createPreventableEvent(form);
+ controller.submitBulkAction(event);
+
+ const inputs = form.querySelectorAll('input[name="ids[]"]');
+ expect(inputs.length).toBe(1);
+ expect(inputs[0].value).toBe('1');
+ });
+
+ it('uses custom parameter name for selected rows', async () => {
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+ const { controller } = await getController(application);
+
+ controller.selectedIds.add('42');
+
+ const form = document.querySelector('#bulk-archive-form');
+ const event = createPreventableEvent(form);
+
+ // We bypass confirmation for this test by mocking confirm
+ vi.stubGlobal('confirm', () => true);
+
+ // But submitBulkAction will call confirmAction which will preventDefault
+ // and eventually call executeConfirmedTarget.
+ // For simplicity, let's test injectSelectedIds directly or handle the flow.
+
+ controller.injectSelectedIds(form);
+
+ const inputs = form.querySelectorAll('input[name="rows[]"]');
+ expect(inputs.length).toBe(1);
+ expect(inputs[0].value).toBe('42');
+ });
+
+ it('prevents submission when no rows are selected', async () => {
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+ const { controller } = await getController(application);
+
+ expect(controller.selectedIds.size).toBe(0);
+
+ const form = document.querySelector('#bulk-delete-form');
+ const event = createPreventableEvent(form);
+
+ controller.submitBulkAction(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(form.querySelectorAll('input[name="ids[]"]').length).toBe(0);
+ });
+
+ it('triggers confirmation before injecting IDs', async () => {
+ const confirmMock = vi.fn(() => true);
+ vi.stubGlobal('confirm', confirmMock);
+
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+ const { controller } = await getController(application);
+
+ controller.selectedIds.add('1');
+
+ const form = document.querySelector('#bulk-archive-form');
+
+ // Mock form.submit to avoid errors in JSDOM and verify it's called
+ form.submit = vi.fn();
+
+ const event = createPreventableEvent(form);
+ controller.submitBulkAction(event);
+
+ expect(confirmMock).toHaveBeenCalledWith('Archive selected users?');
+ expect(event.preventDefault).toHaveBeenCalled();
+
+ // After confirmation, executeConfirmedTarget should have been called
+ // Since we mocked window.confirm to return true, confirmAction should have called executeConfirmedTarget
+ expect(form.submit).toHaveBeenCalled();
+ const inputs = form.querySelectorAll('input[name="rows[]"]');
+ expect(inputs.length).toBe(1);
+ expect(inputs[0].value).toBe('1');
+ });
+});
diff --git a/tests/Frontend/datatable_controller_search_builder.test.js b/tests/Frontend/datatable_controller_search_builder.test.js
new file mode 100644
index 0000000..697695a
--- /dev/null
+++ b/tests/Frontend/datatable_controller_search_builder.test.js
@@ -0,0 +1,474 @@
+import { Application } from '@hotwired/stimulus';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import DatatableController from '../../assets/controllers/datatable_controller.js';
+
+const CONTROLLER_IDENTIFIER = 'zhortein--datatable-bundle--datatable';
+
+function createDatatableHtml() {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+function createJsonResponse(payload = {}) {
+ return {
+ ok: true,
+ json: () => Promise.resolve({
+ body: ' | loaded | ',
+ page: 1,
+ pageSize: 25,
+ ...payload,
+ }),
+ };
+}
+
+async function flushPromises() {
+ for (let i = 0; i < 20; i++) {
+ await Promise.resolve();
+ }
+}
+
+function startApplication() {
+ const application = Application.start();
+ application.register(CONTROLLER_IDENTIFIER, DatatableController);
+
+ return application;
+}
+
+async function getController(application) {
+ await flushPromises();
+
+ const element = document.querySelector('#zhortein-datatable-users');
+ const controller = application.getControllerForElementAndIdentifier(element, CONTROLLER_IDENTIFIER);
+
+ return { element, controller };
+}
+
+function getLastRequestedUrl(fetchMock) {
+ const rawUrl = fetchMock.mock.calls.at(-1)[0];
+
+ return new URL(rawUrl, window.location.origin);
+}
+
+function getRootAddConditionButton(element) {
+ const rootGroup = element.querySelector('.zhortein-datatable__search-builder-group--root');
+
+ return rootGroup.querySelector(':scope > button[data-action$="#addSearchBuilderCondition"]');
+}
+
+function getRootAddSubgroupButton(element) {
+ const rootGroup = element.querySelector('.zhortein-datatable__search-builder-group--root');
+
+ return rootGroup.querySelector(':scope > button[data-action$="#addSearchBuilderSubgroup"]');
+}
+
+function clickWithCurrentTarget(controller, methodName, button) {
+ controller[methodName]({ currentTarget: button, preventDefault: () => {} });
+}
+
+describe('datatable_controller search builder interactions', () => {
+ let application = null;
+
+ afterEach(() => {
+ if (application) {
+ application.stop();
+ application = null;
+ }
+
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ document.body.innerHTML = '';
+ });
+
+ it('adds and removes conditions', async () => {
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+
+ const { element, controller } = await getController(application);
+ const addButton = getRootAddConditionButton(element);
+ const conditionsContainer = element.querySelector('.zhortein-datatable__search-builder-group--root > .zhortein-datatable__search-builder-conditions');
+
+ expect(conditionsContainer.children.length).toBe(0);
+
+ clickWithCurrentTarget(controller, 'addSearchBuilderCondition', addButton);
+ expect(conditionsContainer.children.length).toBe(1);
+
+ clickWithCurrentTarget(controller, 'addSearchBuilderCondition', addButton);
+ expect(conditionsContainer.children.length).toBe(2);
+
+ const removeButton = conditionsContainer.children[0].querySelector('button[data-action$="#removeSearchBuilderCondition"]');
+ controller.removeSearchBuilderCondition({ currentTarget: removeButton, preventDefault: () => {} });
+ expect(conditionsContainer.children.length).toBe(1);
+ });
+
+ it('updates operator choices when field changes', async () => {
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+
+ const { element, controller } = await getController(application);
+ clickWithCurrentTarget(controller, 'addSearchBuilderCondition', getRootAddConditionButton(element));
+
+ const condition = element.querySelector('.zhortein-datatable__search-builder-condition');
+ const fieldSelect = condition.querySelector('select[data-action$="#onSearchBuilderFieldChange"]');
+ const operatorSelect = condition.querySelector('select[data-action$="#onSearchBuilderOperatorChange"]');
+
+ expect(operatorSelect.disabled).toBe(true);
+
+ fieldSelect.value = 'email';
+ controller.onSearchBuilderFieldChange({ target: fieldSelect });
+
+ expect(operatorSelect.disabled).toBe(false);
+ expect(operatorSelect.options.length).toBe(4); // Select + 3 operators
+ expect(operatorSelect.options[1].value).toBe('eq');
+ expect(operatorSelect.options[1].textContent).toBe('Equals');
+ });
+
+ it('updates value input based on field type and operator', async () => {
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+
+ const { element, controller } = await getController(application);
+ clickWithCurrentTarget(controller, 'addSearchBuilderCondition', getRootAddConditionButton(element));
+
+ const condition = element.querySelector('.zhortein-datatable__search-builder-condition');
+ const fieldSelect = condition.querySelector('select[data-action$="#onSearchBuilderFieldChange"]');
+ const valueContainer = condition.querySelector('.zhortein-datatable__search-builder-value-container');
+
+ // Choice field
+ fieldSelect.value = 'status';
+ controller.onSearchBuilderFieldChange({ target: fieldSelect });
+ expect(valueContainer.querySelector('select')).not.toBeNull();
+ expect(valueContainer.querySelector('select').options.length).toBe(2);
+
+ // Boolean field
+ fieldSelect.value = 'enabled';
+ controller.onSearchBuilderFieldChange({ target: fieldSelect });
+ expect(valueContainer.querySelector('select')).not.toBeNull();
+ expect(valueContainer.querySelector('select').options.length).toBe(2);
+ expect(valueContainer.querySelector('select').options[0].textContent).toBe('Yes');
+
+ // Number field with between operator
+ fieldSelect.value = 'age';
+ controller.onSearchBuilderFieldChange({ target: fieldSelect });
+ const operatorSelect = condition.querySelector('select[data-action$="#onSearchBuilderOperatorChange"]');
+ operatorSelect.value = 'between';
+ controller.onSearchBuilderOperatorChange();
+ controller.updateSearchBuilderValueInput(condition, 'number', null);
+
+ expect(valueContainer.querySelectorAll('input').length).toBe(2);
+ expect(valueContainer.querySelectorAll('input')[0].placeholder).toBe('From');
+ });
+
+ it('serializes search builder payload in ajax request', async () => {
+ const fetchMock = vi.fn(() => Promise.resolve(createJsonResponse()));
+ vi.stubGlobal('fetch', fetchMock);
+
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+
+ const { element, controller } = await getController(application);
+ clickWithCurrentTarget(controller, 'addSearchBuilderCondition', getRootAddConditionButton(element));
+
+ const condition = element.querySelector('.zhortein-datatable__search-builder-condition');
+ const fieldSelect = condition.querySelector('select[data-action$="#onSearchBuilderFieldChange"]');
+ fieldSelect.value = 'email';
+ controller.onSearchBuilderFieldChange({ target: fieldSelect });
+
+ const operatorSelect = condition.querySelector('select[data-action$="#onSearchBuilderOperatorChange"]');
+ operatorSelect.value = 'contains';
+ controller.onSearchBuilderOperatorChange();
+
+ const valueInput = condition.querySelector('.zhortein-datatable__search-builder-value-container input');
+ valueInput.value = 'example';
+
+ controller.refresh();
+
+ await flushPromises();
+
+ const url = getLastRequestedUrl(fetchMock);
+ expect(url.searchParams.get('advancedFilters[logic]')).toBe('and');
+ expect(url.searchParams.get('advancedFilters[conditions][0][field]')).toBe('email');
+ expect(url.searchParams.get('advancedFilters[conditions][0][operator]')).toBe('contains');
+ expect(url.searchParams.get('advancedFilters[conditions][0][value]')).toBe('example');
+ });
+
+ it('restricts operator list to per-field allowed operators', async () => {
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+
+ const { element, controller } = await getController(application);
+ clickWithCurrentTarget(controller, 'addSearchBuilderCondition', getRootAddConditionButton(element));
+
+ const condition = element.querySelector('.zhortein-datatable__search-builder-condition');
+ const fieldSelect = condition.querySelector('select[data-action$="#onSearchBuilderFieldChange"]');
+ const operatorSelect = condition.querySelector('select[data-action$="#onSearchBuilderOperatorChange"]');
+
+ fieldSelect.value = 'name';
+ controller.onSearchBuilderFieldChange({ target: fieldSelect });
+
+ const operatorValues = Array.from(operatorSelect.options).map((o) => o.value);
+ expect(operatorValues).toEqual(['', 'eq', 'contains']);
+ });
+
+ it('includes advanced filters in export URL', async () => {
+ const assignSpy = vi.fn();
+ Object.defineProperty(window, 'location', {
+ value: {
+ origin: 'https://example.test',
+ href: 'https://example.test/current-page',
+ assign: assignSpy,
+ },
+ writable: true,
+ });
+
+ document.body.innerHTML = createDatatableHtml();
+ const exportEl = document.querySelector('#zhortein-datatable-users');
+ exportEl.setAttribute(`data-${CONTROLLER_IDENTIFIER}-export-url-value`, '/_zhortein/datatable/users/export');
+
+ application = startApplication();
+ const { element, controller } = await getController(application);
+
+ clickWithCurrentTarget(controller, 'addSearchBuilderCondition', getRootAddConditionButton(element));
+ const condition = element.querySelector('.zhortein-datatable__search-builder-condition');
+ const fieldSelect = condition.querySelector('select[data-action$="#onSearchBuilderFieldChange"]');
+ fieldSelect.value = 'email';
+ controller.onSearchBuilderFieldChange({ target: fieldSelect });
+ const operatorSelect = condition.querySelector('select[data-action$="#onSearchBuilderOperatorChange"]');
+ operatorSelect.value = 'contains';
+ controller.onSearchBuilderOperatorChange();
+ const valueInput = condition.querySelector('.zhortein-datatable__search-builder-value-container input');
+ valueInput.value = 'example';
+
+ const anchor = document.createElement('a');
+ anchor.href = '/_zhortein/datatable/users/export';
+
+ controller.export({
+ preventDefault: () => {},
+ currentTarget: anchor,
+ params: { exportMode: 'all' },
+ });
+
+ expect(assignSpy).toHaveBeenCalledTimes(1);
+ const url = new URL(assignSpy.mock.calls.at(-1)[0]);
+ expect(url.searchParams.get('advancedFilters[logic]')).toBe('and');
+ expect(url.searchParams.get('advancedFilters[conditions][0][field]')).toBe('email');
+ expect(url.searchParams.get('advancedFilters[conditions][0][operator]')).toBe('contains');
+ expect(url.searchParams.get('advancedFilters[conditions][0][value]')).toBe('example');
+ });
+
+ it('clears search builder and refreshes', async () => {
+ const fetchMock = vi.fn(() => Promise.resolve(createJsonResponse()));
+ vi.stubGlobal('fetch', fetchMock);
+
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+
+ const { element } = await getController(application);
+ const addButton = getRootAddConditionButton(element);
+ addButton.click();
+
+ const clearButton = element.querySelector('button[data-action$="#clearSearchBuilder"]');
+ clearButton.click();
+
+ await flushPromises();
+
+ const url = getLastRequestedUrl(fetchMock);
+ expect(element.querySelector('.zhortein-datatable__search-builder-group--root > .zhortein-datatable__search-builder-conditions').children.length).toBe(0);
+ expect(url.searchParams.has('advancedFilters[logic]')).toBe(false);
+ });
+
+ it('adds and removes a nested subgroup with its own conditions', async () => {
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+
+ const { element, controller } = await getController(application);
+
+ clickWithCurrentTarget(controller, 'addSearchBuilderSubgroup', getRootAddSubgroupButton(element));
+
+ const subgroup = element.querySelector('.zhortein-datatable__search-builder-group--nested');
+ expect(subgroup).not.toBeNull();
+
+ const subgroupAddCondition = subgroup.querySelector(':scope > button[data-action$="#addSearchBuilderCondition"]');
+ clickWithCurrentTarget(controller, 'addSearchBuilderCondition', subgroupAddCondition);
+
+ const conditions = subgroup.querySelectorAll(':scope > .zhortein-datatable__search-builder-conditions > .zhortein-datatable__search-builder-condition');
+ expect(conditions.length).toBe(1);
+
+ const removeSubgroupBtn = subgroup.querySelector('button[data-action$="#removeSearchBuilderSubgroup"]');
+ controller.removeSearchBuilderSubgroup({ currentTarget: removeSubgroupBtn, preventDefault: () => {} });
+
+ expect(element.querySelector('.zhortein-datatable__search-builder-group--nested')).toBeNull();
+ });
+
+ it('serializes nested groups using the conditions key', async () => {
+ const fetchMock = vi.fn(() => Promise.resolve(createJsonResponse()));
+ vi.stubGlobal('fetch', fetchMock);
+
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+
+ const { element, controller } = await getController(application);
+
+ // Root condition: email contains "alice"
+ clickWithCurrentTarget(controller, 'addSearchBuilderCondition', getRootAddConditionButton(element));
+ const rootCondition = element.querySelector('.zhortein-datatable__search-builder-group--root > .zhortein-datatable__search-builder-conditions > .zhortein-datatable__search-builder-condition');
+ const rootFieldSelect = rootCondition.querySelector('select[data-action$="#onSearchBuilderFieldChange"]');
+ rootFieldSelect.value = 'email';
+ controller.onSearchBuilderFieldChange({ target: rootFieldSelect });
+ rootCondition.querySelector('select[data-action$="#onSearchBuilderOperatorChange"]').value = 'contains';
+ controller.onSearchBuilderOperatorChange();
+ rootCondition.querySelector('.zhortein-datatable__search-builder-value-container input').value = 'alice';
+
+ // Subgroup with logic OR
+ clickWithCurrentTarget(controller, 'addSearchBuilderSubgroup', getRootAddSubgroupButton(element));
+ const subgroup = element.querySelector('.zhortein-datatable__search-builder-group--nested');
+ subgroup.querySelector('select.zhortein-datatable__search-builder-logic').value = 'OR';
+
+ // Condition inside subgroup
+ const subAddCondition = subgroup.querySelector(':scope > button[data-action$="#addSearchBuilderCondition"]');
+ clickWithCurrentTarget(controller, 'addSearchBuilderCondition', subAddCondition);
+ const subCondition = subgroup.querySelector(':scope > .zhortein-datatable__search-builder-conditions > .zhortein-datatable__search-builder-condition');
+ const subFieldSelect = subCondition.querySelector('select[data-action$="#onSearchBuilderFieldChange"]');
+ subFieldSelect.value = 'enabled';
+ controller.onSearchBuilderFieldChange({ target: subFieldSelect });
+ subCondition.querySelector('select[data-action$="#onSearchBuilderOperatorChange"]').value = 'eq';
+ controller.onSearchBuilderOperatorChange();
+ // Boolean field swaps the input for a select with value "1" already
+ const booleanValueSelect = subCondition.querySelector('.zhortein-datatable__search-builder-value-container select');
+ if (booleanValueSelect) {
+ booleanValueSelect.value = '1';
+ }
+
+ controller.refresh();
+ await flushPromises();
+
+ const url = getLastRequestedUrl(fetchMock);
+ expect(url.searchParams.get('advancedFilters[logic]')).toBe('and');
+ expect(url.searchParams.get('advancedFilters[conditions][0][field]')).toBe('email');
+ expect(url.searchParams.get('advancedFilters[conditions][0][operator]')).toBe('contains');
+ expect(url.searchParams.get('advancedFilters[conditions][0][value]')).toBe('alice');
+ expect(url.searchParams.get('advancedFilters[conditions][1][logic]')).toBe('or');
+ expect(url.searchParams.get('advancedFilters[conditions][1][conditions][0][field]')).toBe('enabled');
+ expect(url.searchParams.get('advancedFilters[conditions][1][conditions][0][operator]')).toBe('eq');
+ });
+
+ it('changes subgroup logic and triggers refresh', async () => {
+ const fetchMock = vi.fn(() => Promise.resolve(createJsonResponse()));
+ vi.stubGlobal('fetch', fetchMock);
+
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+
+ const { element, controller } = await getController(application);
+
+ clickWithCurrentTarget(controller, 'addSearchBuilderSubgroup', getRootAddSubgroupButton(element));
+ const subgroup = element.querySelector('.zhortein-datatable__search-builder-group--nested');
+
+ // Add condition inside subgroup so it is serialized
+ const subAddCondition = subgroup.querySelector(':scope > button[data-action$="#addSearchBuilderCondition"]');
+ clickWithCurrentTarget(controller, 'addSearchBuilderCondition', subAddCondition);
+ const subCondition = subgroup.querySelector(':scope > .zhortein-datatable__search-builder-conditions > .zhortein-datatable__search-builder-condition');
+ const subFieldSelect = subCondition.querySelector('select[data-action$="#onSearchBuilderFieldChange"]');
+ subFieldSelect.value = 'email';
+ controller.onSearchBuilderFieldChange({ target: subFieldSelect });
+ subCondition.querySelector('select[data-action$="#onSearchBuilderOperatorChange"]').value = 'eq';
+ controller.onSearchBuilderOperatorChange();
+ subCondition.querySelector('.zhortein-datatable__search-builder-value-container input').value = 'bob';
+
+ const subgroupLogicSelect = subgroup.querySelector('select.zhortein-datatable__search-builder-logic');
+ subgroupLogicSelect.value = 'OR';
+ controller.updateSearchBuilderLogic();
+ await flushPromises();
+
+ const url = getLastRequestedUrl(fetchMock);
+ expect(url.searchParams.get('advancedFilters[conditions][0][logic]')).toBe('or');
+ });
+
+ it('clears nested filters when clearing the search builder', async () => {
+ const fetchMock = vi.fn(() => Promise.resolve(createJsonResponse()));
+ vi.stubGlobal('fetch', fetchMock);
+
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+
+ const { element, controller } = await getController(application);
+ clickWithCurrentTarget(controller, 'addSearchBuilderCondition', getRootAddConditionButton(element));
+ clickWithCurrentTarget(controller, 'addSearchBuilderSubgroup', getRootAddSubgroupButton(element));
+
+ const rootConditionsContainer = element.querySelector('.zhortein-datatable__search-builder-group--root > .zhortein-datatable__search-builder-conditions');
+ expect(rootConditionsContainer.children.length).toBe(2);
+
+ element.querySelector('button[data-action$="#clearSearchBuilder"]').click();
+ await flushPromises();
+
+ expect(rootConditionsContainer.children.length).toBe(0);
+ const url = getLastRequestedUrl(fetchMock);
+ expect(url.searchParams.has('advancedFilters[logic]')).toBe(false);
+ });
+});
diff --git a/tests/Frontend/datatable_controller_selection.test.js b/tests/Frontend/datatable_controller_selection.test.js
new file mode 100644
index 0000000..22810cc
--- /dev/null
+++ b/tests/Frontend/datatable_controller_selection.test.js
@@ -0,0 +1,248 @@
+import { Application } from '@hotwired/stimulus';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import DatatableController from '../../assets/controllers/datatable_controller.js';
+
+const CONTROLLER_IDENTIFIER = 'zhortein--datatable-bundle--datatable';
+
+function createDatatableHtml() {
+ return `
+
+ `;
+}
+
+function createJsonResponse(payload = {}) {
+ return {
+ ok: true,
+ json: () => Promise.resolve({
+ body: `
+
+ |
+
+ |
+ charlie@example.test |
+
+ `,
+ ...payload,
+ }),
+ };
+}
+
+async function flushPromises() {
+ for (let i = 0; i < 20; i++) {
+ await Promise.resolve();
+ }
+}
+
+function startApplication() {
+ const application = Application.start();
+ application.register(CONTROLLER_IDENTIFIER, DatatableController);
+
+ return application;
+}
+
+async function getController(application) {
+ await flushPromises();
+
+ const element = document.querySelector('#zhortein-datatable-users');
+ const controller = application.getControllerForElementAndIdentifier(element, CONTROLLER_IDENTIFIER);
+
+ return { element, controller };
+}
+
+describe('datatable_controller row selection', () => {
+ let application = null;
+
+ afterEach(() => {
+ if (application) {
+ application.stop();
+ application = null;
+ }
+
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ document.body.innerHTML = '';
+ });
+
+ it('tracks single row selection', async () => {
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+ const { controller } = await getController(application);
+
+ const checkboxes = document.querySelectorAll(`[data-${CONTROLLER_IDENTIFIER}-target="rowCheckbox"]`);
+ const selectAllCheckbox = document.querySelector(`[data-${CONTROLLER_IDENTIFIER}-target="selectAllCheckbox"]`);
+ const selectedCount = document.querySelector(`[data-${CONTROLLER_IDENTIFIER}-target="selectedCount"]`);
+
+ // Select first row
+ checkboxes[0].checked = true;
+ checkboxes[0].dispatchEvent(new Event('change'));
+
+ expect(controller.selectedIds.has('1')).toBe(true);
+ expect(controller.selectedIds.size).toBe(1);
+ expect(selectedCount.textContent).toBe('1');
+ expect(selectAllCheckbox.checked).toBe(false);
+ expect(selectAllCheckbox.indeterminate).toBe(true);
+
+ // Select second row
+ checkboxes[1].checked = true;
+ checkboxes[1].dispatchEvent(new Event('change'));
+
+ expect(controller.selectedIds.has('2')).toBe(true);
+ expect(controller.selectedIds.size).toBe(2);
+ expect(selectedCount.textContent).toBe('2');
+ expect(selectAllCheckbox.checked).toBe(true);
+ expect(selectAllCheckbox.indeterminate).toBe(false);
+
+ // Unselect first row
+ checkboxes[0].checked = false;
+ checkboxes[0].dispatchEvent(new Event('change'));
+
+ expect(controller.selectedIds.has('1')).toBe(false);
+ expect(controller.selectedIds.size).toBe(1);
+ expect(selectedCount.textContent).toBe('1');
+ expect(selectAllCheckbox.checked).toBe(false);
+ expect(selectAllCheckbox.indeterminate).toBe(true);
+ });
+
+ it('selects all visible rows via header checkbox', async () => {
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+ const { controller } = await getController(application);
+
+ const selectAllCheckbox = document.querySelector(`[data-${CONTROLLER_IDENTIFIER}-target="selectAllCheckbox"]`);
+ const checkboxes = document.querySelectorAll(`[data-${CONTROLLER_IDENTIFIER}-target="rowCheckbox"]`);
+ const selectedCount = document.querySelector(`[data-${CONTROLLER_IDENTIFIER}-target="selectedCount"]`);
+
+ // Select all
+ selectAllCheckbox.checked = true;
+ selectAllCheckbox.dispatchEvent(new Event('change'));
+
+ expect(checkboxes[0].checked).toBe(true);
+ expect(checkboxes[1].checked).toBe(true);
+ expect(controller.selectedIds.has('1')).toBe(true);
+ expect(controller.selectedIds.has('2')).toBe(true);
+ expect(controller.selectedIds.size).toBe(2);
+ expect(selectedCount.textContent).toBe('2');
+ expect(selectAllCheckbox.indeterminate).toBe(false);
+
+ // Unselect all
+ selectAllCheckbox.checked = false;
+ selectAllCheckbox.dispatchEvent(new Event('change'));
+
+ expect(checkboxes[0].checked).toBe(false);
+ expect(checkboxes[1].checked).toBe(false);
+ expect(controller.selectedIds.size).toBe(0);
+ expect(selectedCount.textContent).toBe('0');
+ expect(selectAllCheckbox.indeterminate).toBe(false);
+ });
+
+ it('resets selection on refresh', async () => {
+ const fetchMock = vi.fn(() => Promise.resolve(createJsonResponse()));
+ vi.stubGlobal('fetch', fetchMock);
+
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+ const { controller } = await getController(application);
+
+ const checkboxes = document.querySelectorAll(`[data-${CONTROLLER_IDENTIFIER}-target="rowCheckbox"]`);
+ const selectedCount = document.querySelector(`[data-${CONTROLLER_IDENTIFIER}-target="selectedCount"]`);
+
+ checkboxes[0].checked = true;
+ checkboxes[0].dispatchEvent(new Event('change'));
+
+ expect(controller.selectedIds.size).toBe(1);
+ expect(selectedCount.textContent).toBe('1');
+
+ await controller.refresh();
+ await flushPromises();
+
+ expect(controller.selectedIds.size).toBe(0);
+ expect(selectedCount.textContent).toBe('0');
+
+ const newCheckboxes = document.querySelectorAll(`[data-${CONTROLLER_IDENTIFIER}-target="rowCheckbox"]`);
+ expect(newCheckboxes.length).toBe(1);
+ expect(newCheckboxes[0].value).toBe('3');
+ expect(newCheckboxes[0].checked).toBe(false);
+ });
+
+ it('toggles bulk toolbar and buttons based on selection', async () => {
+ document.body.innerHTML = createDatatableHtml();
+ application = startApplication();
+ const { controller } = await getController(application);
+
+ const checkboxes = document.querySelectorAll(`[data-${CONTROLLER_IDENTIFIER}-target="rowCheckbox"]`);
+ const bulkToolbar = document.querySelector(`[data-${CONTROLLER_IDENTIFIER}-target="bulkToolbar"]`);
+ const bulkAction = document.querySelector(`[data-${CONTROLLER_IDENTIFIER}-target="bulkAction"]`);
+
+ expect(bulkToolbar.hidden).toBe(true);
+ expect(bulkAction.disabled).toBe(true);
+
+ // Select first row
+ checkboxes[0].checked = true;
+ checkboxes[0].dispatchEvent(new Event('change'));
+
+ expect(bulkToolbar.hidden).toBe(false);
+ expect(bulkAction.disabled).toBe(false);
+
+ // Unselect first row
+ checkboxes[0].checked = false;
+ checkboxes[0].dispatchEvent(new Event('change'));
+
+ expect(bulkToolbar.hidden).toBe(true);
+ expect(bulkAction.disabled).toBe(true);
+ });
+});
diff --git a/tests/Functional/Doctrine/DoctrineAdvancedFiltersFunctionalTest.php b/tests/Functional/Doctrine/DoctrineAdvancedFiltersFunctionalTest.php
new file mode 100644
index 0000000..cfc485a
--- /dev/null
+++ b/tests/Functional/Doctrine/DoctrineAdvancedFiltersFunctionalTest.php
@@ -0,0 +1,303 @@
+bootDoctrineAndLoadFixtures();
+
+ $expression = new AdvancedFilterExpression(
+ new Group(LogicOperator::And, [
+ new Condition('email', ComparisonOperator::Equals, 'alice@example.test'),
+ ])
+ );
+
+ $result = $this->createProvider()->getData(
+ $this->createDefinition(),
+ DatatableRequest::create(advancedFilterExpression: $expression),
+ );
+
+ self::assertSame(3, $result->getTotalItems());
+ self::assertSame(1, $result->getFilteredItems());
+ self::assertSame(['alice@example.test'], array_column($result->getRows(), 'e_email'));
+ }
+
+ public function test_it_applies_and_condition(): void
+ {
+ $this->bootDoctrineAndLoadFixtures();
+
+ $expression = new AdvancedFilterExpression(
+ new Group(LogicOperator::And, [
+ new Condition('enabled', ComparisonOperator::Equals, true),
+ new Condition('displayName', ComparisonOperator::Contains, 'Charlie'),
+ ])
+ );
+
+ $result = $this->createProvider()->getData(
+ $this->createDefinition(),
+ DatatableRequest::create(advancedFilterExpression: $expression),
+ );
+
+ self::assertSame(1, $result->getFilteredItems());
+ self::assertSame(['charlie@example.test'], array_column($result->getRows(), 'e_email'));
+ }
+
+ public function test_it_applies_or_condition(): void
+ {
+ $this->bootDoctrineAndLoadFixtures();
+
+ $expression = new AdvancedFilterExpression(
+ new Group(LogicOperator::Or, [
+ new Condition('email', ComparisonOperator::Equals, 'alice@example.test'),
+ new Condition('email', ComparisonOperator::Equals, 'bob@example.test'),
+ ])
+ );
+
+ $result = $this->createProvider()->getData(
+ $this->createDefinition(),
+ DatatableRequest::create(advancedFilterExpression: $expression),
+ );
+
+ self::assertSame(2, $result->getFilteredItems());
+ $emails = array_column($result->getRows(), 'e_email');
+ sort($emails);
+ self::assertSame(['alice@example.test', 'bob@example.test'], $emails);
+ }
+
+ public function test_it_applies_nested_conditions(): void
+ {
+ $this->bootDoctrineAndLoadFixtures();
+
+ $expression = new AdvancedFilterExpression(
+ new Group(LogicOperator::And, [
+ new Condition('enabled', ComparisonOperator::Equals, true),
+ new Group(LogicOperator::Or, [
+ new Condition('displayName', ComparisonOperator::Equals, 'Alice'),
+ new Condition('displayName', ComparisonOperator::Equals, 'Charlie'),
+ ]),
+ ])
+ );
+
+ $result = $this->createProvider()->getData(
+ $this->createDefinition(),
+ DatatableRequest::create(advancedFilterExpression: $expression),
+ );
+
+ self::assertSame(2, $result->getFilteredItems());
+ }
+
+ public function test_it_applies_various_operators(): void
+ {
+ $this->bootDoctrineAndLoadFixtures();
+
+ $cases = [
+ [ComparisonOperator::NotEquals, 'alice@example.test', 2],
+ [ComparisonOperator::StartsWith, 'ali', 1],
+ [ComparisonOperator::EndsWith, '.test', 3],
+ [ComparisonOperator::Contains, 'example', 3],
+ [ComparisonOperator::NotContains, 'alice', 2],
+ [ComparisonOperator::In, ['alice@example.test', 'bob@example.test'], 2],
+ [ComparisonOperator::NotIn, ['alice@example.test'], 2],
+ [ComparisonOperator::IsNull, null, 0],
+ [ComparisonOperator::IsNotNull, null, 3],
+ ];
+
+ foreach ($cases as [$operator, $value, $expectedCount]) {
+ $expression = new AdvancedFilterExpression(
+ new Group(LogicOperator::And, [
+ new Condition('email', $operator, $value),
+ ])
+ );
+
+ $result = $this->createProvider()->getData(
+ $this->createDefinition(),
+ DatatableRequest::create(advancedFilterExpression: $expression),
+ );
+
+ self::assertSame($expectedCount, $result->getFilteredItems(), sprintf('Failed for operator %s', $operator->value));
+ }
+ }
+
+ public function test_it_applies_between_operator(): void
+ {
+ $this->bootDoctrineAndLoadFixtures();
+
+ $expression = new AdvancedFilterExpression(
+ new Group(LogicOperator::And, [
+ new Condition('id', ComparisonOperator::Between, [1, 2]),
+ ])
+ );
+
+ $result = $this->createProvider()->getData(
+ $this->createDefinition(),
+ DatatableRequest::create(advancedFilterExpression: $expression),
+ );
+
+ self::assertSame(2, $result->getFilteredItems());
+ }
+
+ public function test_it_applies_joined_field_condition(): void
+ {
+ $this->bootDoctrineAndLoadFixtures();
+
+ $expression = new AdvancedFilterExpression(
+ new Group(LogicOperator::And, [
+ new Condition('organization.name', ComparisonOperator::Equals, 'Acme Corp'),
+ ])
+ );
+
+ $result = $this->createProvider()->getData(
+ $this->createDefinition(),
+ DatatableRequest::create(advancedFilterExpression: $expression),
+ );
+
+ self::assertSame(3, $result->getFilteredItems());
+ }
+
+ #[After]
+ protected function cleanupDoctrine(): void
+ {
+ if (!$this->entityManager instanceof EntityManagerInterface) {
+ return;
+ }
+
+ $entityManager = $this->entityManager;
+
+ $this->dropSchema();
+ $entityManager->close();
+ $this->entityManager = null;
+ }
+
+ protected static function getKernelClass(): string
+ {
+ return TestKernel::class;
+ }
+
+ private function createProvider(): DoctrineOrmDataProvider
+ {
+ $managerRegistry = self::getContainer()->get('doctrine');
+
+ self::assertInstanceOf(\Doctrine\Persistence\ManagerRegistry::class, $managerRegistry);
+
+ return new DoctrineOrmDataProvider($managerRegistry);
+ }
+
+ private function createDefinition(): DatatableDefinition
+ {
+ $definition = new DatatableDefinition('doctrine-users');
+
+ $definition
+ ->setEntityClass(DoctrineUser::class)
+ ->addColumn('e.id', label: 'ID')
+ ->addColumn('e.email', label: 'Email')
+ ->addColumn('e.enabled', label: 'Enabled')
+ ->addColumn('e.displayName', label: 'Display Name')
+ ->addJoin('organization', 'e.organization')
+ ->addColumn('organization.name', label: 'Organization')
+ ;
+
+ return $definition;
+ }
+
+ private function bootDoctrineAndLoadFixtures(): void
+ {
+ self::bootKernel();
+
+ $entityManager = self::getContainer()->get(EntityManagerInterface::class);
+
+ self::assertInstanceOf(EntityManagerInterface::class, $entityManager);
+
+ $this->entityManager = $entityManager;
+ $this->recreateSchema();
+
+ $organization = new DoctrineOrganization('Acme Corp', true);
+ $entityManager->persist($organization);
+
+ $entityManager->persist(new DoctrineUser(
+ email: 'alice@example.test',
+ displayName: 'Alice',
+ enabled: true,
+ organization: $organization,
+ ));
+ $entityManager->persist(new DoctrineUser(
+ email: 'bob@example.test',
+ displayName: 'Bob',
+ enabled: false,
+ organization: $organization,
+ ));
+ $entityManager->persist(new DoctrineUser(
+ email: 'charlie@example.test',
+ displayName: 'Charlie',
+ enabled: true,
+ organization: $organization,
+ ));
+
+ $entityManager->flush();
+ $entityManager->clear();
+ }
+
+ private function recreateSchema(): void
+ {
+ $this->dropSchema();
+
+ $schemaTool = $this->createSchemaTool();
+ $schemaTool->createSchema($this->getMetadata());
+ }
+
+ private function dropSchema(): void
+ {
+ if (!$this->entityManager instanceof EntityManagerInterface) {
+ return;
+ }
+
+ $schemaTool = $this->createSchemaTool();
+
+ try {
+ $schemaTool->dropSchema($this->getMetadata());
+ } catch (\Throwable) {
+ // The schema may not exist yet.
+ }
+ }
+
+ private function createSchemaTool(): SchemaTool
+ {
+ return new SchemaTool($this->getStoredEntityManager());
+ }
+
+ private function getStoredEntityManager(): EntityManagerInterface
+ {
+ if (!$this->entityManager instanceof EntityManagerInterface) {
+ throw new \LogicException('The entity manager is not initialized.');
+ }
+
+ return $this->entityManager;
+ }
+}
diff --git a/tests/Functional/Export/AdvancedFilterExportFunctionalTest.php b/tests/Functional/Export/AdvancedFilterExportFunctionalTest.php
new file mode 100644
index 0000000..a550798
--- /dev/null
+++ b/tests/Functional/Export/AdvancedFilterExportFunctionalTest.php
@@ -0,0 +1,247 @@
+bootDoctrineAndLoadFixtures();
+
+ // Advanced filter for Alice only
+ $advancedFilters = [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'field' => 'email',
+ 'operator' => 'eq',
+ 'value' => 'alice@example.test',
+ ],
+ ],
+ ];
+
+ $request = new Request(query: [
+ 'advancedFilters' => $advancedFilters,
+ ]);
+
+ $controller = self::getContainer()->get(DatatableController::class);
+ self::assertInstanceOf(DatatableController::class, $controller);
+
+ $response = $controller->export($request, 'doctrine-users', 'csv');
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('text/csv; charset=UTF-8', $response->headers->get('Content-Type'));
+
+ $content = (string) $response->getContent();
+ self::assertStringContainsString('alice@example.test', $content);
+ self::assertStringNotContainsString('bob@example.test', $content);
+ self::assertStringNotContainsString('charlie@example.test', $content);
+ }
+
+ public function test_xlsx_export_respects_advanced_filters(): void
+ {
+ $this->bootDoctrineAndLoadFixtures();
+
+ // Advanced filter for enabled users only (Alice and Charlie)
+ $advancedFilters = [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'field' => 'enabled',
+ 'operator' => 'eq',
+ 'value' => true,
+ ],
+ ],
+ ];
+
+ $request = new Request(query: [
+ 'advancedFilters' => $advancedFilters,
+ ]);
+
+ $controller = self::getContainer()->get(DatatableController::class);
+ self::assertInstanceOf(DatatableController::class, $controller);
+
+ $response = $controller->export($request, 'doctrine-users', 'xlsx');
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
+
+ self::assertNotEmpty($response->getContent());
+ }
+
+ public function test_array_export_respects_advanced_filters(): void
+ {
+ self::bootKernel();
+
+ $advancedFilters = [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'field' => 'email',
+ 'operator' => 'eq',
+ 'value' => 'alice@example.test',
+ ],
+ ],
+ ];
+
+ $request = new Request(query: [
+ 'advancedFilters' => $advancedFilters,
+ ]);
+
+ $controller = self::getContainer()->get(DatatableController::class);
+ self::assertInstanceOf(DatatableController::class, $controller);
+
+ $response = $controller->export($request, 'array-users', 'csv');
+
+ self::assertSame(200, $response->getStatusCode());
+
+ $content = (string) $response->getContent();
+ self::assertStringContainsString('alice@example.test', $content);
+ self::assertStringNotContainsString('bob@example.test', $content);
+ }
+
+ public function test_full_mode_export_respects_advanced_filters(): void
+ {
+ $this->bootDoctrineAndLoadFixtures();
+
+ // Advanced filter for Bob only
+ $advancedFilters = [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'field' => 'email',
+ 'operator' => 'eq',
+ 'value' => 'bob@example.test',
+ ],
+ ],
+ ];
+
+ $request = new Request(query: [
+ 'advancedFilters' => $advancedFilters,
+ 'mode' => 'full',
+ ]);
+
+ $controller = self::getContainer()->get(DatatableController::class);
+ self::assertInstanceOf(DatatableController::class, $controller);
+
+ $response = $controller->export($request, 'doctrine-users', 'csv');
+
+ self::assertSame(200, $response->getStatusCode());
+
+ $content = (string) $response->getContent();
+ self::assertStringContainsString('bob@example.test', $content);
+ self::assertStringNotContainsString('alice@example.test', $content);
+ self::assertStringNotContainsString('charlie@example.test', $content);
+ }
+
+ #[After]
+ protected function cleanupDoctrine(): void
+ {
+ $entityManager = $this->entityManager;
+
+ if (!$entityManager instanceof EntityManagerInterface) {
+ return;
+ }
+
+ $this->dropSchema();
+ $entityManager->close();
+ $this->entityManager = null;
+ }
+
+ protected static function getKernelClass(): string
+ {
+ return TestKernel::class;
+ }
+
+ private function bootDoctrineAndLoadFixtures(): void
+ {
+ self::bootKernel();
+ $entityManager = self::getContainer()->get(EntityManagerInterface::class);
+
+ self::assertInstanceOf(EntityManagerInterface::class, $entityManager);
+
+ $this->entityManager = $entityManager;
+ $this->recreateSchema();
+
+ $organization = new DoctrineOrganization('Acme Corp', true);
+ $entityManager->persist($organization);
+
+ $entityManager->persist(new DoctrineUser(
+ email: 'alice@example.test',
+ displayName: 'Alice',
+ enabled: true,
+ organization: $organization,
+ ));
+ $entityManager->persist(new DoctrineUser(
+ email: 'bob@example.test',
+ displayName: 'Bob',
+ enabled: false,
+ organization: $organization,
+ ));
+ $entityManager->persist(new DoctrineUser(
+ email: 'charlie@example.test',
+ displayName: 'Charlie',
+ enabled: true,
+ organization: $organization,
+ ));
+
+ $entityManager->flush();
+ $entityManager->clear();
+ }
+
+ private function recreateSchema(): void
+ {
+ $this->dropSchema();
+
+ $entityManager = $this->getStoredEntityManager();
+ $schemaTool = new SchemaTool($entityManager);
+ $schemaTool->createSchema($this->getMetadata());
+ }
+
+ private function dropSchema(): void
+ {
+ $entityManager = $this->entityManager;
+
+ if (!$entityManager instanceof EntityManagerInterface) {
+ return;
+ }
+
+ $schemaTool = new SchemaTool($entityManager);
+
+ try {
+ $schemaTool->dropSchema($this->getMetadata());
+ } catch (\Throwable) {
+ // The schema may not exist yet.
+ }
+ }
+
+ private function getStoredEntityManager(): EntityManagerInterface
+ {
+ if (!$this->entityManager instanceof EntityManagerInterface) {
+ throw new \LogicException('The entity manager is not initialized.');
+ }
+
+ return $this->entityManager;
+ }
+}
diff --git a/tests/Functional/Fixtures/Datatable/ArrayUserDatatable.php b/tests/Functional/Fixtures/Datatable/ArrayUserDatatable.php
new file mode 100644
index 0000000..61d61fa
--- /dev/null
+++ b/tests/Functional/Fixtures/Datatable/ArrayUserDatatable.php
@@ -0,0 +1,29 @@
+setOption(ArrayDataProvider::OPTION_PROVIDER, ArrayDataProvider::PROVIDER_NAME)
+ ->setOption(ArrayDataProvider::OPTION_ROWS, [
+ ['email' => 'alice@example.test', 'enabled' => true],
+ ['email' => 'bob@example.test', 'enabled' => false],
+ ])
+ ->addColumn('email', label: 'Email')
+ ->addColumn('enabled', label: 'Enabled')
+ ->addAdvancedFilterField('email', 'email')
+ ->addAdvancedFilterField('enabled', 'enabled')
+ ;
+ }
+}
diff --git a/tests/Functional/Fixtures/Datatable/DoctrineUserDatatable.php b/tests/Functional/Fixtures/Datatable/DoctrineUserDatatable.php
new file mode 100644
index 0000000..950ca23
--- /dev/null
+++ b/tests/Functional/Fixtures/Datatable/DoctrineUserDatatable.php
@@ -0,0 +1,31 @@
+setEntityClass(DoctrineUser::class)
+ ->addColumn('e.id', label: 'ID')
+ ->addColumn('e.email', label: 'Email')
+ ->addColumn('e.enabled', label: 'Enabled')
+ ->addColumn('e.displayName', label: 'Display Name')
+ ->addJoin('organization', 'e.organization')
+ ->addColumn('organization.name', label: 'Organization')
+ ->addAdvancedFilterField('email', 'e.email')
+ ->addAdvancedFilterField('enabled', 'e.enabled')
+ ->addAdvancedFilterField('displayName', 'e.displayName')
+ ->addAdvancedFilterField('organization.name', 'organization.name')
+ ;
+ }
+}
diff --git a/tests/Functional/IconResolverFunctionalTest.php b/tests/Functional/IconResolverFunctionalTest.php
new file mode 100644
index 0000000..7f20a42
--- /dev/null
+++ b/tests/Functional/IconResolverFunctionalTest.php
@@ -0,0 +1,44 @@
+getContainer();
+
+ self::assertTrue($container->has('test.'.IconResolver::class));
+ self::assertTrue($container->has('test.'.IconResolverInterface::class));
+
+ $resolver = $container->get('test.'.IconResolver::class);
+ self::assertInstanceOf(IconResolver::class, $resolver);
+ }
+
+ public function test_icon_resolver_uses_default_icons(): void
+ {
+ self::bootKernel();
+ $container = self::getContainer();
+
+ /** @var IconResolverInterface $resolver */
+ $resolver = $container->get('test.'.IconResolverInterface::class);
+
+ self::assertSame('bi bi-eye', $resolver->resolve('view'));
+ }
+
+ protected static function getKernelClass(): string
+ {
+ return TestKernel::class;
+ }
+}
diff --git a/tests/Functional/Kernel/TestKernel.php b/tests/Functional/Kernel/TestKernel.php
index 9d4a3c1..2ff0e7d 100644
--- a/tests/Functional/Kernel/TestKernel.php
+++ b/tests/Functional/Kernel/TestKernel.php
@@ -12,9 +12,11 @@
use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
use Zhortein\DatatableBundle\Action\ActionVisibilityCheckerInterface;
+use Zhortein\DatatableBundle\Contract\IconResolverInterface;
use Zhortein\DatatableBundle\Doctrine\DoctrineDatatableDefinitionEnricher;
use Zhortein\DatatableBundle\Doctrine\DoctrineFieldTypeGuesser;
use Zhortein\DatatableBundle\Export\ExportWriterRegistry;
+use Zhortein\DatatableBundle\Icon\IconResolver;
use Zhortein\DatatableBundle\Preference\DatatablePreferenceProviderInterface;
use Zhortein\DatatableBundle\Provider\DataProviderRegistry;
use Zhortein\DatatableBundle\Tests\Functional\Fixtures\Entity\DoctrineUser;
@@ -125,6 +127,16 @@ protected function configureContainer(ContainerConfigurator $container): void
->alias('test.'.ActionVisibilityCheckerInterface::class, ActionVisibilityCheckerInterface::class)
->public()
;
+
+ $services
+ ->alias('test.'.IconResolver::class, IconResolver::class)
+ ->public()
+ ;
+
+ $services
+ ->alias('test.'.IconResolverInterface::class, IconResolverInterface::class)
+ ->public()
+ ;
}
protected function configureRoutes(RoutingConfigurator $routes): void
diff --git a/tests/Unit/Action/AllowAllActionVisibilityCheckerTest.php b/tests/Unit/Action/AllowAllActionVisibilityCheckerTest.php
index 36407ff..264a2c9 100644
--- a/tests/Unit/Action/AllowAllActionVisibilityCheckerTest.php
+++ b/tests/Unit/Action/AllowAllActionVisibilityCheckerTest.php
@@ -8,6 +8,7 @@
use Zhortein\DatatableBundle\Action\ActionVisibilityContext;
use Zhortein\DatatableBundle\Action\AllowAllActionVisibilityChecker;
use Zhortein\DatatableBundle\Definition\ActionDefinition;
+use Zhortein\DatatableBundle\Definition\BulkActionDefinition;
use Zhortein\DatatableBundle\Definition\DatatableDefinition;
final class AllowAllActionVisibilityCheckerTest extends TestCase
@@ -36,4 +37,16 @@ public function test_it_allows_global_actions(): void
),
));
}
+
+ public function test_it_allows_bulk_actions(): void
+ {
+ $checker = new AllowAllActionVisibilityChecker();
+
+ self::assertTrue($checker->isVisible(
+ new BulkActionDefinition(name: 'delete', route: 'app_user_bulk_delete'),
+ new ActionVisibilityContext(
+ definition: new DatatableDefinition('users'),
+ ),
+ ));
+ }
}
diff --git a/tests/Unit/Action/AuthorizationActionVisibilityCheckerTest.php b/tests/Unit/Action/AuthorizationActionVisibilityCheckerTest.php
index 10772dd..216307d 100644
--- a/tests/Unit/Action/AuthorizationActionVisibilityCheckerTest.php
+++ b/tests/Unit/Action/AuthorizationActionVisibilityCheckerTest.php
@@ -11,6 +11,7 @@
use Zhortein\DatatableBundle\Action\AllowAllActionVisibilityChecker;
use Zhortein\DatatableBundle\Action\AuthorizationActionVisibilityChecker;
use Zhortein\DatatableBundle\Definition\ActionDefinition;
+use Zhortein\DatatableBundle\Definition\BulkActionDefinition;
use Zhortein\DatatableBundle\Definition\DatatableDefinition;
final class AuthorizationActionVisibilityCheckerTest extends TestCase
@@ -95,6 +96,32 @@ public function test_it_uses_definition_as_subject_for_global_actions(): void
self::assertSame($definition, $authorizationChecker->getLastSubject());
}
+ public function test_it_uses_definition_as_subject_for_bulk_actions(): void
+ {
+ $definition = new DatatableDefinition('users');
+
+ $authorizationChecker = new RecordingAuthorizationChecker([
+ 'USER_BULK_DELETE' => true,
+ ]);
+
+ $checker = new AuthorizationActionVisibilityChecker($authorizationChecker);
+
+ $action = new BulkActionDefinition(
+ name: 'bulk_delete',
+ route: 'app_user_bulk_delete',
+ attributes: [
+ 'permission' => 'USER_BULK_DELETE',
+ ],
+ );
+
+ $context = new ActionVisibilityContext(definition: $definition);
+
+ self::assertTrue($checker->isVisible($action, $context));
+ self::assertSame(1, $authorizationChecker->getCallCount());
+ self::assertSame('USER_BULK_DELETE', $authorizationChecker->getLastAttribute());
+ self::assertSame($definition, $authorizationChecker->getLastSubject());
+ }
+
public function test_it_uses_fallback_checker_when_no_permission_attribute_is_defined(): void
{
$authorizationChecker = new RecordingAuthorizationChecker();
diff --git a/tests/Unit/Controller/DatatableControllerTest.php b/tests/Unit/Controller/DatatableControllerTest.php
index 02430ce..479cd46 100644
--- a/tests/Unit/Controller/DatatableControllerTest.php
+++ b/tests/Unit/Controller/DatatableControllerTest.php
@@ -16,6 +16,7 @@
use Zhortein\DatatableBundle\Definition\DatatableDefinition;
use Zhortein\DatatableBundle\Export\CsvExportWriter;
use Zhortein\DatatableBundle\Export\ExportWriterRegistry;
+use Zhortein\DatatableBundle\Factory\AdvancedFilterExpressionFactory;
use Zhortein\DatatableBundle\Factory\DatatableDefinitionFactory;
use Zhortein\DatatableBundle\Factory\DatatableRequestFactory;
use Zhortein\DatatableBundle\Provider\ArrayDataProvider;
@@ -185,7 +186,7 @@ private function createController(): DatatableController
{
return new DatatableController(
definitionFactory: new DatatableDefinitionFactory($this->createDatatableRegistry()),
- requestFactory: new DatatableRequestFactory(),
+ requestFactory: new DatatableRequestFactory(new AdvancedFilterExpressionFactory()),
providerRegistry: new DataProviderRegistry([
ArrayDataProvider::PROVIDER_NAME => new ArrayDataProvider(),
]),
diff --git a/tests/Unit/Definition/BulkActionDefinitionTest.php b/tests/Unit/Definition/BulkActionDefinitionTest.php
new file mode 100644
index 0000000..88fb326
--- /dev/null
+++ b/tests/Unit/Definition/BulkActionDefinitionTest.php
@@ -0,0 +1,53 @@
+ 'bar'],
+ attributes: ['data-test' => 'bulk-delete'],
+ selectedRowsParameterName: 'user_ids',
+ );
+
+ self::assertSame('delete', $action->getName());
+ self::assertSame('app_user_bulk_delete', $action->getRoute());
+ self::assertSame('Delete selected', $action->getLabel());
+ self::assertSame('trash', $action->getIcon());
+ self::assertSame(ActionIconPosition::After, $action->getIconPosition());
+ self::assertSame('POST', $action->getHttpMethod());
+ self::assertSame('Are you sure you want to delete selected users?', $action->getConfirmationMessage());
+ self::assertSame('btn btn-sm btn-danger', $action->getClassName());
+ self::assertSame(['foo' => 'bar'], $action->getRouteParameters());
+ self::assertSame(['data-test' => 'bulk-delete'], $action->getAttributes());
+ self::assertSame('user_ids', $action->getSelectedRowsParameterName());
+ }
+
+ public function test_it_has_default_values(): void
+ {
+ $action = new BulkActionDefinition(
+ name: 'delete',
+ route: 'app_user_bulk_delete',
+ );
+
+ self::assertSame('POST', $action->getHttpMethod());
+ self::assertSame(ActionIconPosition::Before, $action->getIconPosition());
+ self::assertSame('ids', $action->getSelectedRowsParameterName());
+ }
+}
diff --git a/tests/Unit/Definition/DatatableDefinitionAdvancedFiltersTest.php b/tests/Unit/Definition/DatatableDefinitionAdvancedFiltersTest.php
new file mode 100644
index 0000000..6d1eafb
--- /dev/null
+++ b/tests/Unit/Definition/DatatableDefinitionAdvancedFiltersTest.php
@@ -0,0 +1,201 @@
+addAdvancedFilterField(
+ name: 'email',
+ field: 'e.email',
+ label: 'Email',
+ type: FilterType::Text,
+ allowedOperators: [FilterOperator::Equals, FilterOperator::Like]
+ )
+ ->addAdvancedFilterField(
+ name: 'status',
+ field: 'e.status',
+ label: 'Status',
+ type: FilterType::Choice,
+ choices: ['active' => 'Active', 'inactive' => 'Inactive']
+ )
+ ;
+
+ $fields = $definition->getAdvancedFilterFields();
+
+ self::assertCount(2, $fields);
+ self::assertArrayHasKey('email', $fields);
+ self::assertArrayHasKey('status', $fields);
+
+ self::assertSame('email', $fields['email']->getName());
+ self::assertSame('e.email', $fields['email']->getField());
+ self::assertSame('Email', $fields['email']->getLabel());
+ self::assertSame(FilterType::Text, $fields['email']->getType());
+ self::assertSame(
+ [
+ ComparisonOperator::Equals,
+ ComparisonOperator::Contains,
+ ComparisonOperator::StartsWith,
+ ComparisonOperator::EndsWith,
+ ],
+ $fields['email']->getAllowedOperators(),
+ );
+
+ self::assertSame('status', $fields['status']->getName());
+ self::assertSame('e.status', $fields['status']->getField());
+ self::assertSame('Status', $fields['status']->getLabel());
+ self::assertSame(FilterType::Choice, $fields['status']->getType());
+ self::assertSame(['active' => 'Active', 'inactive' => 'Inactive'], $fields['status']->getChoices());
+ }
+
+ public function test_it_has_sensible_defaults(): void
+ {
+ $definition = new DatatableDefinition('users');
+
+ $definition->addAdvancedFilterField('id', 'e.id');
+
+ $fields = $definition->getAdvancedFilterFields();
+ $field = $fields['id'];
+
+ self::assertSame(FilterType::Text, $field->getType());
+ self::assertSame([], $field->getAllowedOperators());
+ self::assertSame([], $field->getChoices());
+ self::assertNull($field->getLabel());
+ }
+
+ public function test_it_accepts_comparison_operators_in_allowed_operators(): void
+ {
+ $definition = new DatatableDefinition('users');
+
+ $definition->addAdvancedFilterField(
+ name: 'email',
+ field: 'e.email',
+ type: FilterType::Text,
+ allowedOperators: [
+ ComparisonOperator::Contains,
+ ComparisonOperator::StartsWith,
+ ],
+ );
+
+ $field = $definition->getAdvancedFilterFields()['email'];
+
+ self::assertSame(
+ [ComparisonOperator::Contains, ComparisonOperator::StartsWith],
+ $field->getAllowedOperators(),
+ );
+ self::assertSame(['contains', 'starts_with'], $field->getEffectiveOperatorValues());
+ }
+
+ public function test_it_accepts_legacy_filter_operators_in_allowed_operators(): void
+ {
+ $definition = new DatatableDefinition('users');
+
+ $definition->addAdvancedFilterField(
+ name: 'enabled',
+ field: 'e.enabled',
+ type: FilterType::Boolean,
+ allowedOperators: [
+ FilterOperator::Equals,
+ FilterOperator::NotEquals,
+ ],
+ );
+
+ $field = $definition->getAdvancedFilterFields()['enabled'];
+
+ self::assertSame(
+ [ComparisonOperator::Equals, ComparisonOperator::NotEquals],
+ $field->getAllowedOperators(),
+ );
+ self::assertSame(['eq', 'neq'], $field->getEffectiveOperatorValues());
+ }
+
+ public function test_it_accepts_mixed_operator_types(): void
+ {
+ $definition = new DatatableDefinition('users');
+
+ $definition->addAdvancedFilterField(
+ name: 'email',
+ field: 'e.email',
+ type: FilterType::Text,
+ allowedOperators: [
+ FilterOperator::Equals,
+ ComparisonOperator::Contains,
+ ],
+ );
+
+ $field = $definition->getAdvancedFilterFields()['email'];
+
+ self::assertSame(
+ [ComparisonOperator::Equals, ComparisonOperator::Contains],
+ $field->getAllowedOperators(),
+ );
+ }
+
+ public function test_effective_operators_drop_incompatible_developer_allowed_operators(): void
+ {
+ $definition = new DatatableDefinition('users');
+
+ // Developer mistakenly allowed Contains for a Boolean field.
+ $definition->addAdvancedFilterField(
+ name: 'enabled',
+ field: 'e.enabled',
+ type: FilterType::Boolean,
+ allowedOperators: [
+ ComparisonOperator::Equals,
+ ComparisonOperator::Contains,
+ ],
+ nullable: false,
+ );
+
+ $field = $definition->getAdvancedFilterFields()['enabled'];
+
+ self::assertSame(['eq'], $field->getEffectiveOperatorValues());
+ self::assertNotContains('contains', $field->getEffectiveOperatorValues());
+ }
+
+ public function test_nullable_field_exposes_null_operators(): void
+ {
+ $definition = new DatatableDefinition('users');
+
+ $definition->addAdvancedFilterField(
+ name: 'email',
+ field: 'e.email',
+ type: FilterType::Text,
+ nullable: true,
+ );
+
+ $effectiveOperators = $definition->getAdvancedFilterFields()['email']->getEffectiveOperatorValues();
+
+ self::assertContains('is_null', $effectiveOperators);
+ self::assertContains('is_not_null', $effectiveOperators);
+ }
+
+ public function test_non_nullable_field_does_not_expose_null_operators(): void
+ {
+ $definition = new DatatableDefinition('users');
+
+ $definition->addAdvancedFilterField(
+ name: 'email',
+ field: 'e.email',
+ type: FilterType::Text,
+ nullable: false,
+ );
+
+ $effectiveOperators = $definition->getAdvancedFilterFields()['email']->getEffectiveOperatorValues();
+
+ self::assertNotContains('is_null', $effectiveOperators);
+ self::assertNotContains('is_not_null', $effectiveOperators);
+ }
+}
diff --git a/tests/Unit/Definition/DatatableDefinitionBulkActionTest.php b/tests/Unit/Definition/DatatableDefinitionBulkActionTest.php
new file mode 100644
index 0000000..2743269
--- /dev/null
+++ b/tests/Unit/Definition/DatatableDefinitionBulkActionTest.php
@@ -0,0 +1,33 @@
+addBulkAction('delete', route: 'app_user_bulk_delete', label: 'Delete selected')
+ ->addBulkAction('activate', route: 'app_user_bulk_activate', label: 'Activate selected', selectedRowsParameterName: 'uids')
+ ;
+
+ $bulkActions = $definition->getBulkActions();
+
+ self::assertCount(2, $bulkActions);
+ self::assertArrayHasKey('delete', $bulkActions);
+ self::assertArrayHasKey('activate', $bulkActions);
+
+ self::assertSame('app_user_bulk_delete', $bulkActions['delete']->getRoute());
+ self::assertSame('ids', $bulkActions['delete']->getSelectedRowsParameterName());
+
+ self::assertSame('app_user_bulk_activate', $bulkActions['activate']->getRoute());
+ self::assertSame('uids', $bulkActions['activate']->getSelectedRowsParameterName());
+ }
+}
diff --git a/tests/Unit/DependencyInjection/BundleConfigurationTest.php b/tests/Unit/DependencyInjection/BundleConfigurationTest.php
index 0afbf3f..e5a9dc7 100644
--- a/tests/Unit/DependencyInjection/BundleConfigurationTest.php
+++ b/tests/Unit/DependencyInjection/BundleConfigurationTest.php
@@ -20,6 +20,22 @@ public function test_it_registers_default_configuration_parameters(): void
self::assertSame(25, $container->getParameter('zhortein_datatable.default_page_size'));
self::assertSame(500, $container->getParameter('zhortein_datatable.max_page_size'));
self::assertFalse($container->getParameter('zhortein_datatable.search_enabled'));
+ self::assertSame([], $container->getParameter('zhortein_datatable.icons'));
+ }
+
+ public function test_it_accepts_custom_icons(): void
+ {
+ $container = $this->loadBundleConfiguration([
+ 'icons' => [
+ 'view' => 'fa fa-eye',
+ 'custom' => 'fa fa-star',
+ ],
+ ]);
+
+ self::assertSame([
+ 'view' => 'fa fa-eye',
+ 'custom' => 'fa fa-star',
+ ], $container->getParameter('zhortein_datatable.icons'));
}
public function test_it_accepts_custom_configuration_values(): void
diff --git a/tests/Unit/Factory/AdvancedFilterExpressionFactoryTest.php b/tests/Unit/Factory/AdvancedFilterExpressionFactoryTest.php
new file mode 100644
index 0000000..189b49f
--- /dev/null
+++ b/tests/Unit/Factory/AdvancedFilterExpressionFactoryTest.php
@@ -0,0 +1,384 @@
+factory = new AdvancedFilterExpressionFactory();
+ }
+
+ public function test_it_returns_null_for_empty_payload(): void
+ {
+ self::assertNull($this->factory->createFromArray([]));
+ }
+
+ public function test_it_parses_simple_condition(): void
+ {
+ $payload = [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'field' => 'name',
+ 'operator' => 'eq',
+ 'value' => 'John',
+ ],
+ ],
+ ];
+
+ $expression = $this->factory->createFromArray($payload);
+
+ self::assertNotNull($expression);
+ self::assertSame(LogicOperator::And, $expression->root->logic);
+ self::assertCount(1, $expression->root->children);
+
+ /** @var Condition $condition */
+ $condition = $expression->root->children[0];
+ self::assertSame('name', $condition->field);
+ self::assertSame(ComparisonOperator::Equals, $condition->operator);
+ self::assertSame('John', $condition->value);
+ }
+
+ public function test_it_parses_nested_groups(): void
+ {
+ $payload = [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'field' => 'name',
+ 'operator' => 'contains',
+ 'value' => 'John',
+ ],
+ [
+ 'logic' => 'OR',
+ 'children' => [
+ [
+ 'field' => 'age',
+ 'operator' => 'gt',
+ 'value' => 20,
+ ],
+ [
+ 'field' => 'age',
+ 'operator' => 'lt',
+ 'value' => 10,
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ $expression = $this->factory->createFromArray($payload);
+
+ self::assertNotNull($expression);
+ self::assertCount(2, $expression->root->children);
+ self::assertInstanceOf(Group::class, $expression->root->children[1]);
+ self::assertSame(LogicOperator::Or, $expression->root->children[1]->logic);
+ self::assertCount(2, $expression->root->children[1]->children);
+ }
+
+ public function test_it_validates_against_definition(): void
+ {
+ $definition = new DatatableDefinition('test');
+ $definition->addAdvancedFilterField('name', 'e.name', 'Name', FilterType::Text, [FilterOperator::Equals]);
+
+ $payload = [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'field' => 'name',
+ 'operator' => 'eq',
+ 'value' => 'John',
+ ],
+ [
+ 'field' => 'name',
+ 'operator' => 'gt', // Not allowed for 'name'
+ 'value' => 'John',
+ ],
+ [
+ 'field' => 'email', // Not defined
+ 'operator' => 'eq',
+ 'value' => 'john@example.com',
+ ],
+ ],
+ ];
+
+ $expression = $this->factory->createFromArray($payload, $definition);
+
+ self::assertNotNull($expression);
+ // Only the first condition should be present
+ self::assertCount(1, $expression->root->children);
+
+ /** @var Condition $condition */
+ $condition = $expression->root->children[0];
+ self::assertSame('name', $condition->field);
+ }
+
+ public function test_it_rejects_operators_incompatible_with_field_type(): void
+ {
+ $definition = new DatatableDefinition('test');
+ $definition->addAdvancedFilterField('name', 'e.name', 'Name', FilterType::Text);
+ $definition->addAdvancedFilterField('age', 'e.age', 'Age', FilterType::Number);
+
+ $payload = [
+ 'logic' => 'AND',
+ 'children' => [
+ ['field' => 'name', 'operator' => 'gt', 'value' => 'John'],
+ ['field' => 'age', 'operator' => 'contains', 'value' => '5'],
+ ['field' => 'age', 'operator' => 'gt', 'value' => 18],
+ ],
+ ];
+
+ $expression = $this->factory->createFromArray($payload, $definition);
+
+ self::assertNotNull($expression);
+ self::assertCount(1, $expression->root->children);
+
+ /** @var Condition $condition */
+ $condition = $expression->root->children[0];
+ self::assertSame('age', $condition->field);
+ self::assertSame(ComparisonOperator::GreaterThan, $condition->operator);
+ }
+
+ public function test_it_normalizes_between_with_named_keys(): void
+ {
+ $definition = new DatatableDefinition('test');
+ $definition->addAdvancedFilterField('age', 'e.age', 'Age', FilterType::Number);
+
+ $payload = [
+ 'logic' => 'AND',
+ 'children' => [
+ ['field' => 'age', 'operator' => 'between', 'value' => ['from' => 18, 'to' => 65]],
+ ],
+ ];
+
+ $expression = $this->factory->createFromArray($payload, $definition);
+
+ self::assertNotNull($expression);
+ /** @var Condition $condition */
+ $condition = $expression->root->children[0];
+ self::assertSame([18, 65], $condition->value);
+ }
+
+ public function test_it_supports_grouped_expressions(): void
+ {
+ $payload = [
+ 'logic' => 'OR',
+ 'children' => [
+ [
+ 'logic' => 'AND',
+ 'children' => [
+ ['field' => 'a', 'operator' => 'eq', 'value' => 'x'],
+ ['field' => 'b', 'operator' => 'contains', 'value' => 'y'],
+ ],
+ ],
+ ['field' => 'c', 'operator' => 'eq', 'value' => 1],
+ ],
+ ];
+
+ $expression = $this->factory->createFromArray($payload);
+
+ self::assertNotNull($expression);
+ self::assertSame(LogicOperator::Or, $expression->root->logic);
+ self::assertCount(2, $expression->root->children);
+ self::assertInstanceOf(Group::class, $expression->root->children[0]);
+ self::assertSame(LogicOperator::And, $expression->root->children[0]->logic);
+ }
+
+ public function test_it_supports_enum_filter_field(): void
+ {
+ $definition = new DatatableDefinition('test');
+ $definition->addAdvancedFilterField(
+ name: 'status',
+ field: 'e.status',
+ type: FilterType::Enum,
+ enumClass: StatusEnumFixture::class,
+ nullable: true,
+ );
+
+ $payload = [
+ 'logic' => 'AND',
+ 'children' => [
+ ['field' => 'status', 'operator' => 'eq', 'value' => 'active'],
+ ['field' => 'status', 'operator' => 'in', 'value' => ['active', 'pending']],
+ ['field' => 'status', 'operator' => 'contains', 'value' => 'active'],
+ ['field' => 'status', 'operator' => 'is_null'],
+ ],
+ ];
+
+ $expression = $this->factory->createFromArray($payload, $definition);
+
+ self::assertNotNull($expression);
+ self::assertCount(3, $expression->root->children);
+
+ $advancedField = $definition->getAdvancedFilterFields()['status'];
+ self::assertSame(StatusEnumFixture::class, $advancedField->getEnumClass());
+ self::assertSame(['Active' => 'active', 'Pending' => 'pending', 'Inactive' => 'inactive'], $advancedField->getChoices());
+ self::assertContains('eq', $advancedField->getEffectiveOperatorValues());
+ self::assertContains('in', $advancedField->getEffectiveOperatorValues());
+ self::assertContains('is_null', $advancedField->getEffectiveOperatorValues());
+ self::assertNotContains('contains', $advancedField->getEffectiveOperatorValues());
+ }
+
+ public function test_it_accepts_spec_payload_using_conditions_key_and_lowercase_logic(): void
+ {
+ $payload = [
+ 'logic' => 'and',
+ 'conditions' => [
+ [
+ 'field' => 'email',
+ 'operator' => 'contains',
+ 'value' => 'alice',
+ ],
+ [
+ 'logic' => 'or',
+ 'conditions' => [
+ [
+ 'field' => 'enabled',
+ 'operator' => 'eq',
+ 'value' => true,
+ ],
+ [
+ 'field' => 'status',
+ 'operator' => 'eq',
+ 'value' => 'admin',
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ $expression = $this->factory->createFromArray($payload);
+
+ self::assertNotNull($expression);
+ self::assertSame(LogicOperator::And, $expression->root->logic);
+ self::assertCount(2, $expression->root->children);
+
+ /** @var Condition $first */
+ $first = $expression->root->children[0];
+ self::assertSame('email', $first->field);
+ self::assertSame(ComparisonOperator::Contains, $first->operator);
+
+ self::assertInstanceOf(Group::class, $expression->root->children[1]);
+ self::assertSame(LogicOperator::Or, $expression->root->children[1]->logic);
+ self::assertCount(2, $expression->root->children[1]->children);
+ }
+
+ public function test_it_rejects_incompatible_submitted_operator_for_field_type(): void
+ {
+ $definition = new DatatableDefinition('test');
+ $definition->addAdvancedFilterField('enabled', 'e.enabled', type: FilterType::Boolean);
+
+ $payload = [
+ 'logic' => 'and',
+ 'conditions' => [
+ // contains is not compatible with boolean
+ ['field' => 'enabled', 'operator' => 'contains', 'value' => 'yes'],
+ ],
+ ];
+
+ self::assertNull($this->factory->createFromArray($payload, $definition));
+ }
+
+ public function test_it_rejects_developer_disallowed_operator(): void
+ {
+ $definition = new DatatableDefinition('test');
+ $definition->addAdvancedFilterField(
+ 'email',
+ 'e.email',
+ type: FilterType::Text,
+ allowedOperators: [ComparisonOperator::Contains],
+ );
+
+ $payload = [
+ 'logic' => 'and',
+ 'conditions' => [
+ ['field' => 'email', 'operator' => 'eq', 'value' => 'foo@bar.test'],
+ ['field' => 'email', 'operator' => 'contains', 'value' => 'foo'],
+ ],
+ ];
+
+ $expression = $this->factory->createFromArray($payload, $definition);
+
+ self::assertNotNull($expression);
+ self::assertCount(1, $expression->root->children);
+
+ /** @var Condition $remaining */
+ $remaining = $expression->root->children[0];
+ self::assertSame(ComparisonOperator::Contains, $remaining->operator);
+ }
+
+ public function test_it_rejects_legacy_filter_operator_when_not_in_allowed_set(): void
+ {
+ $definition = new DatatableDefinition('test');
+ $definition->addAdvancedFilterField(
+ 'email',
+ 'e.email',
+ type: FilterType::Text,
+ allowedOperators: [FilterOperator::Equals],
+ );
+
+ $payload = [
+ 'logic' => 'and',
+ 'conditions' => [
+ ['field' => 'email', 'operator' => 'eq', 'value' => 'foo@bar.test'],
+ ['field' => 'email', 'operator' => 'contains', 'value' => 'foo'],
+ ],
+ ];
+
+ $expression = $this->factory->createFromArray($payload, $definition);
+
+ self::assertNotNull($expression);
+ self::assertCount(1, $expression->root->children);
+
+ /** @var Condition $remaining */
+ $remaining = $expression->root->children[0];
+ self::assertSame(ComparisonOperator::Equals, $remaining->operator);
+ }
+
+ public function test_it_returns_null_on_max_depth_exceeded(): void
+ {
+ $payload = [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'field' => 'too_deep',
+ 'operator' => 'eq',
+ 'value' => 'depth 4',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ self::assertNull($this->factory->createFromArray($payload));
+ }
+}
diff --git a/tests/Unit/Factory/DatatableRequestFactoryColumnVisibilityTest.php b/tests/Unit/Factory/DatatableRequestFactoryColumnVisibilityTest.php
index d7343f0..b0132e7 100644
--- a/tests/Unit/Factory/DatatableRequestFactoryColumnVisibilityTest.php
+++ b/tests/Unit/Factory/DatatableRequestFactoryColumnVisibilityTest.php
@@ -6,15 +6,21 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
+use Zhortein\DatatableBundle\Factory\AdvancedFilterExpressionFactory;
use Zhortein\DatatableBundle\Factory\DatatableRequestFactory;
final class DatatableRequestFactoryColumnVisibilityTest extends TestCase
{
- public function test_it_reads_column_visibility_from_query_parameters(): void
+ private DatatableRequestFactory $factory;
+
+ protected function setUp(): void
{
- $factory = new DatatableRequestFactory();
+ $this->factory = new DatatableRequestFactory(new AdvancedFilterExpressionFactory());
+ }
- $datatableRequest = $factory->createFromRequest(new Request([
+ public function test_it_reads_column_visibility_from_query_parameters(): void
+ {
+ $datatableRequest = $this->factory->createFromRequest(new Request([
'visibleColumns' => ['e.email', 'e.displayName'],
'hiddenColumns' => ['e.createdAt'],
]));
@@ -26,9 +32,7 @@ public function test_it_reads_column_visibility_from_query_parameters(): void
public function test_it_reads_single_column_visibility_values(): void
{
- $factory = new DatatableRequestFactory();
-
- $datatableRequest = $factory->createFromRequest(new Request([
+ $datatableRequest = $this->factory->createFromRequest(new Request([
'visibleColumns' => 'e.email',
'hiddenColumns' => 'e.createdAt',
]));
@@ -39,9 +43,7 @@ public function test_it_reads_single_column_visibility_values(): void
public function test_request_payload_overrides_query_column_visibility(): void
{
- $factory = new DatatableRequestFactory();
-
- $datatableRequest = $factory->createFromRequest(new Request(
+ $datatableRequest = $this->factory->createFromRequest(new Request(
query: [
'visibleColumns' => ['e.email'],
'hiddenColumns' => ['e.createdAt'],
@@ -58,9 +60,7 @@ public function test_request_payload_overrides_query_column_visibility(): void
public function test_it_ignores_invalid_column_visibility_values(): void
{
- $factory = new DatatableRequestFactory();
-
- $datatableRequest = $factory->createFromRequest(new Request([
+ $datatableRequest = $this->factory->createFromRequest(new Request([
'visibleColumns' => ['e.email', '', new \stdClass()],
'hiddenColumns' => new \stdClass(),
]));
diff --git a/tests/Unit/Factory/DatatableRequestFactoryConfiguredDefaultsTest.php b/tests/Unit/Factory/DatatableRequestFactoryConfiguredDefaultsTest.php
index 1c82fdd..f4d82cb 100644
--- a/tests/Unit/Factory/DatatableRequestFactoryConfiguredDefaultsTest.php
+++ b/tests/Unit/Factory/DatatableRequestFactoryConfiguredDefaultsTest.php
@@ -7,6 +7,7 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Zhortein\DatatableBundle\Enum\SortDirection;
+use Zhortein\DatatableBundle\Factory\AdvancedFilterExpressionFactory;
use Zhortein\DatatableBundle\Factory\DatatableRequestFactory;
final class DatatableRequestFactoryConfiguredDefaultsTest extends TestCase
@@ -14,6 +15,7 @@ final class DatatableRequestFactoryConfiguredDefaultsTest extends TestCase
public function test_it_uses_configured_defaults(): void
{
$factory = new DatatableRequestFactory(
+ advancedFilterExpressionFactory: new AdvancedFilterExpressionFactory(),
defaultPage: 1,
defaultPageSize: 50,
maxPageSize: 200,
@@ -29,6 +31,7 @@ public function test_it_uses_configured_defaults(): void
public function test_it_caps_page_size_with_configured_maximum(): void
{
$factory = new DatatableRequestFactory(
+ advancedFilterExpressionFactory: new AdvancedFilterExpressionFactory(),
defaultPage: 1,
defaultPageSize: 50,
maxPageSize: 100,
@@ -44,6 +47,7 @@ public function test_it_caps_page_size_with_configured_maximum(): void
public function test_runtime_request_values_override_configured_defaults(): void
{
$factory = new DatatableRequestFactory(
+ advancedFilterExpressionFactory: new AdvancedFilterExpressionFactory(),
defaultPage: 1,
defaultPageSize: 50,
maxPageSize: 100,
@@ -63,6 +67,9 @@ public function test_it_rejects_invalid_constructor_defaults(): void
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The default datatable page size must be greater than or equal to 1.');
- new DatatableRequestFactory(defaultPageSize: 0);
+ new DatatableRequestFactory(
+ advancedFilterExpressionFactory: new AdvancedFilterExpressionFactory(),
+ defaultPageSize: 0,
+ );
}
}
diff --git a/tests/Unit/Factory/DatatableRequestFactoryTest.php b/tests/Unit/Factory/DatatableRequestFactoryTest.php
index 1f0a3d4..5a60317 100644
--- a/tests/Unit/Factory/DatatableRequestFactoryTest.php
+++ b/tests/Unit/Factory/DatatableRequestFactoryTest.php
@@ -7,15 +7,21 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Zhortein\DatatableBundle\Enum\SortDirection;
+use Zhortein\DatatableBundle\Factory\AdvancedFilterExpressionFactory;
use Zhortein\DatatableBundle\Factory\DatatableRequestFactory;
final class DatatableRequestFactoryTest extends TestCase
{
- public function test_it_creates_request_from_query_parameters(): void
+ private DatatableRequestFactory $factory;
+
+ protected function setUp(): void
{
- $factory = new DatatableRequestFactory();
+ $this->factory = new DatatableRequestFactory(new AdvancedFilterExpressionFactory());
+ }
- $datatableRequest = $factory->createFromRequest(new Request([
+ public function test_it_creates_request_from_query_parameters(): void
+ {
+ $datatableRequest = $this->factory->createFromRequest(new Request([
'page' => '3',
'pageSize' => '50',
'search' => ' john ',
@@ -41,9 +47,7 @@ public function test_it_creates_request_from_query_parameters(): void
public function test_request_payload_overrides_query_parameters(): void
{
- $factory = new DatatableRequestFactory();
-
- $datatableRequest = $factory->createFromRequest(new Request(
+ $datatableRequest = $this->factory->createFromRequest(new Request(
query: [
'page' => '1',
'pageSize' => '10',
@@ -73,9 +77,7 @@ public function test_request_payload_overrides_query_parameters(): void
public function test_it_uses_defaults_when_parameters_are_missing(): void
{
- $factory = new DatatableRequestFactory();
-
- $datatableRequest = $factory->createFromRequest(new Request());
+ $datatableRequest = $this->factory->createFromRequest(new Request());
self::assertSame(DatatableRequestFactory::DEFAULT_PAGE, $datatableRequest->getPage());
self::assertSame(DatatableRequestFactory::DEFAULT_PAGE_SIZE, $datatableRequest->getPageSize());
@@ -88,9 +90,7 @@ public function test_it_uses_defaults_when_parameters_are_missing(): void
public function test_it_falls_back_to_defaults_for_invalid_values(): void
{
- $factory = new DatatableRequestFactory();
-
- $datatableRequest = $factory->createFromRequest(new Request([
+ $datatableRequest = $this->factory->createFromRequest(new Request([
'page' => '-10',
'pageSize' => 'invalid',
'search' => [],
@@ -111,9 +111,7 @@ public function test_it_falls_back_to_defaults_for_invalid_values(): void
public function test_it_caps_page_size(): void
{
- $factory = new DatatableRequestFactory();
-
- $datatableRequest = $factory->createFromRequest(new Request([
+ $datatableRequest = $this->factory->createFromRequest(new Request([
'pageSize' => '999999',
]));
@@ -122,9 +120,7 @@ public function test_it_caps_page_size(): void
public function test_it_normalizes_empty_strings(): void
{
- $factory = new DatatableRequestFactory();
-
- $datatableRequest = $factory->createFromRequest(new Request([
+ $datatableRequest = $this->factory->createFromRequest(new Request([
'search' => ' ',
'sortField' => '',
]));
@@ -132,4 +128,50 @@ public function test_it_normalizes_empty_strings(): void
self::assertNull($datatableRequest->getSearchQuery());
self::assertNull($datatableRequest->getSortField());
}
+
+ public function test_it_reads_advanced_filters_from_query_parameters(): void
+ {
+ $payload = [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'field' => 'email',
+ 'operator' => 'contains',
+ 'value' => 'gmail.com',
+ ],
+ ],
+ ];
+
+ $datatableRequest = $this->factory->createFromRequest(new Request([
+ 'advancedFilters' => $payload,
+ ]));
+
+ self::assertTrue($datatableRequest->hasAdvancedFilters());
+ self::assertNotNull($datatableRequest->getAdvancedFilterExpression());
+
+ /** @var \Zhortein\DatatableBundle\Filter\Expression\Condition $condition */
+ $condition = $datatableRequest->getAdvancedFilterExpression()->root->children[0];
+ self::assertSame('email', $condition->field);
+ }
+
+ public function test_it_reads_advanced_filters_from_alternative_key(): void
+ {
+ $payload = [
+ 'logic' => 'AND',
+ 'children' => [
+ [
+ 'field' => 'email',
+ 'operator' => 'contains',
+ 'value' => 'gmail.com',
+ ],
+ ],
+ ];
+
+ $datatableRequest = $this->factory->createFromRequest(new Request([
+ 'filterExpression' => $payload,
+ ]));
+
+ self::assertTrue($datatableRequest->hasAdvancedFilters());
+ self::assertNotNull($datatableRequest->getAdvancedFilterExpression());
+ }
}
diff --git a/tests/Unit/Factory/Fixtures/StatusEnumFixture.php b/tests/Unit/Factory/Fixtures/StatusEnumFixture.php
new file mode 100644
index 0000000..df9d194
--- /dev/null
+++ b/tests/Unit/Factory/Fixtures/StatusEnumFixture.php
@@ -0,0 +1,12 @@
+root);
+ }
+}
diff --git a/tests/Unit/Filter/Expression/ArrayExpressionEvaluatorTest.php b/tests/Unit/Filter/Expression/ArrayExpressionEvaluatorTest.php
new file mode 100644
index 0000000..9205190
--- /dev/null
+++ b/tests/Unit/Filter/Expression/ArrayExpressionEvaluatorTest.php
@@ -0,0 +1,206 @@
+evaluator = new ArrayExpressionEvaluator();
+ }
+
+ /**
+ * @param array $row
+ */
+ #[DataProvider('provideEvaluateData')]
+ public function test_evaluate(ExpressionInterface $root, array $row, bool $expected): void
+ {
+ if ($root instanceof Condition) {
+ $root = new Group(children: [$root]);
+ }
+
+ /** @var Group $root */
+ $expression = new AdvancedFilterExpression($root);
+
+ self::assertSame($expected, $this->evaluator->evaluate($expression, $row));
+ }
+
+ /**
+ * @return iterable, 2: bool}>
+ */
+ public static function provideEvaluateData(): iterable
+ {
+ yield 'equals string' => [
+ new Condition('name', ComparisonOperator::Equals, 'John'),
+ ['name' => 'John'],
+ true,
+ ];
+
+ yield 'equals string case insensitive' => [
+ new Condition('name', ComparisonOperator::Equals, 'john'),
+ ['name' => 'John'],
+ true,
+ ];
+
+ yield 'not equals string' => [
+ new Condition('name', ComparisonOperator::NotEquals, 'John'),
+ ['name' => 'Jane'],
+ true,
+ ];
+
+ yield 'contains' => [
+ new Condition('name', ComparisonOperator::Contains, 'oh'),
+ ['name' => 'John'],
+ true,
+ ];
+
+ yield 'not contains' => [
+ new Condition('name', ComparisonOperator::NotContains, 'ax'),
+ ['name' => 'John'],
+ true,
+ ];
+
+ yield 'starts with' => [
+ new Condition('name', ComparisonOperator::StartsWith, 'Jo'),
+ ['name' => 'John'],
+ true,
+ ];
+
+ yield 'ends with' => [
+ new Condition('name', ComparisonOperator::EndsWith, 'hn'),
+ ['name' => 'John'],
+ true,
+ ];
+
+ yield 'greater than numeric' => [
+ new Condition('age', ComparisonOperator::GreaterThan, 20),
+ ['age' => 25],
+ true,
+ ];
+
+ yield 'greater than or equals numeric' => [
+ new Condition('age', ComparisonOperator::GreaterThanOrEquals, 25),
+ ['age' => 25],
+ true,
+ ];
+
+ yield 'less than numeric' => [
+ new Condition('age', ComparisonOperator::LessThan, 30),
+ ['age' => 25],
+ true,
+ ];
+
+ yield 'less than or equals numeric' => [
+ new Condition('age', ComparisonOperator::LessThanOrEquals, 25),
+ ['age' => 25],
+ true,
+ ];
+
+ yield 'between numeric' => [
+ new Condition('age', ComparisonOperator::Between, [20, 30]),
+ ['age' => 25],
+ true,
+ ];
+
+ yield 'is null' => [
+ new Condition('name', ComparisonOperator::IsNull),
+ ['name' => null],
+ true,
+ ];
+
+ yield 'is not null' => [
+ new Condition('name', ComparisonOperator::IsNotNull),
+ ['name' => 'John'],
+ true,
+ ];
+
+ yield 'in array' => [
+ new Condition('name', ComparisonOperator::In, ['John', 'Jane']),
+ ['name' => 'John'],
+ true,
+ ];
+
+ yield 'not in array' => [
+ new Condition('name', ComparisonOperator::NotIn, ['Jane', 'Jack']),
+ ['name' => 'John'],
+ true,
+ ];
+
+ yield 'logic AND success' => [
+ new Group(LogicOperator::And, [
+ new Condition('name', ComparisonOperator::Equals, 'John'),
+ new Condition('age', ComparisonOperator::GreaterThan, 20),
+ ]),
+ ['name' => 'John', 'age' => 25],
+ true,
+ ];
+
+ yield 'logic AND failure' => [
+ new Group(LogicOperator::And, [
+ new Condition('name', ComparisonOperator::Equals, 'John'),
+ new Condition('age', ComparisonOperator::GreaterThan, 30),
+ ]),
+ ['name' => 'John', 'age' => 25],
+ false,
+ ];
+
+ yield 'logic OR success' => [
+ new Group(LogicOperator::Or, [
+ new Condition('name', ComparisonOperator::Equals, 'John'),
+ new Condition('name', ComparisonOperator::Equals, 'Jane'),
+ ]),
+ ['name' => 'Jane'],
+ true,
+ ];
+
+ yield 'nested group' => [
+ new Group(LogicOperator::And, [
+ new Condition('active', ComparisonOperator::Equals, true),
+ new Group(LogicOperator::Or, [
+ new Condition('name', ComparisonOperator::Equals, 'John'),
+ new Condition('name', ComparisonOperator::Equals, 'Jane'),
+ ]),
+ ]),
+ ['active' => true, 'name' => 'Jane'],
+ true,
+ ];
+
+ yield 'boolean true' => [
+ new Condition('active', ComparisonOperator::Equals, true),
+ ['active' => 1],
+ true,
+ ];
+
+ yield 'boolean false' => [
+ new Condition('active', ComparisonOperator::Equals, false),
+ ['active' => 'no'],
+ true,
+ ];
+
+ yield 'date equals' => [
+ new Condition('created_at', ComparisonOperator::Equals, '2023-05-01'),
+ ['created_at' => new \DateTimeImmutable('2023-05-01')],
+ true,
+ ];
+
+ yield 'date greater than' => [
+ new Condition('created_at', ComparisonOperator::GreaterThan, '2023-01-01'),
+ ['created_at' => '2023-05-01'],
+ true,
+ ];
+ }
+}
diff --git a/tests/Unit/Filter/Expression/ConditionTest.php b/tests/Unit/Filter/Expression/ConditionTest.php
new file mode 100644
index 0000000..257c11a
--- /dev/null
+++ b/tests/Unit/Filter/Expression/ConditionTest.php
@@ -0,0 +1,56 @@
+field);
+ self::assertSame(ComparisonOperator::Contains, $condition->operator);
+ self::assertSame('gmail.com', $condition->value);
+ self::assertSame(0, $condition->getDepth());
+ }
+
+ public function test_empty_field_throws_exception(): void
+ {
+ $this->expectException(InvalidExpressionException::class);
+ $this->expectExceptionMessage('Condition field cannot be empty.');
+ new Condition('', ComparisonOperator::Equals, 'value');
+ }
+
+ public function test_between_requires_two_values(): void
+ {
+ $this->expectException(InvalidExpressionException::class);
+ $this->expectExceptionMessage('Between operator requires an array of exactly 2 values.');
+ new Condition('age', ComparisonOperator::Between, [10]);
+ }
+
+ public function test_in_requires_array(): void
+ {
+ $this->expectException(InvalidExpressionException::class);
+ $this->expectExceptionMessage('"in" operator requires an array of values.');
+ new Condition('status', ComparisonOperator::In, 'active');
+ }
+
+ public function test_null_value_throws_exception_for_most_operators(): void
+ {
+ $this->expectException(InvalidExpressionException::class);
+ $this->expectExceptionMessage('"eq" operator requires a non-null value.');
+ new Condition('name', ComparisonOperator::Equals, null);
+ }
+
+ public function test_is_null_allows_null_value(): void
+ {
+ $condition = new Condition('deletedAt', ComparisonOperator::IsNull);
+ self::assertNull($condition->value);
+ }
+}
diff --git a/tests/Unit/Filter/Expression/GroupTest.php b/tests/Unit/Filter/Expression/GroupTest.php
new file mode 100644
index 0000000..cc08872
--- /dev/null
+++ b/tests/Unit/Filter/Expression/GroupTest.php
@@ -0,0 +1,58 @@
+logic);
+ self::assertCount(1, $group->children);
+ self::assertSame($condition, $group->children[0]);
+ self::assertSame(1, $group->getDepth());
+ }
+
+ public function test_empty_children_throws_exception(): void
+ {
+ $this->expectException(InvalidExpressionException::class);
+ $this->expectExceptionMessage('Group must have at least one child.');
+ new Group(LogicOperator::And, []);
+ }
+
+ public function test_max_depth_enforced(): void
+ {
+ $c = new Condition('f', ComparisonOperator::Equals, 'v');
+ $g1 = new Group(LogicOperator::And, [$c]); // depth 1
+ $g2 = new Group(LogicOperator::And, [$g1]); // depth 2
+ $g3 = new Group(LogicOperator::And, [$g2]); // depth 3
+
+ self::assertSame(3, $g3->getDepth());
+
+ $this->expectException(InvalidExpressionException::class);
+ $this->expectExceptionMessage('Expression tree depth exceeds maximum allowed depth of 3.');
+ new Group(LogicOperator::And, [$g3]); // depth 4
+ }
+
+ public function test_nested_groups_depth(): void
+ {
+ $c1 = new Condition('f1', ComparisonOperator::Equals, 'v1');
+ $c2 = new Condition('f2', ComparisonOperator::Equals, 'v2');
+
+ $g1 = new Group(LogicOperator::Or, [$c1, $c2]); // depth 1
+ $g2 = new Group(LogicOperator::And, [$g1, $c1]); // depth 2
+
+ self::assertSame(2, $g2->getDepth());
+ }
+}
diff --git a/tests/Unit/Icon/IconResolverTest.php b/tests/Unit/Icon/IconResolverTest.php
new file mode 100644
index 0000000..c427178
--- /dev/null
+++ b/tests/Unit/Icon/IconResolverTest.php
@@ -0,0 +1,48 @@
+resolve('view'));
+ self::assertSame('bi bi-pencil', $resolver->resolve('edit'));
+ self::assertSame('bi bi-trash', $resolver->resolve('delete'));
+ self::assertSame('bi bi-plus-lg', $resolver->resolve('add'));
+ self::assertSame('bi bi-check-lg', $resolver->resolve('check'));
+ self::assertSame('bi bi-x-lg', $resolver->resolve('cancel'));
+ self::assertSame('bi bi-arrow-down-up', $resolver->resolve('sort_neutral'));
+ self::assertSame('bi bi-arrow-up', $resolver->resolve('sort_asc'));
+ self::assertSame('bi bi-arrow-down', $resolver->resolve('sort_desc'));
+ self::assertSame('bi bi-funnel', $resolver->resolve('filter'));
+ self::assertSame('bi bi-filetype-csv', $resolver->resolve('export_csv'));
+ self::assertSame('bi bi-filetype-xlsx', $resolver->resolve('export_excel'));
+ }
+
+ public function test_resolve_unknown_icon_returns_null(): void
+ {
+ $resolver = new IconResolver();
+
+ self::assertNull($resolver->resolve('unknown'));
+ }
+
+ public function test_resolve_overridden_icon(): void
+ {
+ $resolver = new IconResolver([
+ 'view' => 'fa fa-eye',
+ 'custom' => 'fa fa-star',
+ ]);
+
+ self::assertSame('fa fa-eye', $resolver->resolve('view'));
+ self::assertSame('fa fa-star', $resolver->resolve('custom'));
+ self::assertSame('bi bi-pencil', $resolver->resolve('edit'));
+ }
+}
diff --git a/tests/Unit/Provider/ArrayDataProviderAdvancedFiltersTest.php b/tests/Unit/Provider/ArrayDataProviderAdvancedFiltersTest.php
new file mode 100644
index 0000000..3718701
--- /dev/null
+++ b/tests/Unit/Provider/ArrayDataProviderAdvancedFiltersTest.php
@@ -0,0 +1,134 @@
+getData(
+ $this->createDefinition(),
+ DatatableRequest::create(advancedFilterExpression: $expression),
+ );
+
+ self::assertSame(4, $result->getTotalItems());
+ self::assertSame(1, $result->getFilteredItems());
+ self::assertSame([
+ [
+ 'id' => 3,
+ 'name' => 'John',
+ 'age' => 25,
+ ],
+ ], $result->getRows());
+ }
+
+ public function test_it_applies_nested_and_or_groups(): void
+ {
+ // (age > 20) AND ((name starts_with 'J') OR (name eq 'Alice'))
+ $expression = new AdvancedFilterExpression(
+ new Group(LogicOperator::And, [
+ new Condition('age', ComparisonOperator::GreaterThan, 20),
+ new Group(LogicOperator::Or, [
+ new Condition('name', ComparisonOperator::StartsWith, 'J'),
+ new Condition('name', ComparisonOperator::Equals, 'Alice'),
+ ]),
+ ])
+ );
+
+ $result = new ArrayDataProvider()->getData(
+ $this->createDefinition(),
+ DatatableRequest::create(advancedFilterExpression: $expression),
+ );
+
+ $names = [];
+ foreach ($result->getRows() as $row) {
+ $name = $row['name'] ?? null;
+ if (is_string($name)) {
+ $names[] = $name;
+ }
+ }
+ sort($names);
+
+ self::assertSame(['Jane', 'John'], $names);
+ }
+
+ public function test_it_combines_with_simple_search(): void
+ {
+ $expression = new AdvancedFilterExpression(
+ new Group(LogicOperator::And, [
+ new Condition('age', ComparisonOperator::GreaterThan, 20),
+ ])
+ );
+
+ $result = new ArrayDataProvider()->getData(
+ $this->createDefinition(),
+ DatatableRequest::create(
+ searchQuery: 'jane',
+ advancedFilterExpression: $expression,
+ ),
+ );
+
+ self::assertSame(1, $result->getFilteredItems());
+ self::assertSame([
+ [
+ 'id' => 4,
+ 'name' => 'Jane',
+ 'age' => 22,
+ ],
+ ], $result->getRows());
+ }
+
+ private function createDefinition(): DatatableDefinition
+ {
+ $definition = new DatatableDefinition('users');
+
+ $definition
+ ->addColumn('id')
+ ->addColumn('name')
+ ->addColumn('age')
+ ->setOption(ArrayDataProvider::OPTION_ROWS, [
+ [
+ 'id' => 1,
+ 'name' => 'Alice',
+ 'age' => 18,
+ ],
+ [
+ 'id' => 2,
+ 'name' => 'Bob',
+ 'age' => 30,
+ ],
+ [
+ 'id' => 3,
+ 'name' => 'John',
+ 'age' => 25,
+ ],
+ [
+ 'id' => 4,
+ 'name' => 'Jane',
+ 'age' => 22,
+ ],
+ ])
+ ;
+
+ return $definition;
+ }
+}
diff --git a/tests/Unit/Renderer/BulkActionRendererTest.php b/tests/Unit/Renderer/BulkActionRendererTest.php
new file mode 100644
index 0000000..ce33047
--- /dev/null
+++ b/tests/Unit/Renderer/BulkActionRendererTest.php
@@ -0,0 +1,200 @@
+addColumn('email', label: 'Email');
+
+ $renderer = new DatatableRenderer($this->createTwigEnvironment());
+
+ $html = $renderer->render($definition);
+
+ self::assertStringNotContainsString('zhortein-datatable__selector-column', $html);
+ self::assertStringNotContainsString('name="selected_rows[]"', $html);
+ self::assertStringNotContainsString('data-action="zhortein--datatable-bundle--datatable#selectAll"', $html);
+ }
+
+ public function test_it_renders_selector_column_with_bulk_actions(): void
+ {
+ $definition = new DatatableDefinition('users');
+ $definition->addColumn('email', label: 'Email');
+ $definition->addBulkAction('delete', 'user_bulk_delete');
+
+ $renderer = new DatatableRenderer($this->createTwigEnvironment());
+
+ $result = new DatatableResult(
+ rows: [
+ ['id' => 123, 'email' => 'user1@example.com'],
+ ['id' => 456, 'email' => 'user2@example.com'],
+ ],
+ page: 1,
+ pageSize: 10,
+ totalItems: 2,
+ filteredItems: 2,
+ );
+
+ // Header rendering
+ $headerHtml = $renderer->renderHeader($definition);
+ self::assertStringContainsString('zhortein-datatable__selector-column', $headerHtml);
+ self::assertStringContainsString('data-action="zhortein--datatable-bundle--datatable#selectAll"', $headerHtml);
+ self::assertStringContainsString('aria-label="Select all"', $headerHtml);
+
+ // Body rendering
+ $bodyHtml = $renderer->renderBody($definition, $result);
+ self::assertStringContainsString('zhortein-datatable__selector-column', $bodyHtml);
+ self::assertStringContainsString('name="selected_rows[]"', $bodyHtml);
+ self::assertStringContainsString('value="123"', $bodyHtml);
+ self::assertStringContainsString('value="456"', $bodyHtml);
+ self::assertStringContainsString('aria-label="Select row 123"', $bodyHtml);
+ self::assertStringContainsString('aria-label="Select row 456"', $bodyHtml);
+ }
+
+ public function test_it_resolves_identifier_from_option(): void
+ {
+ $definition = new DatatableDefinition('users');
+ $definition->addColumn('email', label: 'Email');
+ $definition->addBulkAction('delete', 'user_bulk_delete');
+ $definition->setOption('identifier', 'uuid');
+
+ $renderer = new DatatableRenderer($this->createTwigEnvironment());
+
+ $result = new DatatableResult(
+ rows: [
+ ['uuid' => 'abc-def', 'id' => 123, 'email' => 'user1@example.com'],
+ ],
+ page: 1,
+ pageSize: 10,
+ totalItems: 1,
+ filteredItems: 1,
+ );
+
+ $bodyHtml = $renderer->renderBody($definition, $result);
+ self::assertStringContainsString('value="abc-def"', $bodyHtml);
+ self::assertStringNotContainsString('value="123"', $bodyHtml);
+ }
+
+ public function test_it_resolves_identifier_from_e_id_fallback(): void
+ {
+ $definition = new DatatableDefinition('users');
+ $definition->addColumn('email', label: 'Email');
+ $definition->addBulkAction('delete', 'user_bulk_delete');
+
+ $renderer = new DatatableRenderer($this->createTwigEnvironment());
+
+ $result = new DatatableResult(
+ rows: [
+ ['e_id' => 789, 'email' => 'user1@example.com'],
+ ],
+ page: 1,
+ pageSize: 10,
+ totalItems: 1,
+ filteredItems: 1,
+ );
+
+ $bodyHtml = $renderer->renderBody($definition, $result);
+ self::assertStringContainsString('value="789"', $bodyHtml);
+ }
+
+ public function test_it_updates_colspan_in_empty_body(): void
+ {
+ $definition = new DatatableDefinition('users');
+ $definition->addColumn('email', label: 'Email');
+ $definition->addBulkAction('delete', 'user_bulk_delete');
+
+ $renderer = new DatatableRenderer($this->createTwigEnvironment());
+
+ $html = $renderer->renderEmptyBody($definition);
+
+ // 1 column + 1 selector column = colspan 2
+ self::assertStringContainsString('colspan="2"', $html);
+ }
+
+ public function test_it_renders_bulk_action_toolbar(): void
+ {
+ $definition = new DatatableDefinition('users');
+ $definition->addColumn('email', label: 'Email');
+ $definition->addBulkAction('delete', 'user_bulk_delete', label: 'Delete selected', icon: 'fa fa-trash');
+ $definition->addBulkAction('archive', 'user_bulk_archive', label: 'Archive selected', confirmationMessage: 'Are you sure?', selectedRowsParameterName: 'rows');
+
+ $renderer = new DatatableRenderer(
+ twig: $this->createTwigEnvironment(),
+ urlGenerator: $this->createUrlGeneratorStub(),
+ );
+
+ $html = $renderer->render($definition);
+
+ self::assertStringContainsString('zhortein-datatable__bulk-actions', $html);
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-target="bulkToolbar"', $html);
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-target="selectedCount"', $html);
+
+ // Delete action
+ self::assertStringContainsString('Delete selected', $html);
+ self::assertStringContainsString('fa fa-trash', $html);
+ self::assertStringContainsString('disabled', $html);
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-target="bulkAction"', $html);
+ self::assertStringContainsString('data-action="submit->zhortein--datatable-bundle--datatable#submitBulkAction"', $html);
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-selected-rows-parameter-name="ids"', $html);
+
+ // Archive action
+ self::assertStringContainsString('Archive selected', $html);
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-confirmation-message="Are you sure?"', $html);
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-selected-rows-parameter-name="rows"', $html);
+ }
+
+ private function createUrlGeneratorStub(): UrlGeneratorInterface
+ {
+ return new class implements UrlGeneratorInterface {
+ /**
+ * @param array $parameters
+ */
+ public function generate(
+ string $name,
+ array $parameters = [],
+ int $referenceType = self::ABSOLUTE_PATH,
+ ): string {
+ return '/'.$name;
+ }
+
+ public function setContext(RequestContext $context): void
+ {
+ }
+
+ public function getContext(): RequestContext
+ {
+ return new RequestContext();
+ }
+ };
+ }
+
+ private function createTwigEnvironment(): Environment
+ {
+ $loader = new FilesystemLoader();
+ $loader->addPath(__DIR__.'/../../../templates', 'ZhorteinDatatable');
+
+ $twig = new Environment($loader, [
+ 'strict_variables' => true,
+ 'autoescape' => 'html',
+ ]);
+
+ $this->addTranslationExtension($twig);
+
+ return $twig;
+ }
+}
diff --git a/tests/Unit/Renderer/DatatableRendererBooleanDisplayModeTest.php b/tests/Unit/Renderer/DatatableRendererBooleanDisplayModeTest.php
index 4b7ff2a..9918d3e 100644
--- a/tests/Unit/Renderer/DatatableRendererBooleanDisplayModeTest.php
+++ b/tests/Unit/Renderer/DatatableRendererBooleanDisplayModeTest.php
@@ -7,8 +7,10 @@
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
+use Zhortein\DatatableBundle\Contract\IconResolverInterface;
use Zhortein\DatatableBundle\Definition\DatatableDefinition;
use Zhortein\DatatableBundle\Enum\BooleanDisplayMode;
+use Zhortein\DatatableBundle\Icon\IconResolver;
use Zhortein\DatatableBundle\Renderer\DatatableRenderer;
use Zhortein\DatatableBundle\Result\DatatableResult;
@@ -32,7 +34,7 @@ public function test_it_renders_boolean_false_as_badge_by_default(): void
self::assertStringContainsString('No', $html);
}
- public function test_it_renders_boolean_as_icon(): void
+ public function test_it_renders_boolean_as_icon_fallback_when_no_resolver(): void
{
$html = $this->renderBoolean(true, BooleanDisplayMode::Icon);
@@ -43,7 +45,7 @@ public function test_it_renders_boolean_as_icon(): void
self::assertStringNotContainsString('badge text-bg-success', $html);
}
- public function test_it_renders_boolean_false_as_icon(): void
+ public function test_it_renders_boolean_false_as_icon_fallback_when_no_resolver(): void
{
$html = $this->renderBoolean(false, BooleanDisplayMode::Icon);
@@ -52,6 +54,40 @@ public function test_it_renders_boolean_false_as_icon(): void
self::assertStringContainsString('No', $html);
}
+ public function test_it_renders_boolean_as_icon_with_resolver(): void
+ {
+ $html = $this->renderBoolean(true, BooleanDisplayMode::Icon, new IconResolver());
+
+ self::assertStringContainsString('bi bi-check-lg', $html);
+ self::assertStringContainsString('text-success', $html);
+ self::assertStringContainsString('visually-hidden', $html);
+ self::assertStringContainsString('Yes', $html);
+ }
+
+ public function test_it_renders_boolean_false_as_icon_with_resolver(): void
+ {
+ $html = $this->renderBoolean(false, BooleanDisplayMode::Icon, new IconResolver());
+
+ self::assertStringContainsString('bi bi-x-lg', $html);
+ self::assertStringContainsString('text-danger', $html);
+ self::assertStringContainsString('visually-hidden', $html);
+ self::assertStringContainsString('No', $html);
+ }
+
+ public function test_it_renders_boolean_as_custom_icon(): void
+ {
+ $resolver = new IconResolver([
+ 'boolean_true' => 'custom-true-icon',
+ 'boolean_false' => 'custom-false-icon',
+ ]);
+
+ $html = $this->renderBoolean(true, BooleanDisplayMode::Icon, $resolver);
+ self::assertStringContainsString('custom-true-icon', $html);
+
+ $html = $this->renderBoolean(false, BooleanDisplayMode::Icon, $resolver);
+ self::assertStringContainsString('custom-false-icon', $html);
+ }
+
public function test_it_renders_boolean_as_switch(): void
{
$html = $this->renderBoolean(true, BooleanDisplayMode::Switch);
@@ -95,7 +131,7 @@ public function test_it_renders_boolean_from_string_option(): void
self::assertStringContainsString('form-check form-switch', $html);
}
- private function renderBoolean(bool $value, ?BooleanDisplayMode $mode = null): string
+ private function renderBoolean(bool $value, ?BooleanDisplayMode $mode = null, ?IconResolverInterface $iconResolver = null): string
{
$definition = new DatatableDefinition('users');
@@ -111,7 +147,10 @@ private function renderBoolean(bool $value, ?BooleanDisplayMode $mode = null): s
$options['booleanDisplayMode'] = $mode->value;
}
- return new DatatableRenderer($this->createTwigEnvironment())->renderBody(
+ return new DatatableRenderer(
+ twig: $this->createTwigEnvironment(),
+ iconResolver: $iconResolver,
+ )->renderBody(
$definition,
new DatatableResult(
rows: [
diff --git a/tests/Unit/Renderer/DatatableRendererBulkActionVisibilityTest.php b/tests/Unit/Renderer/DatatableRendererBulkActionVisibilityTest.php
new file mode 100644
index 0000000..d612850
--- /dev/null
+++ b/tests/Unit/Renderer/DatatableRendererBulkActionVisibilityTest.php
@@ -0,0 +1,143 @@
+createTwigEnvironment(),
+ urlGenerator: $urlGenerator,
+ routeParameterResolver: new RowActionRouteParameterResolver(),
+ actionVisibilityChecker: new DenyBulkActionVisibilityChecker(),
+ );
+
+ $html = $renderer->render($this->createDefinition());
+
+ self::assertStringNotContainsString('Bulk delete', $html);
+ self::assertStringNotContainsString('/users/bulk-delete', $html);
+ // If all bulk actions are hidden, the selection checkbox column should also be hidden
+ self::assertStringNotContainsString('data-zhortein--datatable-bundle--datatable-target="selectAllCheckbox"', $html);
+ self::assertSame(0, $urlGenerator->getGenerateCallCount());
+ }
+
+ public function test_it_renders_bulk_actions_when_visibility_checker_allows_them(): void
+ {
+ $urlGenerator = new BulkActionCountingUrlGenerator();
+
+ $renderer = new DatatableRenderer(
+ twig: $this->createTwigEnvironment(),
+ urlGenerator: $urlGenerator,
+ routeParameterResolver: new RowActionRouteParameterResolver(),
+ actionVisibilityChecker: new AllowDeleteBulkActionVisibilityChecker(),
+ );
+
+ $html = $renderer->render($this->createDefinition());
+
+ self::assertStringContainsString('Bulk delete', $html);
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-target="selectAllCheckbox"', $html);
+ self::assertSame(1, $urlGenerator->getGenerateCallCount());
+ }
+
+ private function createDefinition(): DatatableDefinition
+ {
+ $definition = new DatatableDefinition('users');
+
+ $definition
+ ->addColumn('e.email', label: 'Email')
+ ->addBulkAction(
+ name: 'bulk-delete',
+ route: 'app_user_bulk_delete',
+ label: 'Bulk delete',
+ )
+ ;
+
+ return $definition;
+ }
+
+ private function createTwigEnvironment(): Environment
+ {
+ $loader = new FilesystemLoader();
+ $loader->addPath(__DIR__.'/../../../templates', 'ZhorteinDatatable');
+
+ $twig = new Environment($loader, [
+ 'strict_variables' => true,
+ 'autoescape' => 'html',
+ ]);
+
+ $this->addTranslationExtension($twig);
+
+ return $twig;
+ }
+}
+
+final class DenyBulkActionVisibilityChecker implements ActionVisibilityCheckerInterface
+{
+ public function isVisible(ActionDefinition|BulkActionDefinition $action, ActionVisibilityContext $context): bool
+ {
+ return false;
+ }
+}
+
+final class AllowDeleteBulkActionVisibilityChecker implements ActionVisibilityCheckerInterface
+{
+ public function isVisible(ActionDefinition|BulkActionDefinition $action, ActionVisibilityContext $context): bool
+ {
+ return 'bulk-delete' === $action->getName();
+ }
+}
+
+final class BulkActionCountingUrlGenerator implements UrlGeneratorInterface
+{
+ private int $generateCallCount = 0;
+
+ /**
+ * @param array $parameters
+ */
+ public function generate(
+ string $name,
+ array $parameters = [],
+ int $referenceType = self::ABSOLUTE_PATH,
+ ): string {
+ ++$this->generateCallCount;
+
+ return match ($name) {
+ 'app_user_bulk_delete' => '/users/bulk-delete',
+ default => '/'.$name,
+ };
+ }
+
+ public function setContext(RequestContext $context): void
+ {
+ }
+
+ public function getContext(): RequestContext
+ {
+ return new RequestContext();
+ }
+
+ public function getGenerateCallCount(): int
+ {
+ return $this->generateCallCount;
+ }
+}
diff --git a/tests/Unit/Renderer/DatatableRendererGlobalActionVisibilityTest.php b/tests/Unit/Renderer/DatatableRendererGlobalActionVisibilityTest.php
index 9a52a10..82aea09 100644
--- a/tests/Unit/Renderer/DatatableRendererGlobalActionVisibilityTest.php
+++ b/tests/Unit/Renderer/DatatableRendererGlobalActionVisibilityTest.php
@@ -13,6 +13,7 @@
use Zhortein\DatatableBundle\Action\ActionVisibilityContext;
use Zhortein\DatatableBundle\Action\RowActionRouteParameterResolver;
use Zhortein\DatatableBundle\Definition\ActionDefinition;
+use Zhortein\DatatableBundle\Definition\BulkActionDefinition;
use Zhortein\DatatableBundle\Definition\DatatableDefinition;
use Zhortein\DatatableBundle\Renderer\DatatableRenderer;
@@ -97,7 +98,7 @@ private function createTwigEnvironment(): Environment
final class DenyGlobalActionVisibilityChecker implements ActionVisibilityCheckerInterface
{
- public function isVisible(ActionDefinition $action, ActionVisibilityContext $context): bool
+ public function isVisible(ActionDefinition|BulkActionDefinition $action, ActionVisibilityContext $context): bool
{
return false;
}
@@ -105,7 +106,7 @@ public function isVisible(ActionDefinition $action, ActionVisibilityContext $con
final class AllowCreateGlobalActionVisibilityChecker implements ActionVisibilityCheckerInterface
{
- public function isVisible(ActionDefinition $action, ActionVisibilityContext $context): bool
+ public function isVisible(ActionDefinition|BulkActionDefinition $action, ActionVisibilityContext $context): bool
{
return 'create' === $action->getName();
}
diff --git a/tests/Unit/Renderer/DatatableRendererHeaderFragmentTest.php b/tests/Unit/Renderer/DatatableRendererHeaderFragmentTest.php
index 76a58d6..b7eff50 100644
--- a/tests/Unit/Renderer/DatatableRendererHeaderFragmentTest.php
+++ b/tests/Unit/Renderer/DatatableRendererHeaderFragmentTest.php
@@ -38,6 +38,26 @@ public function test_it_renders_header_fragment_with_runtime_visible_columns():
self::assertStringNotContainsString('Display name', $html);
}
+ public function test_it_renders_header_fragment_with_sort_icons(): void
+ {
+ $renderer = new DatatableRenderer(
+ $this->createTwigEnvironment(),
+ new \Zhortein\DatatableBundle\Icon\IconResolver([
+ 'sort_neutral' => 'neutral-icon',
+ 'sort_asc' => 'asc-icon',
+ 'sort_desc' => 'desc-icon',
+ ])
+ );
+
+ $html = $renderer->renderHeader($this->createDefinition(), [
+ 'sortField' => 'e.email',
+ 'sortDirection' => 'desc',
+ ]);
+
+ self::assertStringContainsString('', $html);
+ self::assertStringContainsString('', $html);
+ }
+
private function createDefinition(): DatatableDefinition
{
$definition = new DatatableDefinition('users');
diff --git a/tests/Unit/Renderer/DatatableRendererIconResolutionTest.php b/tests/Unit/Renderer/DatatableRendererIconResolutionTest.php
new file mode 100644
index 0000000..81e65eb
--- /dev/null
+++ b/tests/Unit/Renderer/DatatableRendererIconResolutionTest.php
@@ -0,0 +1,215 @@
+addRowAction(
+ name: 'view',
+ route: 'app_user_show',
+ icon: 'explicit-icon',
+ );
+
+ $html = $this->createRenderer()->renderBody($definition, $this->createEmptyResult());
+
+ self::assertStringContainsString('explicit-icon', $html);
+ }
+
+ public function test_default_row_action_icon_resolved(): void
+ {
+ $definition = new DatatableDefinition('users');
+ $definition->addRowAction(
+ name: 'edit',
+ route: 'app_user_edit',
+ );
+
+ $html = $this->createRenderer()->renderBody($definition, $this->createEmptyResult());
+
+ self::assertStringContainsString('bi bi-pencil', $html);
+ }
+
+ public function test_default_global_action_icon_resolved(): void
+ {
+ $definition = new DatatableDefinition('users');
+ $definition->addGlobalAction(
+ name: 'create',
+ route: 'app_user_create',
+ );
+
+ $html = $this->createRenderer()->render($definition);
+
+ self::assertStringContainsString('bi bi-plus-lg', $html);
+ }
+
+ public function test_default_bulk_action_icon_resolved(): void
+ {
+ $definition = new DatatableDefinition('users');
+ $definition->addBulkAction(
+ name: 'process',
+ route: 'app_user_bulk_process',
+ );
+
+ $html = $this->createRenderer()->render($definition);
+
+ self::assertStringContainsString('bi bi-collection', $html);
+ }
+
+ public function test_labels_remain_visible(): void
+ {
+ $definition = new DatatableDefinition('users');
+ $definition->addRowAction(
+ name: 'view',
+ route: 'app_user_show',
+ label: 'View Details',
+ );
+
+ $html = $this->createRenderer()->renderBody($definition, $this->createEmptyResult());
+
+ self::assertStringContainsString('bi bi-eye', $html);
+ self::assertStringContainsString('View Details', $html);
+ }
+
+ public function test_filter_icons_rendered(): void
+ {
+ $definition = new DatatableDefinition('users');
+ $definition->addColumn('username');
+ $definition->addFilter('username', 'username');
+
+ $html = $this->createRenderer()->renderHeader($definition, [
+ 'filterLayout' => 'header',
+ ]);
+
+ self::assertStringContainsString('bi bi-funnel', $html);
+ }
+
+ public function test_filter_active_icon_rendered(): void
+ {
+ $definition = new DatatableDefinition('users');
+ $definition->addColumn('username');
+ $definition->addFilter('username', 'username');
+
+ $html = $this->createRenderer()->renderHeader($definition, [
+ 'filterLayout' => 'header',
+ 'filters' => ['username' => 'admin'],
+ ]);
+
+ self::assertStringContainsString('bi bi-funnel-fill', $html);
+ }
+
+ public function test_export_icons_rendered(): void
+ {
+ $definition = new DatatableDefinition('users');
+
+ $html = $this->createRenderer()->render($definition, [
+ 'exportFormats' => ['csv', 'xlsx'],
+ ]);
+
+ self::assertStringContainsString('bi bi-download', $html);
+ self::assertStringContainsString('bi bi-filetype-csv', $html);
+ self::assertStringContainsString('bi bi-filetype-xlsx', $html);
+ }
+
+ public function test_custom_icon_overrides(): void
+ {
+ $definition = new DatatableDefinition('users');
+ $definition->addColumn('username');
+ $definition->addFilter('username', 'username');
+
+ $iconResolver = new IconResolver([
+ 'filter' => 'custom-filter',
+ 'filter_active' => 'custom-filter-active',
+ 'export' => 'custom-export',
+ 'export_csv' => 'custom-csv',
+ 'export_xlsx' => 'custom-xlsx',
+ ]);
+
+ $renderer = $this->createRenderer($iconResolver);
+
+ $htmlHeader = $renderer->renderHeader($definition, ['filterLayout' => 'header']);
+ self::assertStringContainsString('custom-filter', $htmlHeader);
+
+ $htmlHeaderActive = $renderer->renderHeader($definition, [
+ 'filterLayout' => 'header',
+ 'filters' => ['username' => 'admin'],
+ ]);
+ self::assertStringContainsString('custom-filter-active', $htmlHeaderActive);
+
+ $html = $renderer->render($definition, ['exportFormats' => ['csv', 'xlsx']]);
+ self::assertStringContainsString('custom-export', $html);
+ self::assertStringContainsString('custom-csv', $html);
+ self::assertStringContainsString('custom-xlsx', $html);
+ }
+
+ private function createRenderer(?IconResolver $iconResolver = null): DatatableRenderer
+ {
+ return new DatatableRenderer(
+ twig: $this->createTwigEnvironment(),
+ iconResolver: $iconResolver ?? new IconResolver(),
+ urlGenerator: $this->createUrlGeneratorStub(),
+ routeParameterResolver: new RowActionRouteParameterResolver(),
+ );
+ }
+
+ private function createEmptyResult(): DatatableResult
+ {
+ return new DatatableResult(
+ rows: [['id' => 1]],
+ totalItems: 1,
+ );
+ }
+
+ private function createUrlGeneratorStub(): UrlGeneratorInterface
+ {
+ return new class implements UrlGeneratorInterface {
+ /**
+ * @param array $parameters
+ */
+ public function generate(string $name, array $parameters = [], int $referenceType = self::ABSOLUTE_PATH): string
+ {
+ return '/'.$name;
+ }
+
+ public function setContext(RequestContext $context): void
+ {
+ }
+
+ public function getContext(): RequestContext
+ {
+ return new RequestContext();
+ }
+ };
+ }
+
+ private function createTwigEnvironment(): Environment
+ {
+ $loader = new FilesystemLoader();
+ $loader->addPath(__DIR__.'/../../../templates', 'ZhorteinDatatable');
+
+ $twig = new Environment($loader, [
+ 'strict_variables' => true,
+ 'autoescape' => 'html',
+ ]);
+
+ $this->addTranslationExtension($twig);
+
+ return $twig;
+ }
+}
diff --git a/tests/Unit/Renderer/DatatableRendererRowActionVisibilityTest.php b/tests/Unit/Renderer/DatatableRendererRowActionVisibilityTest.php
index 00d6754..eda7c52 100644
--- a/tests/Unit/Renderer/DatatableRendererRowActionVisibilityTest.php
+++ b/tests/Unit/Renderer/DatatableRendererRowActionVisibilityTest.php
@@ -13,6 +13,7 @@
use Zhortein\DatatableBundle\Action\ActionVisibilityContext;
use Zhortein\DatatableBundle\Action\RowActionRouteParameterResolver;
use Zhortein\DatatableBundle\Definition\ActionDefinition;
+use Zhortein\DatatableBundle\Definition\BulkActionDefinition;
use Zhortein\DatatableBundle\Definition\DatatableDefinition;
use Zhortein\DatatableBundle\Renderer\DatatableRenderer;
use Zhortein\DatatableBundle\Result\DatatableResult;
@@ -110,7 +111,7 @@ private function createTwigEnvironment(): Environment
final class DenyActionVisibilityChecker implements ActionVisibilityCheckerInterface
{
- public function isVisible(ActionDefinition $action, ActionVisibilityContext $context): bool
+ public function isVisible(ActionDefinition|BulkActionDefinition $action, ActionVisibilityContext $context): bool
{
return false;
}
@@ -118,7 +119,7 @@ public function isVisible(ActionDefinition $action, ActionVisibilityContext $con
final class AllowOnlyAliceActionVisibilityChecker implements ActionVisibilityCheckerInterface
{
- public function isVisible(ActionDefinition $action, ActionVisibilityContext $context): bool
+ public function isVisible(ActionDefinition|BulkActionDefinition $action, ActionVisibilityContext $context): bool
{
$row = $context->getRow();
diff --git a/tests/Unit/Renderer/DatatableRendererSearchBuilderTest.php b/tests/Unit/Renderer/DatatableRendererSearchBuilderTest.php
new file mode 100644
index 0000000..5317304
--- /dev/null
+++ b/tests/Unit/Renderer/DatatableRendererSearchBuilderTest.php
@@ -0,0 +1,131 @@
+createTwigEnvironment());
+
+ $html = $renderer->render($this->createDefinition());
+
+ self::assertStringNotContainsString('data-zhortein--datatable-bundle--datatable-target="searchBuilder"', $html);
+ self::assertStringNotContainsString('data-zhortein--datatable-bundle--datatable-search-builder-value="true"', $html);
+ }
+
+ public function test_it_renders_search_builder_when_enabled(): void
+ {
+ $renderer = new DatatableRenderer($this->createTwigEnvironment());
+
+ $html = $renderer->render($this->createDefinition(), [
+ 'searchBuilder' => true,
+ ]);
+
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-target="searchBuilder"', $html);
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-search-builder-value="true"', $html);
+ self::assertStringContainsString('Search Builder', $html);
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-target="searchBuilderConditions"', $html);
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-target="searchBuilderConditionTemplate"', $html);
+ }
+
+ public function test_it_renders_search_builder_toggle_button_in_toolbar(): void
+ {
+ $renderer = new DatatableRenderer($this->createTwigEnvironment());
+
+ $html = $renderer->render($this->createDefinition(), [
+ 'searchBuilder' => true,
+ ]);
+
+ self::assertStringContainsString('data-bs-toggle="collapse"', $html);
+ self::assertStringContainsString('data-bs-target="#zhortein-datatable-users-search-builder"', $html);
+ self::assertStringContainsString('Search Builder', $html);
+ }
+
+ public function test_it_renders_operators_and_labels_as_data_attributes(): void
+ {
+ $renderer = new DatatableRenderer($this->createTwigEnvironment());
+
+ $html = $renderer->render($this->createDefinition(), [
+ 'searchBuilder' => true,
+ ]);
+
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-operators-value', $html);
+ self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-operator-labels-value', $html);
+
+ // Check for some operators in the JSON encoded attributes
+ self::assertStringContainsString('eq', $html);
+ self::assertStringContainsString('neq', $html);
+ self::assertStringContainsString('contains', $html);
+ }
+
+ public function test_it_renders_available_fields_in_template(): void
+ {
+ $renderer = new DatatableRenderer($this->createTwigEnvironment());
+
+ $html = $renderer->render($this->createDefinition(), [
+ 'searchBuilder' => true,
+ ]);
+
+ self::assertStringContainsString(' |