diff --git a/.idea/datatable-bundle.iml b/.idea/datatable-bundle.iml index 4f433e1..016e1b3 100644 --- a/.idea/datatable-bundle.iml +++ b/.idea/datatable-bundle.iml @@ -105,6 +105,7 @@ + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..576d84c --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml index 8af4890..64e24c3 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -119,6 +119,7 @@ + diff --git a/CHANGELOG.md b/CHANGELOG.md index e927a0a..3d4eec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,18 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] -## [0.2.0-alpha.1] - YYYY-MM-DD +## [0.3.0-beta.1] - 2026-06-06 + +### Added +- Added Advanced filter expressions +- Added Icon system and visual consistency +- Added Bulk actions and row selection + +### Documentation +- Added new features documentation +- Fixed few mistakes + +## [0.2.0-alpha.1] - 2026-05-16 ### Added diff --git a/README.md b/README.md index 9d62b76..a8c92ef 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A Symfony 8+ bundle for Bootstrap-first business datatables driven by PHP defini - **Twig Rendering**: Render tables with a single Twig function: `{{ zhortein_datatable() }}`. - **Ajax Fragments**: Seamless server-side updates using vanilla Stimulus. - **Data Providers**: Native support for **Doctrine ORM** and **Array** providers. -- **Filtering**: Built-in global search and permanent backend filters. +- **Filtering**: Built-in global search, toolbar/header filters, and advanced **Search Builder**. - **Actions**: Declarative row and global actions with CSRF-aware non-GET support. - **Exports**: Server-side CSV and optional XLSX exports. - **Customization**: Flexible UI/UX customization via Twig blocks and themes. @@ -91,10 +91,13 @@ final class UserDatatable implements DatatableInterface - [Providers Overview](docs/providers.md) - [Doctrine Provider](docs/doctrine-provider.md) - [Filters](docs/filters.md) +- [Advanced Filters](docs/advanced-filters.md) - [Actions & Security](docs/actions.md) +- [Bulk Actions & Selection](docs/bulk-actions.md) - [Exports](docs/exports.md) -- [Theming & Templates](docs/theming.md) - [UI/UX & Controls](docs/ui-ux.md) +- [Icon System](docs/icons.md) +- [Theming & Templates](docs/theming.md) - [Frontend Test Strategy](docs/frontend-tests.md) - [Roadmap](docs/roadmap.md) diff --git a/assets/controllers/datatable_controller.js b/assets/controllers/datatable_controller.js index dd8625b..604095e 100644 --- a/assets/controllers/datatable_controller.js +++ b/assets/controllers/datatable_controller.js @@ -22,6 +22,15 @@ export default class extends Controller { 'confirmationModal', 'confirmationMessage', 'confirmationConfirmButton', + 'selectAllCheckbox', + 'rowCheckbox', + 'selectedCount', + 'bulkToolbar', + 'bulkAction', + 'searchBuilder', + 'searchBuilderConditions', + 'searchBuilderConditionTemplate', + 'searchBuilderGroupTemplate', ]; static values = { @@ -35,6 +44,7 @@ export default class extends Controller { sortDirection: { type: String, default: 'asc' }, autoLoad: { type: Boolean, default: true }, filterLayout: String, + searchBuilder: { type: Boolean, default: false }, booleanDisplayMode: String, paginationSize: { type: String, default: 'default' }, tableSmall: { type: Boolean, default: false }, @@ -48,6 +58,7 @@ export default class extends Controller { this.pendingConfirmationTarget = null; this.pendingConfirmationType = null; this.confirmationModalInstance = null; + this.selectedIds = new Set(); this.updateActiveFilterState(); @@ -183,6 +194,10 @@ export default class extends Controller { executeConfirmedTarget(target) { if (target instanceof HTMLFormElement) { + if (target.hasAttribute('data-zhortein--datatable-bundle--datatable-selected-rows-parameter-name')) { + this.injectSelectedIds(target); + } + target.submit(); return; @@ -193,6 +208,38 @@ export default class extends Controller { } } + submitBulkAction(event) { + if (this.selectedIds.size === 0) { + event.preventDefault(); + + return; + } + + const message = this.resolveConfirmationMessage(event.currentTarget); + + if (message !== null) { + this.confirmAction(event); + + return; + } + + this.injectSelectedIds(event.currentTarget); + } + + injectSelectedIds(form) { + const parameterName = form.getAttribute('data-zhortein--datatable-bundle--datatable-selected-rows-parameter-name') || 'ids'; + + form.querySelectorAll(`input[name="${parameterName}[]"]`).forEach((input) => input.remove()); + + this.selectedIds.forEach((id) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = `${parameterName}[]`; + input.value = id; + form.appendChild(input); + }); + } + search(event) { this.searchValue = event.target.value; this.pageValue = 1; @@ -322,6 +369,64 @@ export default class extends Controller { this.refresh(); } + selectRow(event) { + const checkbox = event.target; + const id = checkbox.value; + + if (checkbox.checked) { + this.selectedIds.add(id); + } else { + this.selectedIds.delete(id); + } + + this.updateSelectionUI(); + } + + selectAll(event) { + const checkbox = event.target; + const isChecked = checkbox.checked; + + this.rowCheckboxTargets.forEach((rowCheckbox) => { + rowCheckbox.checked = isChecked; + const id = rowCheckbox.value; + + if (isChecked) { + this.selectedIds.add(id); + } else { + this.selectedIds.delete(id); + } + }); + + this.updateSelectionUI(); + } + + updateSelectionUI() { + const selectedCount = this.selectedIds.size; + const visibleRowsCount = this.rowCheckboxTargets.length; + const selectedVisibleRowsCount = this.rowCheckboxTargets.filter((cb) => cb.checked).length; + + if (this.hasSelectAllCheckboxTarget) { + this.selectAllCheckboxTarget.checked = visibleRowsCount > 0 && selectedVisibleRowsCount === visibleRowsCount; + this.selectAllCheckboxTarget.indeterminate = selectedVisibleRowsCount > 0 && selectedVisibleRowsCount < visibleRowsCount; + } + + if (this.hasSelectedCountTarget) { + this.selectedCountTargets.forEach((target) => { + target.textContent = String(selectedCount); + }); + } + + if (this.hasBulkToolbarTarget) { + this.bulkToolbarTarget.hidden = selectedCount === 0; + } + + if (this.hasBulkActionTarget) { + this.bulkActionTargets.forEach((target) => { + target.disabled = selectedCount === 0; + }); + } + } + buildFragmentsUrl() { const url = new URL(this.fragmentsUrlValue, window.location.origin); @@ -352,6 +457,7 @@ export default class extends Controller { } this.appendFilterParameters(url.searchParams); + this.appendAdvancedFilterParameters(url.searchParams); this.appendColumnVisibilityParameters(url.searchParams); return url.toString(); @@ -441,6 +547,312 @@ export default class extends Controller { .filter((control) => control instanceof HTMLInputElement && control.type === 'checkbox'); } + addSearchBuilderCondition(event) { + if (event) event.preventDefault(); + + if (!this.hasSearchBuilderConditionTemplateTarget) { + return; + } + + const container = this.resolveSearchBuilderConditionsContainer(event); + if (container === null) { + return; + } + + const template = this.searchBuilderConditionTemplateTarget.content.cloneNode(true); + container.appendChild(template); + } + + addSearchBuilderSubgroup(event) { + if (event) event.preventDefault(); + + if (!this.hasSearchBuilderGroupTemplateTarget) { + return; + } + + const container = this.resolveSearchBuilderConditionsContainer(event); + if (container === null) { + return; + } + + const template = this.searchBuilderGroupTemplateTarget.content.cloneNode(true); + container.appendChild(template); + } + + removeSearchBuilderCondition(event) { + if (event) event.preventDefault(); + + const condition = event.currentTarget.closest('.zhortein-datatable__search-builder-condition'); + if (condition) { + condition.remove(); + this.refresh(); + } + } + + removeSearchBuilderSubgroup(event) { + if (event) event.preventDefault(); + + const group = event.currentTarget.closest('.zhortein-datatable__search-builder-group--nested'); + if (group) { + group.remove(); + this.refresh(); + } + } + + clearSearchBuilder(event) { + if (event) event.preventDefault(); + + if (this.hasSearchBuilderConditionsTarget) { + this.searchBuilderConditionsTarget.innerHTML = ''; + } + + const rootGroup = this.getRootSearchBuilderGroupElement(); + if (rootGroup !== null) { + const logicSelect = rootGroup.querySelector(':scope > .zhortein-datatable__search-builder-header select.zhortein-datatable__search-builder-logic') + || rootGroup.querySelector('select.zhortein-datatable__search-builder-logic') + || (this.hasSearchBuilderTarget ? this.searchBuilderTarget.querySelector('select[data-action*="updateSearchBuilderLogic"]') : null); + if (logicSelect) { + logicSelect.value = 'AND'; + } + } + + this.refresh(); + } + + updateSearchBuilderLogic() { + this.refresh(); + } + + resolveSearchBuilderConditionsContainer(event) { + if (event && event.currentTarget instanceof Element) { + const group = event.currentTarget.closest('.zhortein-datatable__search-builder-group'); + if (group) { + const container = group.querySelector(':scope > .zhortein-datatable__search-builder-conditions'); + if (container) { + return container; + } + } + } + + return this.hasSearchBuilderConditionsTarget ? this.searchBuilderConditionsTarget : null; + } + + getRootSearchBuilderGroupElement() { + if (!this.hasSearchBuilderTarget) { + return null; + } + + return this.searchBuilderTarget.querySelector('.zhortein-datatable__search-builder-group--root') + || this.searchBuilderTarget; + } + + onSearchBuilderFieldChange(event) { + const select = event.target; + const condition = select.closest('.zhortein-datatable__search-builder-condition'); + const operatorSelect = condition.querySelector('select[data-action*="onSearchBuilderOperatorChange"]'); + const valueContainer = condition.querySelector('.zhortein-datatable__search-builder-value-container'); + + const selectedOption = select.options[select.selectedIndex]; + const type = selectedOption.dataset.type; + + const i18n = JSON.parse(this.searchBuilderTarget.getAttribute('data-zhortein--datatable-bundle--datatable-i18n-value')); + + if (!type) { + operatorSelect.disabled = true; + operatorSelect.innerHTML = ``; + valueContainer.innerHTML = ''; + return; + } + + operatorSelect.disabled = false; + const typeOperators = JSON.parse(this.searchBuilderTarget.getAttribute('data-zhortein--datatable-bundle--datatable-operators-value'))[type] || []; + const operatorLabels = JSON.parse(this.searchBuilderTarget.getAttribute('data-zhortein--datatable-bundle--datatable-operator-labels-value')); + + let allowedOperators = null; + const rawAllowed = selectedOption.dataset.allowedOperators; + if (typeof rawAllowed === 'string' && rawAllowed !== '') { + try { + const parsed = JSON.parse(rawAllowed); + if (Array.isArray(parsed)) { + allowedOperators = parsed; + } + } catch (e) { + allowedOperators = null; + } + } + + const operators = allowedOperators === null + ? typeOperators + : typeOperators.filter((op) => allowedOperators.includes(op)); + + operatorSelect.innerHTML = `` + + operators.map((op) => ``).join(''); + + this.updateSearchBuilderValueInput(condition, type, selectedOption.dataset.choices); + } + + onSearchBuilderOperatorChange() { + this.refresh(); + } + + updateSearchBuilderValueInput(condition, type, choicesJson) { + const valueContainer = condition.querySelector('.zhortein-datatable__search-builder-value-container'); + const operatorSelect = condition.querySelector('select[data-action*="onSearchBuilderOperatorChange"]'); + const operator = operatorSelect.value; + const i18n = JSON.parse(this.searchBuilderTarget.getAttribute('data-zhortein--datatable-bundle--datatable-i18n-value')); + + if (operator === 'is_null' || operator === 'is_not_null') { + valueContainer.innerHTML = ''; + this.refresh(); + + return; + } + + let html = ''; + if (type === 'choice' && choicesJson) { + const choices = JSON.parse(choicesJson); + const isMultiple = operator === 'in' || operator === 'not_in'; + html = `'; + } else if (type === 'boolean') { + html = ``; + } else if (operator === 'between') { + const inputType = (type === 'date' || type === 'date_range') ? 'date' : 'number'; + html = `
+ + +
`; + } else { + const inputType = (type === 'date' || type === 'date_range') ? 'date' : (type === 'number' ? 'number' : 'text'); + html = ``; + } + + valueContainer.innerHTML = html; + this.refresh(); + } + + appendAdvancedFilterParameters(searchParams) { + if (!this.hasSearchBuilderTarget) { + return; + } + + const rootGroup = this.getRootSearchBuilderGroupElement(); + if (rootGroup === null) { + return; + } + + const serialized = this.serializeSearchBuilderGroup(rootGroup); + if (serialized === null || serialized.conditions.length === 0) { + return; + } + + this.appendSearchBuilderEntries(searchParams, 'advancedFilters', serialized); + } + + serializeSearchBuilderGroup(groupElement) { + const logicSelect = groupElement.querySelector(':scope > .zhortein-datatable__search-builder-header select.zhortein-datatable__search-builder-logic') + || groupElement.querySelector(':scope > select.zhortein-datatable__search-builder-logic') + || groupElement.querySelector(':scope > select[data-action*="updateSearchBuilderLogic"]') + || (groupElement.classList.contains('zhortein-datatable__search-builder-group--root') && this.hasSearchBuilderTarget + ? this.searchBuilderTarget.querySelector('select[data-action*="updateSearchBuilderLogic"]') + : null); + + const logicValue = (logicSelect && typeof logicSelect.value === 'string' && logicSelect.value !== '') + ? logicSelect.value + : 'AND'; + + const conditionsContainer = groupElement.querySelector(':scope > .zhortein-datatable__search-builder-conditions') + || (groupElement.classList.contains('zhortein-datatable__search-builder-group--root') && this.hasSearchBuilderConditionsTarget + ? this.searchBuilderConditionsTarget + : null); + + const conditions = []; + + if (conditionsContainer) { + Array.from(conditionsContainer.children).forEach((child) => { + if (child.classList.contains('zhortein-datatable__search-builder-condition')) { + const serialized = this.serializeSearchBuilderCondition(child); + if (serialized !== null) { + conditions.push(serialized); + } + } else if (child.classList.contains('zhortein-datatable__search-builder-group')) { + const serialized = this.serializeSearchBuilderGroup(child); + if (serialized !== null && serialized.conditions.length > 0) { + conditions.push(serialized); + } + } + }); + } + + return { logic: String(logicValue).toLowerCase(), conditions }; + } + + serializeSearchBuilderCondition(conditionElement) { + const fieldSelect = conditionElement.querySelector('select[data-action*="onSearchBuilderFieldChange"]'); + const operatorSelect = conditionElement.querySelector('select[data-action*="onSearchBuilderOperatorChange"]'); + + if (!fieldSelect || !operatorSelect) { + return null; + } + + const field = fieldSelect.value; + const operator = operatorSelect.value; + + if (!field || !operator) { + return null; + } + + const valueContainer = conditionElement.querySelector('.zhortein-datatable__search-builder-value-container'); + const inputs = valueContainer ? valueContainer.querySelectorAll('input, select') : []; + + let value = null; + + if (operator === 'is_null' || operator === 'is_not_null') { + value = null; + } else if (inputs.length === 1) { + const input = inputs[0]; + if (input instanceof HTMLSelectElement && input.multiple) { + value = Array.from(input.selectedOptions).map((opt) => opt.value); + } else { + value = input.value; + } + } else if (inputs.length === 2) { + value = { from: inputs[0].value, to: inputs[1].value }; + } + + const result = { field, operator }; + + if (value !== null) { + result.value = value; + } + + return result; + } + + appendSearchBuilderEntries(searchParams, prefix, payload) { + if (payload === null || typeof payload !== 'object') { + searchParams.set(prefix, String(payload ?? '')); + return; + } + + if (Array.isArray(payload)) { + payload.forEach((item, index) => { + this.appendSearchBuilderEntries(searchParams, `${prefix}[${index}]`, item); + }); + return; + } + + Object.entries(payload).forEach(([key, val]) => { + this.appendSearchBuilderEntries(searchParams, `${prefix}[${key}]`, val); + }); + } + resolveConfirmationMessage(target) { if (!(target instanceof HTMLElement)) { return null; @@ -467,6 +879,8 @@ export default class extends Controller { if (this.hasBodyTarget && typeof payload.body === 'string') { this.bodyTarget.innerHTML = payload.body; + this.selectedIds.clear(); + this.updateSelectionUI(); } if (this.hasPaginationTarget && typeof payload.pagination === 'string') { @@ -575,6 +989,7 @@ export default class extends Controller { } this.appendFilterParameters(searchParams); + this.appendAdvancedFilterParameters(searchParams); this.appendColumnVisibilityParameters(searchParams); } diff --git a/composer.lock b/composer.lock index c1785c2..d57a272 100644 --- a/composer.lock +++ b/composer.lock @@ -161,20 +161,20 @@ }, { "name": "symfony/config", - "version": "v8.0.10", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "de665e669412ec2effe004d90298dbbdaf6e7e8b" + "reference": "429783a0c649696f2058ea5ab5315f082dba6de9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/de665e669412ec2effe004d90298dbbdaf6e7e8b", - "reference": "de665e669412ec2effe004d90298dbbdaf6e7e8b", + "url": "https://api.github.com/repos/symfony/config/zipball/429783a0c649696f2058ea5ab5315f082dba6de9", + "reference": "429783a0c649696f2058ea5ab5315f082dba6de9", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", "symfony/filesystem": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" @@ -215,7 +215,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v8.0.10" + "source": "https://github.com/symfony/config/tree/v8.1.0" }, "funding": [ { @@ -235,28 +235,28 @@ "type": "tidelift" } ], - "time": "2026-05-04T13:41:39+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/dependency-injection", - "version": "v8.0.10", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "6fc374dae45a7633a5865da7fc2908baf29d4900" + "reference": "b6ba1f45127106885de4b77558c5ecca8feb1e1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/6fc374dae45a7633a5865da7fc2908baf29d4900", - "reference": "6fc374dae45a7633a5865da7fc2908baf29d4900", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/b6ba1f45127106885de4b77558c5ecca8feb1e1b", + "reference": "b6ba1f45127106885de4b77558c5ecca8feb1e1b", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/service-contracts": "^3.6", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/var-exporter": "^8.1" }, "conflict": { "ext-psr": "<1.1|>=2" @@ -296,7 +296,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v8.0.10" + "source": "https://github.com/symfony/dependency-injection/tree/v8.1.0" }, "funding": [ { @@ -316,7 +316,7 @@ "type": "tidelift" } ], - "time": "2026-05-06T11:55:35+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/deprecation-contracts", @@ -391,20 +391,20 @@ }, { "name": "symfony/error-handler", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517" + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/c1119fe8dcfc3825ec74ec061b96ef0c8f281517", - "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5", + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", "symfony/polyfill-php85": "^1.32", "symfony/var-dumper": "^7.4|^8.0" @@ -448,7 +448,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v8.0.8" + "source": "https://github.com/symfony/error-handler/tree/v8.1.0" }, "funding": [ { @@ -468,24 +468,25 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { @@ -533,7 +534,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" }, "funding": [ { @@ -553,7 +554,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -637,20 +638,21 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", - "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, @@ -683,7 +685,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.11" + "source": "https://github.com/symfony/filesystem/tree/v8.1.0" }, "funding": [ { @@ -703,24 +705,25 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:39:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b" + "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/02656f7ebeae5c155d659e946f6b3a33df24051b", - "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/af11474600f06718086c2cda4fa6fa8d0a672e7e", + "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.1" }, "conflict": { @@ -763,7 +766,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.0.8" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.0" }, "funding": [ { @@ -783,34 +786,38 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "20d3680373f4b791903c09e74b45402b4aeda71c" + "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/20d3680373f4b791903c09e74b45402b4aeda71c", - "reference": "20d3680373f4b791903c09e74b45402b4aeda71c", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", + "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^7.4|^8.0", "symfony/event-dispatcher": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { + "symfony/dependency-injection": "<8.1", "symfony/flex": "<2.10", "symfony/http-client-contracts": "<2.5", "symfony/translation-contracts": "<2.5", + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", "twig/twig": "<3.21" }, "provide": { @@ -823,13 +830,14 @@ "symfony/config": "^7.4|^8.0", "symfony/console": "^7.4|^8.0", "symfony/css-selector": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", "symfony/dom-crawler": "^7.4|^8.0", "symfony/expression-language": "^7.4|^8.0", "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3", "symfony/process": "^7.4|^8.0", "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", "symfony/routing": "^7.4|^8.0", "symfony/serializer": "^7.4|^8.0", "symfony/stopwatch": "^7.4|^8.0", @@ -837,7 +845,7 @@ "symfony/translation-contracts": "^2.5|^3", "symfony/uid": "^7.4|^8.0", "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", "symfony/var-exporter": "^7.4|^8.0", "twig/twig": "^3.21" }, @@ -867,7 +875,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.11" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.0" }, "funding": [ { @@ -887,24 +895,24 @@ "type": "tidelift" } ], - "time": "2026-05-13T18:07:14+00:00" + "time": "2026-05-29T08:46:08+00:00" }, { "name": "symfony/password-hasher", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/password-hasher.git", - "reference": "57ee968d3c38301ed3e5b838f850a10f2d06a7f6" + "reference": "6934d16beaa4677f2c4584229fff1b51099dd7af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/password-hasher/zipball/57ee968d3c38301ed3e5b838f850a10f2d06a7f6", - "reference": "57ee968d3c38301ed3e5b838f850a10f2d06a7f6", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/6934d16beaa4677f2c4584229fff1b51099dd7af", + "reference": "6934d16beaa4677f2c4584229fff1b51099dd7af", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "require-dev": { "symfony/console": "^7.4|^8.0", @@ -940,7 +948,7 @@ "password" ], "support": { - "source": "https://github.com/symfony/password-hasher/tree/v8.0.8" + "source": "https://github.com/symfony/password-hasher/tree/v8.1.0" }, "funding": [ { @@ -960,7 +968,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1046,17 +1054,104 @@ "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/polyfill-mbstring", + "name": "symfony/polyfill-deepclone", "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", + "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "provide": { + "ext-deepclone": "*" + }, + "suggest": { + "ext-deepclone": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\DeepClone\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the deepclone extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "deepclone", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-26T13:03:27+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { @@ -1108,7 +1203,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { @@ -1128,20 +1223,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -1188,7 +1283,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -1208,24 +1303,24 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:10:57+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/routing", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038" + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038", - "reference": "75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038", + "url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { @@ -1268,7 +1363,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.0.9" + "source": "https://github.com/symfony/routing/tree/v8.1.0" }, "funding": [ { @@ -1288,24 +1383,24 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/security-core", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "7aa47b511c07734bbb3490046ca8cdff1bf4fbef" + "reference": "a8239abe61dafdd0c01c0b4019138b2855717f97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/7aa47b511c07734bbb3490046ca8cdff1bf4fbef", - "reference": "7aa47b511c07734bbb3490046ca8cdff1bf4fbef", + "url": "https://api.github.com/repos/symfony/security-core/zipball/a8239abe61dafdd0c01c0b4019138b2855717f97", + "reference": "a8239abe61dafdd0c01c0b4019138b2855717f97", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/event-dispatcher-contracts": "^2.5|^3", "symfony/password-hasher": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3" @@ -1320,6 +1415,7 @@ "symfony/expression-language": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/ldap": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", "symfony/string": "^7.4|^8.0", "symfony/translation": "^7.4|^8.0", "symfony/validator": "^7.4|^8.0" @@ -1350,7 +1446,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v8.0.11" + "source": "https://github.com/symfony/security-core/tree/v8.1.0" }, "funding": [ { @@ -1370,24 +1466,25 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:07:53+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/security-csrf", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/security-csrf.git", - "reference": "83c8f60ef8d385c05ea863093c9efabe74800883" + "reference": "c865a8ee0d30b14545d7e5349b8e443f4fa9dc3f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-csrf/zipball/83c8f60ef8d385c05ea863093c9efabe74800883", - "reference": "83c8f60ef8d385c05ea863093c9efabe74800883", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/c865a8ee0d30b14545d7e5349b8e443f4fa9dc3f", + "reference": "c865a8ee0d30b14545d7e5349b8e443f4fa9dc3f", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/security-core": "^7.4|^8.0" }, "require-dev": { @@ -1421,7 +1518,7 @@ "description": "Symfony Security Component - CSRF Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-csrf/tree/v8.0.8" + "source": "https://github.com/symfony/security-csrf/tree/v8.1.0" }, "funding": [ { @@ -1441,7 +1538,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/service-contracts", @@ -1532,20 +1629,20 @@ }, { "name": "symfony/translation", - "version": "v8.0.10", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", - "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-mbstring": "^1.0", "symfony/translation-contracts": "^3.6.1" }, @@ -1601,7 +1698,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.10" + "source": "https://github.com/symfony/translation/tree/v8.1.0" }, "funding": [ { @@ -1621,7 +1718,7 @@ "type": "tidelift" } ], - "time": "2026-05-06T11:30:54+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/translation-contracts", @@ -1707,28 +1804,28 @@ }, { "name": "symfony/twig-bridge", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0" + "reference": "25bb8c01edaab85e13142f6010df09b990388343" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/a892d0b7f3d5d51b35895467e48aafbd1f2612a0", - "reference": "a892d0b7f3d5d51b35895467e48aafbd1f2612a0", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/25bb8c01edaab85e13142f6010df09b990388343", + "reference": "25bb8c01edaab85e13142f6010df09b990388343", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/translation-contracts": "^2.5|^3", - "twig/twig": "^3.21" + "twig/twig": "^3.25" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/form": "<7.4.4|>8.0,<8.0.4", - "symfony/mime": "<7.4.8|>8.0,<8.0.8" + "symfony/mime": "<7.4.9|>8.0,<8.0.9" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3|^4", @@ -1746,7 +1843,7 @@ "symfony/http-foundation": "^7.4|^8.0", "symfony/http-kernel": "^7.4|^8.0", "symfony/intl": "^7.4|^8.0", - "symfony/mime": "^7.4.8|^8.0.8", + "symfony/mime": "^7.4.9|^8.0.9", "symfony/polyfill-intl-icu": "^1.0", "symfony/property-info": "^7.4|^8.0", "symfony/routing": "^7.4|^8.0", @@ -1791,7 +1888,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v8.0.8" + "source": "https://github.com/symfony/twig-bridge/tree/v8.1.0" }, "funding": [ { @@ -1811,25 +1908,25 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/twig-bundle", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1" + "reference": "b7f4a471a07b8b52174d153e4db12f46954192ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1", - "reference": "f83767b78e2580ca9fe9a2cf6fcff19cd5389bc1", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/b7f4a471a07b8b52174d153e4db12f46954192ed", + "reference": "b7f4a471a07b8b52174d153e4db12f46954192ed", "shasum": "" }, "require": { "composer-runtime-api": ">=2.1", - "php": ">=8.4", + "php": ">=8.4.1", "symfony/config": "^7.4|^8.0", "symfony/dependency-injection": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", @@ -1875,7 +1972,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v8.0.8" + "source": "https://github.com/symfony/twig-bundle/tree/v8.1.0" }, "funding": [ { @@ -1895,24 +1992,24 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1" + "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", - "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", + "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { @@ -1962,7 +2059,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.0.8" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.0" }, "funding": [ { @@ -1982,24 +2079,26 @@ "type": "tidelift" } ], - "time": "2026-03-31T07:15:36+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/var-exporter", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "24cf67be4dd0926e4413635418682f4fff831412" + "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/24cf67be4dd0926e4413635418682f4fff831412", - "reference": "24cf67be4dd0926e4413635418682f4fff831412", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-deepclone": "^1.37" }, "require-dev": { "symfony/property-access": "^7.4|^8.0", @@ -2029,11 +2128,12 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects", "homepage": "https://symfony.com", "keywords": [ "clone", "construct", + "deep-clone", "export", "hydrate", "instantiate", @@ -2042,7 +2142,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.0.9" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.0" }, "funding": [ { @@ -2062,31 +2162,32 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/yaml", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "48046fbd5567bd1717f278eaa2cfc3131f489984" + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/48046fbd5567bd1717f278eaa2cfc3131f489984", - "reference": "48046fbd5567bd1717f278eaa2cfc3131f489984", + "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8" }, "conflict": { "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^7.4|^8.0" + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" }, "bin": [ "Resources/bin/yaml-lint" @@ -2117,7 +2218,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.11" + "source": "https://github.com/symfony/yaml/tree/v8.1.0" }, "funding": [ { @@ -2137,20 +2238,20 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:07:53+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "twig/twig", - "version": "v3.24.0", + "version": "v3.27.1", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" + "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", - "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74", "shasum": "" }, "require": { @@ -2205,7 +2306,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.24.0" + "source": "https://github.com/twigphp/Twig/tree/v3.27.1" }, "funding": [ { @@ -2217,7 +2318,7 @@ "type": "tidelift" } ], - "time": "2026-03-17T21:31:11+00:00" + "time": "2026-05-30T17:09:26+00:00" } ], "packages-dev": [ @@ -2749,16 +2850,16 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "3.2.2", + "version": "3.2.3", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "af84173db6978c3d2688ea3bcf3a91720b0704ce" + "reference": "9670526ce9a8512c207da3ea4a8103127369f602" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/af84173db6978c3d2688ea3bcf3a91720b0704ce", - "reference": "af84173db6978c3d2688ea3bcf3a91720b0704ce", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/9670526ce9a8512c207da3ea4a8103127369f602", + "reference": "9670526ce9a8512c207da3ea4a8103127369f602", "shasum": "" }, "require": { @@ -2782,14 +2883,15 @@ "require-dev": { "doctrine/coding-standard": "^14", "doctrine/orm": "^3.4.4", - "phpstan/phpstan": "2.1.1", + "phpstan/phpstan": "^2.1.13", "phpstan/phpstan-phpunit": "2.0.3", "phpstan/phpstan-strict-rules": "^2", - "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-symfony": "^2.0.9", "phpunit/phpunit": "^12.3.10", "psr/log": "^3.0", "symfony/doctrine-messenger": "^6.4 || ^7.0 || ^8.0", "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", "symfony/messenger": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.0 || ^8.0", "symfony/security-bundle": "^6.4 || ^7.0 || ^8.0", @@ -2844,7 +2946,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/3.2.2" + "source": "https://github.com/doctrine/DoctrineBundle/tree/3.2.3" }, "funding": [ { @@ -2860,7 +2962,7 @@ "type": "tidelift" } ], - "time": "2025-12-24T12:24:29+00:00" + "time": "2026-05-26T19:29:54+00:00" }, { "name": "doctrine/event-manager", @@ -3191,16 +3293,16 @@ }, { "name": "doctrine/orm", - "version": "3.6.5", + "version": "3.6.7", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "7e88b416153dceeb563352ca2b12465f09eea173" + "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/7e88b416153dceeb563352ca2b12465f09eea173", - "reference": "7e88b416153dceeb563352ca2b12465f09eea173", + "url": "https://api.github.com/repos/doctrine/orm/zipball/bc217c0e19c3a9eadfa67697143b87c9ba01272c", + "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c", "shasum": "" }, "require": { @@ -3273,9 +3375,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.6.5" + "source": "https://github.com/doctrine/orm/tree/3.6.7" }, - "time": "2026-05-11T06:47:19+00:00" + "time": "2026-05-25T16:45:47+00:00" }, { "name": "doctrine/persistence", @@ -3605,23 +3707,23 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.2", + "version": "v3.95.4", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1" + "reference": "3f8f68856837a77e1f1d870354eca3c8747f2f72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a28d88a5e172b27e78d0816992b15a9df3da20f1", - "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3f8f68856837a77e1f1d870354eca3c8747f2f72", + "reference": "3f8f68856837a77e1f1d870354eca3c8747f2f72", "shasum": "" }, "require": { "clue/ndjson-react": "^1.3", "composer/semver": "^3.4", "composer/xdebug-handler": "^3.0.5", - "ergebnis/agent-detector": "^1.1.1", + "ergebnis/agent-detector": "^1.2", "ext-filter": "*", "ext-hash": "*", "ext-json": "*", @@ -3638,10 +3740,10 @@ "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", - "symfony/polyfill-mbstring": "^1.33", - "symfony/polyfill-php80": "^1.33", - "symfony/polyfill-php81": "^1.33", - "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, @@ -3655,9 +3757,9 @@ "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", - "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php85": "^1.37", "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.8" + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.11" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -3698,7 +3800,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.2" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.4" }, "funding": [ { @@ -3706,7 +3808,7 @@ "type": "github" } ], - "time": "2026-05-15T09:20:44+00:00" + "time": "2026-06-03T18:02:44+00:00" }, { "name": "friendsoftwig/twigcs", @@ -3883,16 +3985,16 @@ }, { "name": "openspout/openspout", - "version": "v5.7.0", + "version": "v5.7.2", "source": { "type": "git", "url": "https://github.com/openspout/openspout.git", - "reference": "b82aa46191802c187f67e9639ddc604b9e7a73e9" + "reference": "f383ae8ab4c735b6a6a0cef396e9799900584f3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/openspout/openspout/zipball/b82aa46191802c187f67e9639ddc604b9e7a73e9", - "reference": "b82aa46191802c187f67e9639ddc604b9e7a73e9", + "url": "https://api.github.com/repos/openspout/openspout/zipball/f383ae8ab4c735b6a6a0cef396e9799900584f3e", + "reference": "f383ae8ab4c735b6a6a0cef396e9799900584f3e", "shasum": "" }, "require": { @@ -3906,13 +4008,13 @@ "require-dev": { "ext-fileinfo": "*", "ext-zlib": "*", - "friendsofphp/php-cs-fixer": "^3.95.1", - "infection/infection": "^0.32.7", + "friendsofphp/php-cs-fixer": "^3.95.2", + "infection/infection": "^0.33.2", "phpbench/phpbench": "^1.6.1", - "phpstan/phpstan": "^2.1.53", + "phpstan/phpstan": "^2.2.1", "phpstan/phpstan-phpunit": "^2.0.16", - "phpstan/phpstan-strict-rules": "^2.0.10", - "phpunit/phpunit": "^13.1.7" + "phpstan/phpstan-strict-rules": "^2.0.11", + "phpunit/phpunit": "^13.1.13" }, "suggest": { "ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)", @@ -3960,7 +4062,7 @@ ], "support": { "issues": "https://github.com/openspout/openspout/issues", - "source": "https://github.com/openspout/openspout/tree/v5.7.0" + "source": "https://github.com/openspout/openspout/tree/v5.7.2" }, "funding": [ { @@ -3972,7 +4074,7 @@ "type": "github" } ], - "time": "2026-04-29T07:42:36+00:00" + "time": "2026-05-29T11:43:33+00:00" }, { "name": "phar-io/manifest", @@ -4142,11 +4244,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.54", + "version": "2.2.2", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", - "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", "shasum": "" }, "require": { @@ -4169,6 +4271,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -4191,20 +4304,20 @@ "type": "github" } ], - "time": "2026-04-29T13:31:09+00:00" + "time": "2026-06-05T09:00:01+00:00" }, { "name": "phpstan/phpstan-doctrine", - "version": "2.0.22", + "version": "2.0.25", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "e87516b034749432d51653c0147e053e476e8c53" + "reference": "e20e8bf3223ae6eba9c4b5987c391d922e094b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/e87516b034749432d51653c0147e053e476e8c53", - "reference": "e87516b034749432d51653c0147e053e476e8c53", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/e20e8bf3223ae6eba9c4b5987c391d922e094b3c", + "reference": "e20e8bf3223ae6eba9c4b5987c391d922e094b3c", "shasum": "" }, "require": { @@ -4266,9 +4379,9 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.22" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.25" }, - "time": "2026-05-09T08:10:48+00:00" + "time": "2026-06-02T20:27:36+00:00" }, { "name": "phpstan/phpstan-phpunit", @@ -4379,16 +4492,16 @@ }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.17", + "version": "2.0.19", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "fdd0cb5f08d1980c612d6f259d825ea644ed03f4" + "reference": "546071ed7f80a89ec30909346eb7cc741800740a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/fdd0cb5f08d1980c612d6f259d825ea644ed03f4", - "reference": "fdd0cb5f08d1980c612d6f259d825ea644ed03f4", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/546071ed7f80a89ec30909346eb7cc741800740a", + "reference": "546071ed7f80a89ec30909346eb7cc741800740a", "shasum": "" }, "require": { @@ -4447,22 +4560,22 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.17" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.19" }, - "time": "2026-05-10T08:14:07+00:00" + "time": "2026-05-29T12:52:44+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.6", + "version": "12.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "876099a072646c7745f673d7aeab5382c4439691" + "reference": "186dab580576598076de6818596d12b61801880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", - "reference": "876099a072646c7745f673d7aeab5382c4439691", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", "shasum": "" }, "require": { @@ -4473,13 +4586,13 @@ "php": ">=8.3", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", - "sebastian/lines-of-code": "^4.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", "sebastian/version": "^6.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^12.5.28" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -4517,7 +4630,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" }, "funding": [ { @@ -4537,7 +4650,7 @@ "type": "tidelift" } ], - "time": "2026-04-15T08:23:17+00:00" + "time": "2026-06-01T13:24:19+00:00" }, { "name": "phpunit/php-file-iterator", @@ -4798,16 +4911,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.25", + "version": "12.5.29", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "792c2980442dfce319226b88fa845b8b6de3b333" + "reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/792c2980442dfce319226b88fa845b8b6de3b333", - "reference": "792c2980442dfce319226b88fa845b8b6de3b333", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9aa66a47db3ea70f1a468e66dd969f67e594945a", + "reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a", "shasum": "" }, "require": { @@ -4821,20 +4934,20 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-code-coverage": "^12.5.7", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.6", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.1.0", - "sebastian/exporter": "^7.0.2", - "sebastian/global-state": "^8.0.2", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", "sebastian/object-enumerator": "^7.0.0", "sebastian/recursion-context": "^7.0.1", - "sebastian/type": "^6.0.3", + "sebastian/type": "^6.0.4", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -4876,7 +4989,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.25" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.29" }, "funding": [ { @@ -4884,7 +4997,7 @@ "type": "other" } ], - "time": "2026-05-13T03:56:57+00:00" + "time": "2026-06-04T06:14:42+00:00" }, { "name": "psr/cache", @@ -5463,23 +5576,23 @@ }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -5508,7 +5621,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { @@ -5528,20 +5641,20 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.6", + "version": "7.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "c769009dee98f494e0edc3fd4f4087501688f11e" + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e", - "reference": "c769009dee98f494e0edc3fd4f4087501688f11e", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { @@ -5549,10 +5662,10 @@ "ext-mbstring": "*", "php": ">=8.3", "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0" + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^12.5.25" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -5600,7 +5713,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, "funding": [ { @@ -5620,7 +5733,7 @@ "type": "tidelift" } ], - "time": "2026-04-14T08:23:15+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", @@ -5749,23 +5862,23 @@ }, { "name": "sebastian/environment", - "version": "8.1.0", + "version": "8.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", - "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.26" }, "suggest": { "ext-posix": "*" @@ -5801,7 +5914,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" }, "funding": [ { @@ -5821,29 +5934,29 @@ "type": "tidelift" } ], - "time": "2026-04-15T12:13:01+00:00" + "time": "2026-05-25T13:40:20+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -5891,7 +6004,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { @@ -5911,30 +6024,30 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", "shasum": "" }, "require": { "php": ">=8.3", "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.28" }, "type": "library", "extra": { @@ -5965,7 +6078,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" }, "funding": [ { @@ -5985,28 +6098,28 @@ "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2026-06-01T15:10:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -6035,15 +6148,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", @@ -6237,23 +6362,23 @@ }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "6.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -6282,7 +6407,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { @@ -6302,7 +6427,7 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", @@ -6412,25 +6537,25 @@ }, { "name": "symfony/cache", - "version": "v8.0.10", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "8ff96cde73684bfa32b702f5cff1eb83b1fac429" + "reference": "ba62e0ed9ea9bc26142844a891d4a3dfceb24aed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/8ff96cde73684bfa32b702f5cff1eb83b1fac429", - "reference": "8ff96cde73684bfa32b702f5cff1eb83b1fac429", + "url": "https://api.github.com/repos/symfony/cache/zipball/ba62e0ed9ea9bc26142844a891d4a3dfceb24aed", + "reference": "ba62e0ed9ea9bc26142844a891d4a3dfceb24aed", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/cache": "^2.0|^3.0", "psr/log": "^1.1|^2|^3", "symfony/cache-contracts": "^3.6", "symfony/service-contracts": "^2.5|^3", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/var-exporter": "^8.1" }, "conflict": { "ext-redis": "<6.1", @@ -6487,7 +6612,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v8.0.10" + "source": "https://github.com/symfony/cache/tree/v8.1.0" }, "funding": [ { @@ -6507,7 +6632,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T08:24:00+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/cache-contracts", @@ -6591,23 +6716,29 @@ }, { "name": "symfony/console", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "3156577f46a38aa1b9323aad223de7a9cd426782" + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782", - "reference": "3156577f46a38aa1b9323aad223de7a9cd426782", + "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.4|^8.0" + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" @@ -6615,14 +6746,18 @@ "require-dev": { "psr/log": "^1|^2|^3", "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/http-kernel": "^7.4|^8.0", "symfony/lock": "^7.4|^8.0", "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", "symfony/process": "^7.4|^8.0", "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", @@ -6657,7 +6792,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.11" + "source": "https://github.com/symfony/console/tree/v8.1.0" }, "funding": [ { @@ -6677,26 +6812,27 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:07:53+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/doctrine-bridge", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "dfe3dddc9c22756b9b145785fb5fd4b0445cd06e" + "reference": "80daf848dd39d9ff5a0f39aa6f2bf5448aa662c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/dfe3dddc9c22756b9b145785fb5fd4b0445cd06e", - "reference": "dfe3dddc9c22756b9b145785fb5fd4b0445cd06e", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/80daf848dd39d9ff5a0f39aa6f2bf5448aa662c5", + "reference": "80daf848dd39d9ff5a0f39aa6f2bf5448aa662c5", "shasum": "" }, "require": { "doctrine/event-manager": "^2", "doctrine/persistence": "^3.1|^4", - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8", "symfony/polyfill-mbstring": "^1.0", "symfony/service-contracts": "^2.5|^3" @@ -6716,6 +6852,7 @@ "psr/log": "^1|^2|^3", "symfony/cache": "^7.4|^8.0", "symfony/config": "^7.4|^8.0", + "symfony/console": "^8.1", "symfony/dependency-injection": "^7.4|^8.0", "symfony/doctrine-messenger": "^7.4|^8.0", "symfony/expression-language": "^7.4|^8.0", @@ -6759,7 +6896,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v8.0.9" + "source": "https://github.com/symfony/doctrine-bridge/tree/v8.1.0" }, "funding": [ { @@ -6779,24 +6916,24 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-29T05:18:49+00:00" }, { "name": "symfony/finder", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8da41214757b87d97f181e3d14a4179286151007" + "reference": "58d2e767a66052c1487356f953445634a8194c64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", - "reference": "8da41214757b87d97f181e3d14a4179286151007", + "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64", + "reference": "58d2e767a66052c1487356f953445634a8194c64", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "require-dev": { "symfony/filesystem": "^7.4|^8.0" @@ -6827,7 +6964,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.8" + "source": "https://github.com/symfony/finder/tree/v8.1.0" }, "funding": [ { @@ -6847,48 +6984,50 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/framework-bundle", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "c0d53dba8de800f5dd1e9dac79683d8c59934d34" + "reference": "6a0953f4fd8b51db6136c2628af99b7193e63256" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/c0d53dba8de800f5dd1e9dac79683d8c59934d34", - "reference": "c0d53dba8de800f5dd1e9dac79683d8c59934d34", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/6a0953f4fd8b51db6136c2628af99b7193e63256", + "reference": "6a0953f4fd8b51db6136c2628af99b7193e63256", "shasum": "" }, "require": { "composer-runtime-api": ">=2.1", "ext-xml": "*", - "php": ">=8.4", + "php": ">=8.4.1", "symfony/cache": "^7.4|^8.0", "symfony/config": "^7.4.4|^8.0.4", - "symfony/dependency-injection": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^8.1", "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^7.4|^8.0", - "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/event-dispatcher": "^8.1", "symfony/filesystem": "^7.4|^8.0", "symfony/finder": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", + "symfony/http-kernel": "^8.1", "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php85": "^1.32", - "symfony/routing": "^7.4|^8.0" + "symfony/polyfill-php85": "^1.33", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^3.7", + "symfony/var-exporter": "^8.1" }, "conflict": { "doctrine/persistence": "<1.3", "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", - "symfony/console": "<7.4", + "symfony/console": "<8.1", "symfony/form": "<7.4", "symfony/json-streamer": "<7.4", - "symfony/messenger": "<7.4", + "symfony/messenger": "<7.4.10|>=8.0,<8.0.10", "symfony/mime": "<7.4.9|>=8.0,<8.0.9", "symfony/security-csrf": "<7.4", "symfony/serializer": "<7.4", @@ -6906,7 +7045,7 @@ "symfony/asset-mapper": "^7.4|^8.0", "symfony/browser-kit": "^7.4|^8.0", "symfony/clock": "^7.4|^8.0", - "symfony/console": "^7.4|^8.0", + "symfony/console": "^8.1", "symfony/css-selector": "^7.4|^8.0", "symfony/dom-crawler": "^7.4|^8.0", "symfony/dotenv": "^7.4|^8.0", @@ -6917,7 +7056,7 @@ "symfony/json-streamer": "^7.4|^8.0", "symfony/lock": "^7.4|^8.0", "symfony/mailer": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", + "symfony/messenger": "^7.4.10|^8.0.10", "symfony/mime": "^7.4.9|^8.0.9", "symfony/notifier": "^7.4|^8.0", "symfony/object-mapper": "^7.4.9|^8.0.9", @@ -6968,7 +7107,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v8.0.11" + "source": "https://github.com/symfony/framework-bundle/tree/v8.1.0" }, "funding": [ { @@ -6988,24 +7127,24 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:07:53+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/options-resolver", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", - "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", @@ -7039,7 +7178,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" + "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" }, "funding": [ { @@ -7059,20 +7198,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -7121,7 +7260,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -7141,20 +7280,20 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:13:48+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -7206,7 +7345,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -7226,7 +7365,7 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-php80", @@ -7314,16 +7453,16 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", "shasum": "" }, "require": { @@ -7370,7 +7509,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" }, "funding": [ { @@ -7390,20 +7529,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-26T12:45:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -7450,7 +7589,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -7470,24 +7609,24 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/process", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", - "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -7515,7 +7654,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.11" + "source": "https://github.com/symfony/process/tree/v8.1.0" }, "funding": [ { @@ -7535,24 +7674,24 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:56:32+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/stopwatch", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3" + "reference": "21c07b026905d596e8379caeb115d87aa479499d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3", - "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", + "reference": "21c07b026905d596e8379caeb115d87aa479499d", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/service-contracts": "^2.5|^3" }, "type": "library", @@ -7581,7 +7720,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v8.0.8" + "source": "https://github.com/symfony/stopwatch/tree/v8.1.0" }, "funding": [ { @@ -7601,24 +7740,24 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/string", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", - "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8", "symfony/polyfill-intl-grapheme": "^1.33", "symfony/polyfill-intl-normalizer": "^1.0", @@ -7671,7 +7810,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.11" + "source": "https://github.com/symfony/string/tree/v8.1.0" }, "funding": [ { @@ -7691,7 +7830,7 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:07:53+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/services.php b/config/services.php index 4152f6e..ca0d364 100644 --- a/config/services.php +++ b/config/services.php @@ -11,6 +11,8 @@ use Zhortein\DatatableBundle\Doctrine\DoctrineJoinApplier; use Zhortein\DatatableBundle\Doctrine\DoctrinePaginationApplier; use Zhortein\DatatableBundle\Export\XlsxExportWriter; +use Zhortein\DatatableBundle\Icon\IconResolver; +use Zhortein\DatatableBundle\Contract\IconResolverInterface; use Zhortein\DatatableBundle\Renderer\DatatableSummaryRenderer; use function Symfony\Component\DependencyInjection\Loader\Configurator\param; use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; @@ -25,6 +27,7 @@ use Zhortein\DatatableBundle\Doctrine\DoctrineFieldTypeGuesser; 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\Preference\DatatablePreferenceProviderInterface; @@ -49,6 +52,8 @@ $services->alias(ActionVisibilityCheckerInterface::class, AllowAllActionVisibilityChecker::class); + $services->set(AdvancedFilterExpressionFactory::class); + $services->set(DatatableDefinitionFactory::class); $services @@ -65,6 +70,13 @@ $services->alias(DatatablePreferenceProviderInterface::class, NullDatatablePreferenceProvider::class); + $services + ->set(IconResolver::class) + ->arg('$icons', param('zhortein_datatable.icons')) + ; + + $services->alias(IconResolverInterface::class, IconResolver::class); + if (interface_exists(ManagerRegistry::class)) { $services->set(DoctrineFieldTypeGuesser::class); @@ -125,6 +137,7 @@ ->arg('$theme', param('zhortein_datatable.default_theme')) ->arg('$defaultPageSize', param('zhortein_datatable.default_page_size')) ->arg('$searchEnabled', param('zhortein_datatable.search_enabled')) + ->arg('$searchBuilderEnabled', param('zhortein_datatable.search_builder_enabled')) ->arg('$actionVisibilityChecker', service(ActionVisibilityCheckerInterface::class)) ->arg('$defaultTableOptions', [ 'tableStriped' => param('zhortein_datatable.bootstrap.table_striped'), diff --git a/docs/actions.md b/docs/actions.md index 9771ecf..bec2696 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -2,20 +2,20 @@ This document explains how to declare datatable actions, handle security, and manage visibility. -The bundle supports **Row Actions** (per row) and **Global Actions** (rendered in the toolbar). +The bundle supports **Row Actions** (per row), **Global Actions** (rendered in the toolbar) and **Bulk Actions** (on selected rows). ## Status Currently implemented: - GET actions (rendered as links). - Non-GET actions (POST, PUT, DELETE - rendered as forms with CSRF protection). +- Bulk actions (on multiple selected rows). - Route parameter resolution from row data. - Action visibility checker extension point. - Optional Symfony Authorization adapter (voters). - Confirmation messages (native `window.confirm` or Bootstrap modal). Not implemented yet: -- Bulk actions (on selected rows). - Action visibility callbacks in the public API. - Advanced icon-only action accessibility model. - Async confirmations. @@ -52,6 +52,19 @@ $definition->addGlobalAction( ); ``` +### Bulk Actions +Bulk actions are used to perform operations on multiple rows. See [Bulk Actions and Selection](bulk-actions.md) for detailed documentation. + +```php +$definition->addBulkAction( + name: 'delete_selected', + route: 'app_user_bulk_delete', + label: 'Delete Selected', + className: 'btn btn-outline-danger', + confirmationMessage: 'Are you sure you want to delete the selected rows?', +); +``` + ## Security and CSRF ### Non-GET Actions @@ -101,12 +114,14 @@ By default, this uses `window.confirm()`. If Bootstrap JavaScript and a modal ta ## Customization -- **Icons**: Provide a CSS class via the `icon` option. See [UI/UX](ui-ux.md) for details. +- **Icons**: Provide a CSS class via the `icon` option. If no explicit icon is provided, the bundle attempts to resolve a default icon based on the action name (e.g., `view`, `edit`, `delete`). See [Icon System](icons.md) for details. - **Position**: Use `ActionIconPosition` enum to place icons `Before` or `After` the label. - **Attributes**: Pass arbitrary HTML attributes via the `attributes` array. ## Related documentation +- [Icon System](icons.md) +- [Bulk Actions and Selection](bulk-actions.md) - [UI/UX customization](ui-ux.md) - [Theming](theming.md) - [Architecture](architecture/overview.md) diff --git a/docs/advanced-filters.md b/docs/advanced-filters.md new file mode 100644 index 0000000..24e4ea3 --- /dev/null +++ b/docs/advanced-filters.md @@ -0,0 +1,236 @@ +# Advanced Filter Expressions + +The "Advanced Filter Expressions" system (also referred to as **Search Builder**) allows users to build complex, nested filtering logic using `AND`/`OR` groups and various operators. + +## Enabling Advanced Filters + +Advanced filters are disabled by default. You can enable them globally or per-datatable. + +### Global Enablement + +In your `zhortein_datatable.yaml` configuration: + +```yaml +zhortein_datatable: + search_builder_enabled: true +``` + +### Per-Datatable Enablement + +When rendering the datatable in Twig: + +```twig +{{ zhortein_datatable('users', { + searchBuilder: true +}) }} +``` + +## Declaring Filterable Fields + +Unlike simple filters, fields for the Search Builder must be explicitly declared in your `DatatableDefinition` using `addAdvancedFilterField()`. This ensures a strict security boundary where only intended fields are exposed to the frontend. + +```php +use Zhortein\DatatableBundle\Enum\FilterType; +use Zhortein\DatatableBundle\Enum\FilterOperator; +use Zhortein\DatatableBundle\Filter\Expression\ComparisonOperator; + +$definition->addAdvancedFilterField( + name: 'email', + field: 'e.email', + label: 'Email', + type: FilterType::Text, + allowedOperators: [ + ComparisonOperator::Equals, + ComparisonOperator::Contains, + ComparisonOperator::StartsWith, + ] +); +``` + +### Options + +| Option | Description | +|---|---| +| `name` | Public field name used in the frontend payload. | +| `field` | Provider field targeted (e.g., `e.email` or `organization.name`). | +| `label` | (Optional) Human-readable label rendered in the UI. Defaults to a capitalized version of `name`. | +| `type` | `FilterType` enum value (Text, Choice, Enum, Boolean, Date, Number). | +| `allowedOperators` | (Optional) List of operators allowed for this field. Accepts both advanced `ComparisonOperator` values and legacy/simple `FilterOperator` values. Operators are normalized internally to the advanced `ComparisonOperator` model. If empty, all operators compatible with the type are allowed. | +| `choices` | (Optional) Array of choices for `Choice` fields. | +| `enumClass` | (Optional) Backed enum class. When provided, the field type is upgraded to `FilterType::Enum` and choices are derived from the enum. | +| `nullable` | (Optional) When `true`, `is_null` / `is_not_null` operators are exposed for this field. | + +### Mixing `ComparisonOperator` and `FilterOperator` + +Both operator enums may be used in `allowedOperators`. The bundle normalizes them +to `ComparisonOperator` internally. Legacy `FilterOperator::Like` expands to +`Contains`, `StartsWith` and `EndsWith`; `FilterOperator::NotLike` maps to +`NotContains`. + +```php +// Using ComparisonOperator (advanced enum) +$definition->addAdvancedFilterField( + name: 'email', + field: 'e.email', + type: FilterType::Text, + allowedOperators: [ + ComparisonOperator::Contains, + ComparisonOperator::StartsWith, + ], +); + +// Using legacy FilterOperator (still supported) +$definition->addAdvancedFilterField( + name: 'enabled', + field: 'e.enabled', + type: FilterType::Boolean, + allowedOperators: [ + FilterOperator::Equals, + FilterOperator::NotEquals, + ], +); +``` + +### Effective operators + +The operator list displayed in the UI and accepted from the frontend is computed +as the intersection of: + +1. **Type-compatible operators** for the field's `FilterType` (and nullability), and +2. **Developer-allowed operators** declared via `allowedOperators`. + +If a developer accidentally allows an operator incompatible with the field type +(e.g., `Contains` on a `Boolean`), that operator is silently filtered out: it +will not appear in the UI and the backend will reject any condition that uses +it. + +## Supported Types and Operators + +### Types + +The Search Builder supports the following types from the `FilterType` enum: +- `Text` +- `Choice` +- `Enum` +- `Boolean` +- `Date` +- `Number` +- `NumberRange` +- `DateRange` + +### Enum / Choice fields + +`Choice` fields use a static `choices` map (`label => value`). + +`Enum` fields accept a backed enum class via the `enumClass` option. The bundle +automatically derives the choice map from the enum cases (case name → backed +value). Enum values are submitted as their backed (scalar) values. + +For both `Choice` and `Enum`, the operators effectively available are limited to +the equality and set operators: `eq`, `neq`, `in`, `not_in` (and `is_null` / +`is_not_null` when the field is nullable). Operator restrictions declared via +`allowedOperators` apply on top. + +### Operators + +The following operators are supported (see `ComparisonOperator` enum for internal values): + +| Label | Internal Code | Behavior | +|---|---|---| +| **Equals** | `eq` | Exact match. | +| **Not Equals** | `neq` | Not equal match. | +| **Contains** | `contains` | Case-insensitive `LIKE %value%`. | +| **Does not contain** | `not_contains` | Case-insensitive `NOT LIKE %value%`. | +| **Starts with** | `starts_with` | Case-insensitive `LIKE value%`. | +| **Ends with** | `ends_with` | Case-insensitive `LIKE %value`. | +| **Greater than** | `gt` | `>` comparison. | +| **Greater than or equals** | `gte` | `>=` comparison. | +| **Less than** | `lt` | `<` comparison. | +| **Less than or equals** | `lte` | `<=` comparison. | +| **Between** | `between` | `BETWEEN value1 AND value2`. | +| **Is null** | `is_null` | `IS NULL` check. | +| **Is not null** | `is_not_null` | `IS NOT NULL` check. | +| **In** | `in` | `IN (value1, value2, ...)` check. | +| **Not in** | `not_in` | `NOT IN (value1, value2, ...)` check. | + +## Logic and Nesting + +The Search Builder supports `AND` and `OR` logic. Users can create nested groups +to build complex expressions: + +- **Root Group**: The top-level group (defaults to `AND`). +- **Sub-groups**: Additional groups can be added inside other groups (up to a + depth of 3). +- **Add condition / Add subgroup**: Each group exposes buttons to add either a + leaf condition or a nested subgroup. +- **Remove condition / Remove subgroup**: Each condition and each nested + subgroup can be removed individually. +- **Change logic**: Each group exposes a logic select (`AND` / `OR`). +- **Clear**: The root group's "Clear" button removes all conditions and nested + subgroups and resets the root logic to `AND`. + +### Payload shape + +The frontend serializes the tree using lowercase `logic` values and a +`conditions` array containing either leaf conditions or nested groups: + +```json +{ + "logic": "and", + "conditions": [ + { + "field": "email", + "operator": "contains", + "value": "alice" + }, + { + "logic": "or", + "conditions": [ + { "field": "enabled", "operator": "eq", "value": true }, + { "field": "status", "operator": "eq", "value": "admin" } + ] + } + ] +} +``` + +The backend factory also accepts the legacy `children` key in place of +`conditions`, and uppercase `AND` / `OR` for `logic`, for backward +compatibility. + +## Provider Behavior + +### Doctrine Provider + +Advanced filters are applied directly to the Doctrine `QueryBuilder`. +- **Join Handling**: Joins are automatically managed based on the field references (e.g., `organization.name` will use the `organization` alias). +- **Case Sensitivity**: String comparisons (`Contains`, `Starts with`, etc.) use `LOWER()` on both the field and the parameter for database-agnostic case-insensitivity. +- **Security**: All parameters are bound using Doctrine parameter binding to prevent SQL injection. + +### Array Provider + +Advanced filters work with the Array provider as well. The evaluator performs in-memory comparisons: +- **Case Sensitivity**: String comparisons are performed using `mb_strtolower`. +- **Date Handling**: Supports `\DateTimeInterface` objects and `Y-m-d` date strings. +- **Type Coercion**: Performs basic type coercion (e.g., numeric strings vs numbers) to ensure consistent results. + +## Export Behavior + +When exporting to CSV or XLSX, the active advanced filters are automatically applied to the exported dataset, ensuring the export matches the user's current view. + +## Security Boundaries + +Security is a core design principle of the Advanced Filters system: + +1. **Backend-defined Fields**: Only fields explicitly declared with `addAdvancedFilterField()` can be used in expressions. Attempting to filter on undeclared fields will result in the condition being ignored. +2. **No Arbitrary DQL/SQL**: The frontend sends a declarative JSON payload. The backend parses this payload into a structured expression tree. No raw DQL or SQL is ever accepted from the client. +3. **Strict Operators**: The backend validates that only supported operators are used. +4. **Parameter Binding**: All values from the frontend are treated as parameters and bound using Doctrine's secure parameter binding system. No values are ever directly concatenated into query strings. +5. **Depth Limit**: The expression tree depth is limited (default 3) to prevent complex query exhaustion attacks. + +## Limitations + +- **Saved Presets**: There is currently no support for saving or sharing filter presets. +- **User Persistence**: Advanced filters are not persisted between sessions or page reloads. +- **Third-party Widgets**: The current implementation uses standard Bootstrap inputs; custom widgets like Select2 or specialized datepickers are not yet supported. +- **Collection-valued Associations**: Filtering on collection-valued associations (e.g., "Users having at least one Role with name X") is not supported. diff --git a/docs/architecture.md b/docs/architecture.md index 3ab7297..fb18366 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,6 +10,7 @@ This document serves as the index for the technical architecture of `zhortein/da - [**Stimulus**](architecture/stimulus.md): Frontend interaction model, controller responsibilities, and AssetMapper integration. - [**Exports**](architecture/exports.md): Server-side export model, writer contract, and format implementations. - [**Doctrine Provider**](architecture/doctrine.md): DQL query building, metadata resolution, joins, and aggregates. +- [**Icons**](decisions/0008-icon-strategy-and-configuration-model.md): CSS-class based icon strategy and configuration model. ## Related Documentation diff --git a/docs/bulk-actions.md b/docs/bulk-actions.md new file mode 100644 index 0000000..715cd80 --- /dev/null +++ b/docs/bulk-actions.md @@ -0,0 +1,124 @@ +# Bulk Actions and Row Selection + +Bulk actions allow users to perform operations on multiple rows at once. This involves a selector column (checkboxes), a selection state management in the frontend, and a backend route to handle the submitted IDs. + +## Declaring Bulk Actions + +Bulk actions are declared in your datatable class using the `addBulkAction` method on the `DatatableDefinition`. + +```php +use Zhortein\DatatableBundle\Definition\DatatableDefinition; +use Zhortein\DatatableBundle\Enum\ActionIconPosition; + +public function buildDatatable(DatatableDefinition $definition): void +{ + $definition->addBulkAction( + name: 'delete_selected', + route: 'app_user_bulk_delete', + label: 'Delete Selected', + icon: 'bi bi-trash', + className: 'btn btn-outline-danger', + confirmationMessage: 'Are you sure you want to delete the selected users?', + selectedRowsParameterName: 'ids', // Default is 'ids' + ); +} +``` + +## Selector Column + +When at least one bulk action is defined, a **selector column** is automatically prepended to the datatable. +- The header contains a "Select All" checkbox that toggles all rows on the current page. +- Each row contains a checkbox to select that specific row. + +## How it Works + +1. **Selection**: Users select rows using checkboxes. The `datatable` Stimulus controller tracks selected IDs in a `Set`. +2. **Bulk Toolbar**: As soon as one or more rows are selected, a bulk action toolbar appears above the table, showing the count of selected rows and available bulk actions. +3. **Submission**: When a bulk action is triggered, the controller injects the selected IDs as hidden inputs into a form and submits it via POST. + +### Selected Row Payload + +The backend route receives the selected IDs in the request. By default, they are sent as an array named `ids`. + +```php +// In your controller +#[Route('/users/bulk-delete', name: 'app_user_bulk_delete', methods: ['POST'])] +public function bulkDelete(Request $request): Response +{ + $ids = $request->request->all('ids'); + + // Perform bulk operation... + + return $this->redirectToRoute('app_user_index'); +} +``` + +You can customize the parameter name using the `selectedRowsParameterName` option: + +```php +$definition->addBulkAction( + name: 'export', + route: 'app_user_bulk_export', + selectedRowsParameterName: 'user_ids', +); +``` + +## Security and CSRF + +### CSRF Protection +Bulk actions are always submitted via `POST` (or the configured `httpMethod`). If Symfony's CSRF protection is enabled, the bundle automatically includes a CSRF token in the form. The token ID is `zhortein_datatable_action_{action_name}`. + +### Backend Authorization +**CRITICAL**: Visibility checks in the datatable only control whether the action button is rendered. Your backend route **MUST** independently enforce authorization and validate that the user has permission to perform the action on the specific IDs provided. + +```php +public function bulkDelete(Request $request): Response +{ + $ids = $request->request->all('ids'); + + foreach ($ids as $id) { + $user = $this->userRepository->find($id); + if ($user && !$this->isGranted('DELETE', $user)) { + throw $this->createAccessDeniedException(); + } + } + + // ... +} +``` + +## Confirmation + +You can add a confirmation message to any bulk action. It will be displayed using the configured confirmation mechanism (window.confirm or Bootstrap modal) before the action is submitted. + +```php +$definition->addBulkAction( + name: 'activate', + route: 'app_user_bulk_activate', + confirmationMessage: 'Activate all selected users?', +); +``` + +## Current Limitations + +- **No "Select All Filtered"**: Currently, you can only select rows that are visible on the current page. There is no "Select all 5000 matching rows" feature yet. +- **No Persistence across pages**: Selection is lost when navigating to another page, changing page size, or refreshing the table. +- **No Async Bulk Jobs**: The bundle submits the IDs to a standard controller route. For long-running tasks, you should implement your own background job processing (e.g., using Symfony Messenger). + +## Examples + +### Complex Bulk Action + +```php +$definition->addBulkAction( + name: 'change_status', + route: 'app_user_bulk_status', + label: 'Mark as Active', + icon: 'bi bi-check-circle', + iconPosition: ActionIconPosition::After, + className: 'btn btn-sm btn-success', + attributes: [ + 'data-custom' => 'value', + ], +); +``` diff --git a/docs/configuration.md b/docs/configuration.md index dba92d0..65b6ed8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,11 @@ zhortein_datatable: default_page_size: 25 max_page_size: 500 search_enabled: false + icons: + view: "bi bi-eye" + edit: "bi bi-pencil" + delete: "bi bi-trash" + bulk_actions: "bi bi-collection" export: csv: delimiter: ';' @@ -200,6 +205,23 @@ A runtime option can still override it: }) }} ``` +## `icons` + +Type: `array` + +The bundle uses a lightweight icon resolver to map internal icon keys to CSS classes. By default, it uses [Bootstrap Icons](https://icons.getbootstrap.com/) class names. + +See [Icon System documentation](icons.md) for the full list of available keys and detailed strategy. + +Example: + +```yaml +zhortein_datatable: + icons: + action_view: "fas fa-eye" + action_edit: "fas fa-edit" +``` + ## Default export values ```yaml zhortein_datatable: @@ -241,7 +263,7 @@ Default: `badge` Available modes: - `badge`: Renders a Bootstrap badge (Success/Secondary). -- `icon`: Renders a checkmark (✓) or cross (×) with appropriate colors. +- `icon`: Renders an icon from the `IconResolver` (defaults to `bi bi-check-lg` and `bi bi-x-lg`). - `switch`: Renders a Bootstrap switch (checkbox). **Note**: This mode is display-only; inline editing is not supported. - `text`: Renders translated "Yes" or "No" text. diff --git a/docs/decisions/0008-icon-strategy-and-configuration-model.md b/docs/decisions/0008-icon-strategy-and-configuration-model.md new file mode 100644 index 0000000..b9dbf66 --- /dev/null +++ b/docs/decisions/0008-icon-strategy-and-configuration-model.md @@ -0,0 +1,112 @@ +# 0008 - Icon strategy and configuration model + +## Status + +Accepted + +## Context + +The `zhortein/datatable-bundle` already supports icons in some areas (like actions), but lacks a unified strategy. +As more interactive features are added (sorting, filtering, exports, bulk actions), the UI needs a consistent way to render and configure icons. + +The goals are: +- providing a consistent visual language; +- keeping icon libraries optional; +- avoiding mandatory dependencies on specific icon sets (Bootstrap Icons, FontAwesome, etc.); +- maintaining accessibility; +- allowing easy configuration of icon classes. + +## Decision + +The bundle will adopt a **CSS-class based icon strategy**. + +### Lightweight and Optional + +- No mandatory icon library dependency will be introduced. +- Support for icon libraries like **Bootstrap Icons** or **FontAwesome** will be documented but optional. +- **Symfony UX Icons** integration is considered out of scope for the first implementation but may be added later as an optional provider. +- If no icon classes are configured, the bundle should fall back to text-only or native-like indicators where appropriate (especially for sorting). + +### CSS-Class Based Implementation + +The first implementation will rely on CSS classes. The bundle will provide a way to configure which CSS classes should be applied for specific icon keys. + +### Visible Labels are Required + +To maintain usability and accessibility, **icon-only actions are out of scope** for now. +All actions must have a visible label. Icons are decorative and must be hidden from screen readers. + +### Accessibility Rules + +- All rendered icons must include `aria-hidden="true"`. +- Meaningful information must never be conveyed by icons alone. +- Labels remain the primary way to identify actions and states. + +## Icon Keys + +The following initial icon keys are defined for the bundle: + +| Key | Usage | Default (Bootstrap Icons suggestion) | +|-----|-------|---------------------------------------| +| `action_view` | View action icon | `bi bi-eye` | +| `action_edit` | Edit action icon | `bi bi-pencil` | +| `action_delete` | Delete action icon | `bi bi-trash` | +| `action_create` | Create action icon | `bi bi-plus-lg` | +| `bulk_actions` | Bulk actions dropdown/indicator | `bi bi-check2-all` | +| `boolean_true` | Boolean true state (icon mode) | `bi bi-check-lg text-success` | +| `boolean_false` | Boolean false state (icon mode) | `bi bi-x-lg text-danger` | +| `sort_neutral` | Column not sorted | `bi bi-arrow-down-up small text-muted` | +| `sort_asc` | Column sorted ascending | `bi bi-sort-up` | +| `sort_desc` | Column sorted descending | `bi bi-sort-down` | +| `filter` | Filter button/dropdown | `bi bi-filter` | +| `filter_active` | Indicator that a filter is active | `bi bi-funnel-fill` | +| `export` | Export button/dropdown | `bi bi-download` | +| `export_csv` | CSV export action | `bi bi-filetype-csv` | +| `export_xlsx` | XLSX export action | `bi bi-filetype-xlsx` | +| `confirmation_warning` | Warning icon in confirmation modals | `bi bi-exclamation-triangle` | + +## Configuration Shape + +The icon strategy will be configurable through the bundle configuration. + +```yaml +zhortein_datatable: + icons: + enabled: true + # Default icon set prefix (optional convenience) + # prefix: 'bi bi-' + mappings: + action_view: 'bi bi-eye' + action_edit: 'bi bi-pencil' + action_delete: 'bi bi-trash' + action_create: 'bi bi-plus-lg' + bulk_actions: 'bi bi-check2-all' + boolean_true: 'bi bi-check-lg text-success' + boolean_false: 'bi bi-x-lg text-danger' + sort_neutral: 'bi bi-arrow-down-up small text-muted' + sort_asc: 'bi bi-sort-up' + sort_desc: 'bi bi-sort-down' + filter: 'bi bi-filter' + filter_active: 'bi bi-funnel-fill' + export: 'bi bi-download' + export_csv: 'bi bi-filetype-csv' + export_xlsx: 'bi bi-filetype-xlsx' + confirmation_warning: 'bi bi-exclamation-triangle' +``` + +At the PHP level, these mappings will be available to the renderer to resolve icon classes from keys. + +## Implementation Direction + +1. Add `icons` section to the bundle configuration. +2. Introduce an `IconResolver` or similar internal service to map keys to CSS classes. +3. Update Twig templates to use the icon resolver. +4. Ensure icons are wrapped in a consistent way (e.g., ``). +5. Update `DatatableDefinition` to allow overriding icons per action if needed. + +## Consequences + +- Applications can easily switch between icon libraries (e.g., from Bootstrap Icons to FontAwesome) by changing the configuration. +- The bundle remains lightweight and does not force a specific asset dependency. +- Accessibility is preserved by requiring labels and hiding icons from screen readers. +- Consistent visual feedback for common datatable interactions. diff --git a/docs/decisions/0009-advanced-filter-expressions-model.md b/docs/decisions/0009-advanced-filter-expressions-model.md new file mode 100644 index 0000000..0bd224a --- /dev/null +++ b/docs/decisions/0009-advanced-filter-expressions-model.md @@ -0,0 +1,123 @@ +# 0009 - Advanced filter expressions model + +## Status + +Proposed + +## Context + +The current filtering system in `zhortein/datatable-bundle` is limited to a flat list of filters that are always combined using the `AND` logic operator. +To support more complex scenarios (e.g., "(A OR B) AND (C OR D)"), we need a more flexible model: **Advanced Filter Expressions**. + +The goal is to provide a backend-controlled model that allows the frontend to send complex filter trees while maintaining strict security boundaries. We must avoid exposing Doctrine internals (DQL, SQL) or allowing arbitrary expression injection. + +## Terminology + +- **Advanced Filter Expression**: The full tree structure representing the complex filtering logic. +- **Condition**: A leaf node in the expression tree, consisting of a field, a comparison operator, and one or more values. +- **Group**: A node in the expression tree that contains one or more children (Conditions or other Groups) and a logic operator to combine them. +- **Logic Operator**: An operator used to combine elements within a Group (e.g., `AND`, `OR`). +- **Comparison Operator**: An operator used in a Condition to compare a field against values (e.g., `equals`, `contains`, `greater than`). + +## Decision + +We will implement a recursive, tree-based model for advanced filter expressions. + +### Payload Shape + +The payload will be a JSON-serializable structure. A root element can be either a **Condition** or a **Group**. + +#### Group Object +```json +{ + "type": "group", + "logic": "AND", + "children": [ + // ... Conditions or Groups + ] +} +``` + +#### Condition Object +```json +{ + "type": "condition", + "field": "email", + "operator": "contains", + "value": "gmail.com" +} +``` + +### Supported Field Types + +The advanced filter system will support the following field types, mapped from existing `FilterType` where possible: +- `string` / `text` +- `number` +- `boolean` +- `date` / `datetime` +- `choice` + +### Supported Operators + +| Operator | Internal Enum Value | Description | Supported Types | +|---|---|---|---| +| Equals | `eq` | Field equals value | All | +| Not Equals | `neq` | Field does not equal value | All | +| Contains | `contains` | Field contains substring | string | +| Not Contains | `not_contains` | Field does not contain substring | string | +| Starts With | `starts_with` | Field starts with substring | string | +| Ends With | `ends_with` | Field ends with substring | string | +| Greater Than | `gt` | Field > value | number, date | +| Greater Than Or Equals | `gte` | Field >= value | number, date | +| Less Than | `lt` | Field < value | number, date | +| Less Than Or Equals | `lte` | Field <= value | number, date | +| Between | `between` | Field between val1 and val2 | number, date | +| Is Null | `is_null` | Field is null | All | +| Is Not Null | `is_not_null` | Field is not null | All | +| In | `in` | Field in list of values | All | +| Not In | `not_in` | Field not in list of values | All | + +### Logic Operators + +- `AND`: All children must be true. +- `OR`: At least one child must be true. + +### Nesting Policy + +- **Max Depth**: To prevent stack overflow and overly complex queries, a maximum depth of **3** (including the root) will be enforced. +- **Root**: The root of an advanced filter expression must be a **Group**. + +### Security Boundaries + +1. **Declared Fields Only**: Only fields explicitly marked as `filterable` in the `DatatableDefinition` can be used in conditions. +2. **Operator Validation**: Each field type will have a whitelist of allowed operators. +3. **No Arbitrary DQL/SQL**: The backend translates the declarative payload into DQL/SQL using a secure builder. No raw DQL/SQL is ever accepted from the frontend. +4. **Parameter Binding**: All values provided by the frontend MUST be bound as parameters in the resulting query. +5. **No Join Injection**: Frontend cannot specify joins. Joins must be defined in the backend `DatatableDefinition`. + +### Provider Mapping + +#### Doctrine Provider +- Groups are translated into `Andx` or `Orx` expressions. +- Conditions are translated into DQL comparison expressions. +- Field names are resolved against the QueryBuilder aliases. + +#### Array Provider +- Groups are translated into PHP closures using `array_filter` logic. +- Conditions are translated into PHP comparison logic. + +## Out of Scope (First Version) + +- **Saved Filters**: Persisting user-defined expressions in a database. +- **User Presets**: Allowing users to save and load named filter sets. +- **Async Filtering**: Fetching filter options (like choices) asynchronously based on other filters. +- **Custom Widgets**: Supporting third-party JS widgets (Select2, Flatpickr) within the search builder UI. +- **Collection-Valued Associations**: Filtering based on "has any of" or "has all of" for collections. +- **Arbitrary Expression Language**: Using Symfony ExpressionLanguage or similar for complex calculations. + +## Consequences + +- The frontend can build complex query logic using a "Search Builder" UI. +- The backend remains the source of truth for security and field availability. +- The implementation is decoupled from the underlying data source (Doctrine or Array). +- Clear boundaries prevent SQL injection and unauthorized data access. diff --git a/docs/decisions/index.md b/docs/decisions/index.md index d340365..7c1ce00 100644 --- a/docs/decisions/index.md +++ b/docs/decisions/index.md @@ -9,3 +9,5 @@ This directory contains records of significant architectural decisions made duri - [0005 - Doctrine ORM provider architecture](0005-doctrine-orm-provider-architecture.md) - [0006 - Column header filter dropdowns](0006-column-header-filter-dropdowns.md) - [0007 - XLSX export strategy](0007-xlsx-export-strategy.md) +- [0008 - Icon strategy and configuration model](0008-icon-strategy-and-configuration-model.md) +- [0009 - Advanced filter expressions model](0009-advanced-filter-expressions-model.md) diff --git a/docs/exports.md b/docs/exports.md index 2d4df37..d87994f 100644 --- a/docs/exports.md +++ b/docs/exports.md @@ -39,7 +39,12 @@ To enable XLSX in the UI, update your `zhortein_datatable` call: ## Export Modes -Exports respect the current state of the datatable (search, filters, sorting, and column visibility). +Exports respect the current state of the datatable: +- Search queries +- Simple filters +- **Advanced filter expressions** +- Sorting +- Runtime column visibility | Mode | Behavior | |---|---| diff --git a/docs/filters.md b/docs/filters.md index 315f55f..142ef71 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -10,10 +10,10 @@ Currently implemented: - **Types**: Text, Choice, Boolean, Date, Date Range, Number, Number Range. - **Layouts**: Toolbar (default) and Column Header Dropdowns. - **Features**: Active filter summary, clear filters action, Stimulus-powered refresh with debouncing. +- **Advanced Filters**: [Advanced search builder](advanced-filters.md) for complex `AND`/`OR` logic. Not implemented yet: -- Advanced SearchBuilder-style expressions (only `AND` is supported). -- Nested filter groups. +- Nested filter groups (simple filters only). - Persisted filter presets. - Custom filter widgets (Select2, datepickers). - Collection-valued association filters. diff --git a/docs/icons.md b/docs/icons.md new file mode 100644 index 0000000..038b054 --- /dev/null +++ b/docs/icons.md @@ -0,0 +1,121 @@ +# Icon System and Visual Consistency + +`zhortein/datatable-bundle` provides a unified, flexible, and library-agnostic icon system to ensure visual consistency across your datatables. + +## Icon Strategy + +The bundle adopts a **CSS-class based icon strategy**: + +- **Library Agnostic**: No specific icon library is required. You can use Bootstrap Icons, FontAwesome, Material Icons, or any other class-based library. +- **Optional**: Icons are decorative and optional. If no icons are configured, the bundle falls back to text or native-like indicators. +- **Accessible**: To ensure usability for everyone, icons are hidden from screen readers (`aria-hidden="true"`), and all actions MUST have a visible text label. + +## Configuration + +You can configure and override icon mappings globally in your bundle configuration: + +```yaml +# config/packages/zhortein_datatable.yaml +zhortein_datatable: + icons: + # Override specific keys + action_view: 'bi bi-search' + action_edit: 'bi bi-pencil-square' + + # Add custom keys for your own actions + action_approve: 'bi bi-check-circle' +``` + +## Default Icon Keys + +The bundle uses the following default keys within its internal components. The default values are based on **Bootstrap Icons**. + +| Key | Usage | Default Value | +|---|---|---| +| `action_view` | Default for "view" or "show" actions | `bi bi-eye` | +| `action_edit` | Default for "edit" actions | `bi bi-pencil` | +| `action_delete` | Default for "delete" or "remove" actions | `bi bi-trash` | +| `action_create` | Default for "create" actions | `bi bi-plus-lg` | +| `bulk_actions` | Icon for the bulk actions dropdown | `bi bi-collection` | +| `boolean_true` | Icon for boolean "true" state | `bi bi-check-lg` | +| `boolean_false` | Icon for boolean "false" state | `bi bi-x-lg` | +| `sort_neutral` | Column not sorted | `bi bi-arrow-down-up` | +| `sort_asc` | Column sorted ascending | `bi bi-arrow-up` | +| `sort_desc` | Column sorted descending | `bi bi-arrow-down` | +| `filter` | Filter button/dropdown | `bi bi-funnel` | +| `filter_active` | Indicator for active filters | `bi bi-funnel-fill` | +| `export` | Export button/dropdown | `bi bi-download` | +| `export_csv` | CSV export action | `bi bi-filetype-csv` | +| `export_xlsx` | XLSX export action | `bi bi-filetype-xlsx` | + +## Overriding Icons + +### Global Overrides + +As shown in the [Configuration](#configuration) section, use the `icons` key in your YAML configuration to override any default key or define new ones. + +### Explicit Action Icons + +You can explicitly set an icon for a specific action in your datatable definition. This takes precedence over any global mapping. + +```php +$definition->addRowAction( + name: 'custom', + label: 'Custom Action', + route: 'app_custom', + icon: 'bi bi-star-fill' // Explicit icon +); +``` + +### Automatic Action Resolution + +If no `icon` is provided for an action, the bundle attempts to resolve it automatically: +1. It checks for an exact match for the action name in the icon mappings (prefixed with `action_`). +2. For common names like `view`, `edit`, `delete`, it uses built-in fallbacks. +3. If no mapping is found, it falls back to no icon. + +## Examples + +### Using Bootstrap Icons (Default) + +Ensure you include the Bootstrap Icons CSS in your layout, if not included via AssetMapper: + +```html + +``` + +### Using FontAwesome + +If you prefer FontAwesome, update your configuration: + +```yaml +zhortein_datatable: + icons: + action_view: 'fas fa-eye' + action_edit: 'fas fa-edit' + action_delete: 'fas fa-trash' + action_create: 'fas fa-plus' + sort_neutral: 'fas fa-sort' + sort_asc: 'fas fa-sort-up' + sort_desc: 'fas fa-sort-down' + # ... and so on +``` + +## Accessibility Rules + +- **Labels are Mandatory**: Meaningful information must never be conveyed by icons alone. +- **Hidden from AT**: All icons rendered by the bundle include `aria-hidden="true"`. +- **Decorative Nature**: Icons should be considered purely decorative; the user experience should be complete even if icons fail to load. + +## Limitations + +- **Icon-only actions**: Not supported by design to maintain a high accessibility baseline. +- **SVG / Symfony UX Icons**: Currently, the system is optimized for CSS-class based libraries. Native SVG or Symfony UX Icons integration is not yet implemented as a core provider. +- **Icon Libraries**: The bundle does not ship with any icon fonts or CSS. You must include your preferred icon library in your application's assets... + +## Related documentation + +- [UI/UX Rendering](ui-ux.md) +- [Actions and Security](actions.md) +- [Theming and Templates](theming.md) +- [Configuration Reference](configuration.md) diff --git a/docs/index.md b/docs/index.md index aa1a279..5078a06 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,8 +14,11 @@ This bundle is a Symfony 8+ datatable bundle for Bootstrap-first business tables - [Providers](providers.md): Array and Doctrine data sources. - [Filters](filters.md): Toolbar and header-based data filtering. +- [Advanced Filters](advanced-filters.md): Complex nested filtering with Search Builder. - [Actions and Security](actions.md): Row-level and global table actions with CSRF and authorization. +- [Bulk Actions and Selection](bulk-actions.md): Managing multiple rows at once. - [UI/UX and Controls](ui-ux.md): Search, pagination, sorting, and UI customization. +- [Icon System](icons.md): Unified icon strategy and configuration. - [Theming and Templates](theming.md): Customizing the look, icon strategies, and template overrides. - [Server-side Exports](exports.md): CSV and XLSX data exports. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6224ab4..d222dfc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -195,11 +195,11 @@ Current configuration options: ```yaml zhortein_datatable: - default_provider: doctrine - default_theme: bootstrap - default_page_size: 25 - max_page_size: 500 - search_enabled: false + default_provider: doctrine + default_theme: bootstrap + default_page_size: 25 + max_page_size: 500 + search_enabled: false ``` Main outcome: @@ -751,68 +751,334 @@ Current limitations: --- +## 0.21 - UI/UX smoke test fixes ✅ + +Delivered: + +- Fixed XLSX export filename/format behavior discovered during smoke testing. +- Fixed duplicated controls in `controlsLayout: split`. +- Fixed row action dropdown overflow in short tables. +- Added Bootstrap modal action confirmations with native confirmation fallback. +- Fixed non-GET action width in list display mode. +- Fixed sortable header indicator state after Ajax sorting. +- Fixed header filter dropdown rendering. +- Added bulk actions and hierarchical tables to roadmap ideas. +- Planned a dedicated documentation overhaul milestone. +- Recorded post-0.20 UI/UX smoke test findings. + +Main outcome: + +```text +The UI/UX regressions found after the XLSX milestone were resolved before preparing the next alpha release. +``` + +Current UI/UX smoke status: + +- split controls layout is usable; +- row action dropdown/list modes are usable; +- modal confirmation improves action UX; +- sort indicators reflect current sort state; +- header filters render correctly; +- CSV/XLSX exports use correct routes and filenames. + +--- + +## 0.22 - Documentation overhaul ✅ + +Delivered: + +- Documentation audit and classification. +- README rewritten as project landing page. +- Installation and quick-start documentation rewritten. +- Provider documentation consolidated. +- Feature documentation consolidated. +- Architecture documentation split into focused pages. +- Obsolete snippets and stale notes removed. +- Final documentation consistency review against implemented features. +- Documentation navigation cleaned. + +Main outcome: + +```text +The documentation is now structured, clearer, and more suitable for external users evaluating or integrating the bundle. +``` + +Current documentation status: + +- README acts as a project landing page. +- `docs/index.md` acts as the documentation table of contents. +- installation and quick-start paths are clearer. +- user-facing docs, reference docs, architecture docs, decisions, development docs and smoke reports are separated. +- known limitations remain explicit. + +--- + +## 0.23 - Second alpha preparation ✅ + +Delivered: + +- Second alpha smoke test. +- Second alpha blockers resolved. +- Composer and Packagist metadata review. +- Changelog prepared for the second alpha. +- Go/no-go decision recorded. +- Release tag published. +- GitHub Release published. +- Packagist updated automatically. +- Roadmap updated after second alpha. +- Dependabot PRs merged successfully after release preparation. + +Main outcome: + +```text +The bundle reached its second public alpha release after major improvements to UI/UX, Doctrine capabilities, XLSX exports, frontend tests and documentation. +``` + +Released version: + +```text +v0.2.0-alpha.1 +``` + +Release status: + +- GitHub Release published. +- Packagist updated. +- PHP 8.4 and PHP 8.5 CI are green. +- Highest and lowest dependency checks are green. +- Fresh Symfony smoke testing passed after fixes. + +Known limitations after second alpha: + +- The package remains alpha-quality. +- Public APIs may still change before stable 1.0. +- Async exports are not implemented. +- Streaming export provider contracts are not implemented. +- Very large XLSX exports are not considered supported yet. +- Bulk actions are not implemented yet. +- Hierarchical tables are not implemented yet. +- Advanced Doctrine collection-valued association support remains out of scope. +- Full browser E2E coverage and accessibility audit are not implemented yet. +- No Symfony Flex recipe is provided for now. + +--- + +# Current installation stance + +Symfony Flex recipe support is postponed for now. + +The bundle works without a recipe as long as the host application follows the documented manual setup. + +Current required integration steps: + +- install the Composer package; +- register the bundle if Symfony does not do it automatically; +- import the bundle routes; +- enable the Stimulus controller through `assets/controllers.json` when using Symfony UX; +- provide Bootstrap CSS and JavaScript in the host application. + +The main repeated manual step is route import. + +A Symfony Flex recipe may become useful later to automate: + +- route import; +- optional configuration skeleton; +- installation notes for Bootstrap and Stimulus. + +For now, a recipe is not blocking because: + +- the bundle has working defaults; +- configuration is optional; +- documentation covers manual setup; +- publishing and maintaining a recipe adds process overhead; +- external usage feedback is still limited. + +This decision should be revisited after more real-world installations. + +--- + # Next roadmap direction -The next milestone should focus on backend/provider capabilities or export evolution rather than Symfony Flex. +The next milestone should focus on browser-level validation and accessibility. + +The next milestone should focus on production-oriented table actions. + +## 0.24 - Bulk actions and row selection ✅ + +Delivered: + +- `BulkActionDefinition` value object. +- `DatatableDefinition::addBulkAction()` API. +- Automatic selector column rendering (checkboxes). +- "Select all" checkbox in header (current page). +- Stimulus state management for selected IDs. +- Bulk action toolbar rendering when rows are selected. +- Selection count display. +- CSRF-aware form submission for bulk actions. +- Confirmation metadata support. +- Customizable parameter name for selected IDs. +- Documentation for bulk actions. + +Main outcome: + +```text +Datatables can perform safe backend-defined actions on multiple selected rows, which is required for production back-office workflows. +``` -## 0.21 - Frontend E2E and accessibility evaluation 🚧 +Current limitations: + +- no "select all matching rows" across pages; +- no selection persistence across navigation/refresh; +- no async/background bulk processing built-in; +- no bulk edit forms. + +--- + +## 0.25 - Icon system and visual consistency ✅ Goal: ```text -Validate the most interactive datatable behavior in a real browser and define the accessibility baseline before moving toward 1.0. +Provide a consistent, configurable icon strategy across actions, booleans, sorting, filters and exports. ``` -Possible work: +Delivered: + +- **icon resolver**: a flexible icon resolution system; +- **configuration overrides**: global and per-datatable icon overrides; +- **actions**: icons for row and global actions; +- **bulk actions**: icons for bulk action triggers; +- **booleans**: configurable icons for boolean values; +- **sort indicators**: customizable icons for ascending/descending states; +- **filters**: icons for filter headers and actions; +- **exports**: icons for export formats. -- choose whether Playwright is useful for this bundle; +Current limitations: + +- no mandatory icon library; +- no SVG provider; +- no UX Icons hard integration; +- no icon-only actions unless implemented. + +Main outcome: + +```text +Generated datatables have a coherent visual language while allowing host applications to choose their icon system. +``` + +--- + +## 0.26 - Advanced filter expressions ✅ + +Goal: + +```text +Introduce a safe advanced filter expression model without exposing Doctrine QueryBuilder directly to the frontend. +``` + +Delivered: + +- **backend expression model**: structural `Expression`, `Group` and `Condition` value objects; +- **field declarations**: `addAdvancedFilterField()` API with strict security boundaries; +- **request normalization**: robust JSON payload normalization into internal expressions; +- **Bootstrap UI**: a recursive "Search Builder" interface with group/condition management; +- **Stimulus serialization**: vanilla Stimulus state management and Ajax serialization; +- **Array provider support**: full in-memory evaluation of advanced expressions; +- **Doctrine provider support**: DQL translation with automatic joins and case-insensitivity; +- **export compatibility**: filters automatically applied to CSV and XLSX exports. + +Current limitations: + +- no saved filter presets; +- no persistence between sessions or page reloads; +- no specialized third-party widgets (Select2, datepickers, etc.); +- no collection-valued associations (one-to-many/many-to-many filtering); +- tree depth is limited to 3 to prevent complex query exhaustion. + +Main outcome: + +```text +Users can build richer, nested filters safely using a Search Builder UI while the backend remains in control of query generation. +``` + +--- + +## 0.27 - First beta preparation ✅ + + +Goal: + +```text +Prepare the first beta release after bulk actions, icon system and advanced filter expressions. +Target release: v0.3.0-beta.1. +``` + +Delivered: +- Run first beta smoke test +- Resolve first beta blockers +- Review public API before first beta +- Prepare changelog for first beta +- Review go-no-go for first beta tag +- Tag and publish first beta +- Update roadmap after first beta + +Main outcome: +```text +Target release: v0.3.0-beta.1. +``` + +--- + +## 0.28 - Frontend E2E and accessibility evaluation 🚧 + +Goal: + +```text +Validate the most interactive UI behavior in a real browser and define an accessibility baseline. +``` + +Planned: + +- decide whether Playwright or another browser-level tool is needed; - test Bootstrap dropdown behavior in a real browser; - test keyboard navigation; -- test column header filter dropdown UX; -- test action dropdown UX; -- test CSV/XLSX export link behavior; -- test loading and error state visibility; +- test modal confirmations; +- test row selection and bulk actions; +- test column header filters; +- test export links; - add basic accessibility checks where practical; - document findings and limitations. Main expected outcome: ```text -The bundle has a clear browser-level and accessibility validation strategy for its Bootstrap/Stimulus UI. +The most interactive Bootstrap/Stimulus behaviors are validated beyond jsdom unit tests. ``` -Out of scope for the first pass: - -- full visual regression testing; -- cross-browser matrix; -- complete WCAG audit; -- hosted demo application. - --- -## 0.22 - Documentation overhaul ✅ +## 0.29 - Hierarchical tables / expandable child datatables 🕒 Goal: ```text -Audit, reorganize and rewrite the documentation before moving closer to beta/stable releases. +Support expandable rows and child datatables for hierarchical business data. ``` -Delivered: +Planned: -- Audit documentation and classify files. -- Rewrite README as project landing page. -- Rewrite installation and quick-start documentation. -- Consolidate provider documentation. -- Consolidate feature documentation. -- Split architecture documentation into focused pages. -- Remove obsolete snippets and stale notes. -- Documentation link audit and final cleanup. +- design parent/child datatable API; +- support expandable detail rows; +- support lazy Ajax loading; +- propagate parent row context; +- define recursion/performance safeguards; +- document limitations; +- smoke test hierarchical UI. -Main outcome: +Main expected outcome: ```text -The documentation is now structured, clear and professional, providing a solid foundation for external users. +Datatables can represent parent/child business structures without custom per-project table code. ``` --- @@ -825,22 +1091,26 @@ Expected stable scope: - PHP-first datatable declarations. - Symfony service discovery. -- Doctrine provider with simple joins. +- Array and Doctrine providers. +- Doctrine provider with explicit joins. - Global search. - Typed filters. +- Header and toolbar filter layouts. - Sorting. - Pagination. +- Column visibility. - Row/global actions. - CSRF-aware non-GET actions. - Action visibility extension point. +- Bootstrap modal confirmation. - Twig/Bootstrap rendering. - Stimulus Ajax refresh. -- Column visibility. -- Server-side CSV export. +- CSV export. +- Optional XLSX export. - Translation catalog. - Documentation. - CI and quality tooling. -- Validated fresh Symfony integration. +- Fresh Symfony integration validated. 1.0 should not be tagged until the public API feels stable enough for real projects. @@ -851,31 +1121,19 @@ Expected stable scope: Potential future work: - multi-column sorting; -- SearchBuilder-like advanced expressions; - async exports; +- streaming export provider contracts; - additional export formats; - user preference persistence adapters; - API/data-source providers; - Elasticsearch provider; -- bulk actions with row selection: - - selector column; - - selected-row state in Stimulus; - - current-page and filtered-dataset action modes; - - CSRF-aware POST actions; - - authorization-aware visibility; -- hierarchical tables / expandable child datatables: - - expandable detail rows; - - nested datatable rendering; - - parent-row context propagation; - - lazy Ajax loading; - - recursion and performance safeguards;- UX Icons integration; +- UX Icons integration; - richer enum badge/icon rendering; -- accessibility audit; -- frontend test suite; -- Symfony Flex recipe; +- Symfony Flex recipe if external demand justifies it; - Tailwind or custom theme support; - icon provider abstraction; -- frontend smoke test automation. +- frontend smoke test automation; +- export size limits and queued export jobs. --- @@ -905,7 +1163,11 @@ Before a stable 1.0 release, revisit: - `DatatableRenderer` size; - action metadata vs HTML attributes; - `JoinDefinition` naming and namespace; +- `CustomJoinDefinition` naming and namespace; +- aggregate column builder API; +- custom join parameters API; - `DatatableExportResult` usefulness; +- `ExportWriterInterface` streaming suitability; - template context stability; - filter layout API; - action display mode API; diff --git a/docs/theming.md b/docs/theming.md index c48bf1a..7a72fd0 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -11,11 +11,11 @@ Currently implemented: - **Context**: Comprehensive Twig context for all rendering stages. - **Variants**: Runtime Bootstrap table options (striped, hover, bordered, etc.). - **Boolean Display Modes**: Configurable rendering for boolean columns (`badge`, `icon`, `switch`, `text`). +- **Icons**: Extensible `IconResolver` for common UI elements (sort, filter, export, actions). Not implemented yet: - Tailwind or other built-in themes. - Rich enum badges/icons by default. -- Generic icon provider abstraction. ## Template Override Strategy @@ -94,6 +94,7 @@ Or override them at runtime: ## Related documentation +- [Icon System](icons.md) - [UI/UX Rendering](ui-ux.md) - [Actions and Security](actions.md) - [Architecture](architecture/overview.md) diff --git a/docs/ui-ux.md b/docs/ui-ux.md index 0fb1416..65a7cc2 100644 --- a/docs/ui-ux.md +++ b/docs/ui-ux.md @@ -8,14 +8,15 @@ The bundle is **Bootstrap-first** and uses a **Stimulus-powered** interaction mo Currently implemented: - **Interactions**: Global search, pagination, sortable headers, page size selector. +- **Row Selection**: Checkbox-based selection and bulk action toolbar. - **UI Features**: Loading and error states, summary updates, Bootstrap table variants. - **Column Visibility**: User-controlled column visibility with persistent state. +- **Icons**: Consistent icon system for actions, filters, and exports via `IconResolver`. - **Customization**: Action icons, display modes (inline, dropdown), boolean rendering modes. - **Layouts**: Default toolbar layout and Split layout (moving some controls below the table). - **Testing**: Automated frontend test suite for the Stimulus controller. Not implemented yet: -- Icon provider abstraction (currently CSS-class based). - Icon-only actions (accessibility first). - Persisted filter presets. @@ -45,12 +46,16 @@ Clicking a header toggles sorting between `asc`, `desc`, and back to `asc`. Neut ## Rendering Customization ### Action Icons -Actions can declare an optional icon CSS class. The bundle remains accessible by keeping labels visible alongside icons. +Actions can declare an optional icon CSS class. If no explicit icon is provided, the bundle resolves a default icon from the `IconResolver` based on the action name. + +Common action names like `view`, `edit`, `delete`, and `create` have built-in defaults. Bulk actions also have a default icon fallback. + +The bundle remains accessible by keeping labels visible alongside icons. ```php $definition->addRowAction( name: 'edit', - icon: 'bi bi-pencil', + // icon: 'bi bi-pencil', // Optional, resolved automatically for 'edit' label: 'Edit', // ... ); @@ -95,5 +100,7 @@ The bundle follows a strong accessibility baseline: ## Related documentation - [Actions and Security](actions.md) +- [Icon System](icons.md) +- [Bulk Actions and Selection](bulk-actions.md) - [Theming and Templates](theming.md) - [Architecture](architecture/overview.md) diff --git a/package-lock.json b/package-lock.json index 4064739..9dbe6c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,8 @@ }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, + "license": "MIT", "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", @@ -25,8 +24,6 @@ }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "dev": true, "funding": [ { @@ -38,14 +35,13 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": ">=18" } }, "node_modules/@csstools/css-calc": { "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, "funding": [ { @@ -57,6 +53,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": ">=18" }, @@ -67,8 +64,6 @@ }, "node_modules/@csstools/css-color-parser": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, "funding": [ { @@ -80,6 +75,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" @@ -94,8 +90,6 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "dev": true, "funding": [ { @@ -107,6 +101,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": ">=18" }, @@ -116,8 +111,6 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, "funding": [ { @@ -129,243 +122,31 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@hotwired/stimulus": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz", - "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-linux-x64-gnu": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", "cpu": [ "x64" ], @@ -381,8 +162,6 @@ }, "node_modules/@rolldown/binding-linux-x64-musl": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", "cpu": [ "x64" ], @@ -396,105 +175,18 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -504,22 +196,16 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, "node_modules/@vitest/expect": { "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", - "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", "dev": true, "license": "MIT", "dependencies": { @@ -536,8 +222,6 @@ }, "node_modules/@vitest/mocker": { "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", - "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -563,8 +247,6 @@ }, "node_modules/@vitest/pretty-format": { "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", "dev": true, "license": "MIT", "dependencies": { @@ -576,8 +258,6 @@ }, "node_modules/@vitest/runner": { "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", - "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -590,8 +270,6 @@ }, "node_modules/@vitest/snapshot": { "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", - "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", "dev": true, "license": "MIT", "dependencies": { @@ -606,8 +284,6 @@ }, "node_modules/@vitest/spy": { "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", - "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", "dev": true, "license": "MIT", "funding": { @@ -616,8 +292,6 @@ }, "node_modules/@vitest/utils": { "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", "dev": true, "license": "MIT", "dependencies": { @@ -631,17 +305,14 @@ }, "node_modules/agent-base": { "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 14" } }, "node_modules/assertion-error": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -650,15 +321,13 @@ }, "node_modules/asynckit": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -669,8 +338,6 @@ }, "node_modules/chai": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -679,9 +346,8 @@ }, "node_modules/combined-stream": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -691,16 +357,13 @@ }, "node_modules/convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, "node_modules/cssstyle": { "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, + "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" @@ -711,15 +374,13 @@ }, "node_modules/cssstyle/node_modules/rrweb-cssom": { "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/data-urls": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, + "license": "MIT", "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" @@ -730,9 +391,8 @@ }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -747,15 +407,13 @@ }, "node_modules/decimal.js": { "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/delayed-stream": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -770,11 +428,18 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, + "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -786,9 +451,8 @@ }, "node_modules/entities": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -798,34 +462,29 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/es-module-lexer": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, + "license": "MIT", "dependencies": { "es-errors": "^1.3.0" }, @@ -835,9 +494,8 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -850,8 +508,6 @@ }, "node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -860,11 +516,26 @@ }, "node_modules/expect-type": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", "engines": { "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/fdir": { @@ -887,9 +558,8 @@ }, "node_modules/form-data": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -901,35 +571,18 @@ "node": ">= 6" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/get-intrinsic": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -951,9 +604,8 @@ }, "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, + "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -964,9 +616,8 @@ }, "node_modules/gopd": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -976,9 +627,8 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -988,9 +638,8 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, + "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" }, @@ -1003,9 +652,8 @@ }, "node_modules/hasown": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -1015,9 +663,8 @@ }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, + "license": "MIT", "dependencies": { "whatwg-encoding": "^3.1.1" }, @@ -1027,9 +674,8 @@ }, "node_modules/http-proxy-agent": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, + "license": "MIT", "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -1040,9 +686,8 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, + "license": "MIT", "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -1053,9 +698,8 @@ }, "node_modules/iconv-lite": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -1065,15 +709,13 @@ }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jsdom": { "version": "25.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", - "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, + "license": "MIT", "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -1111,8 +753,6 @@ }, "node_modules/lightningcss": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -1139,157 +779,8 @@ "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -1309,8 +800,6 @@ }, "node_modules/lightningcss-linux-x64-musl": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -1328,58 +817,13 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lru-cache": { "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1388,27 +832,24 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/mime-db": { "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, @@ -1418,14 +859,11 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -1443,9 +881,17 @@ }, "node_modules/nwsapi": { "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" }, "node_modules/obug": { "version": "2.1.1", @@ -1460,9 +906,8 @@ }, "node_modules/parse5": { "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, + "license": "MIT", "dependencies": { "entities": "^6.0.0" }, @@ -1472,22 +917,16 @@ }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -1499,8 +938,6 @@ }, "node_modules/postcss": { "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -1528,17 +965,14 @@ }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/rolldown": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1571,21 +1005,18 @@ }, "node_modules/rrweb-cssom": { "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", - "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/saxes": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, + "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" }, @@ -1595,14 +1026,11 @@ }, "node_modules/siginfo": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -1611,33 +1039,26 @@ }, "node_modules/stackback": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/std-env": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, "node_modules/symbol-tree": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tinybench": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tinyexec": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", "dev": true, "license": "MIT", "engines": { @@ -1646,8 +1067,6 @@ }, "node_modules/tinyglobby": { "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { @@ -1663,8 +1082,6 @@ }, "node_modules/tinyrainbow": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -1673,9 +1090,8 @@ }, "node_modules/tldts": { "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, + "license": "MIT", "dependencies": { "tldts-core": "^6.1.86" }, @@ -1685,15 +1101,13 @@ }, "node_modules/tldts-core": { "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tough-cookie": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "tldts": "^6.1.32" }, @@ -1703,9 +1117,8 @@ }, "node_modules/tr46": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, + "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, @@ -1723,8 +1136,6 @@ }, "node_modules/vite": { "version": "8.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", - "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "dev": true, "license": "MIT", "dependencies": { @@ -1801,8 +1212,6 @@ }, "node_modules/vitest": { "version": "4.1.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", - "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1891,9 +1300,8 @@ }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, + "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -1903,19 +1311,16 @@ }, "node_modules/webidl-conversions": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" } }, "node_modules/whatwg-encoding": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, + "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" }, @@ -1925,18 +1330,16 @@ }, "node_modules/whatwg-mimetype": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/whatwg-url": { "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, + "license": "MIT", "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -1947,9 +1350,8 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, + "license": "MIT", "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" @@ -1963,9 +1365,8 @@ }, "node_modules/ws": { "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -1984,18 +1385,16 @@ }, "node_modules/xml-name-validator": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=18" } }, "node_modules/xmlchars": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true + "dev": true, + "license": "MIT" } } } diff --git a/src/Action/ActionVisibilityCheckerInterface.php b/src/Action/ActionVisibilityCheckerInterface.php index 525c7cc..406e1e9 100644 --- a/src/Action/ActionVisibilityCheckerInterface.php +++ b/src/Action/ActionVisibilityCheckerInterface.php @@ -5,8 +5,9 @@ namespace Zhortein\DatatableBundle\Action; use Zhortein\DatatableBundle\Definition\ActionDefinition; +use Zhortein\DatatableBundle\Definition\BulkActionDefinition; interface ActionVisibilityCheckerInterface { - public function isVisible(ActionDefinition $action, ActionVisibilityContext $context): bool; + public function isVisible(ActionDefinition|BulkActionDefinition $action, ActionVisibilityContext $context): bool; } diff --git a/src/Action/AllowAllActionVisibilityChecker.php b/src/Action/AllowAllActionVisibilityChecker.php index 1f2247f..f624b49 100644 --- a/src/Action/AllowAllActionVisibilityChecker.php +++ b/src/Action/AllowAllActionVisibilityChecker.php @@ -5,10 +5,11 @@ namespace Zhortein\DatatableBundle\Action; use Zhortein\DatatableBundle\Definition\ActionDefinition; +use Zhortein\DatatableBundle\Definition\BulkActionDefinition; final readonly class AllowAllActionVisibilityChecker implements ActionVisibilityCheckerInterface { - public function isVisible(ActionDefinition $action, ActionVisibilityContext $context): bool + public function isVisible(ActionDefinition|BulkActionDefinition $action, ActionVisibilityContext $context): bool { return true; } diff --git a/src/Action/AuthorizationActionVisibilityChecker.php b/src/Action/AuthorizationActionVisibilityChecker.php index 8287e9f..29b4a2b 100644 --- a/src/Action/AuthorizationActionVisibilityChecker.php +++ b/src/Action/AuthorizationActionVisibilityChecker.php @@ -6,6 +6,7 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Zhortein\DatatableBundle\Definition\ActionDefinition; +use Zhortein\DatatableBundle\Definition\BulkActionDefinition; final readonly class AuthorizationActionVisibilityChecker implements ActionVisibilityCheckerInterface { @@ -15,7 +16,7 @@ public function __construct( ) { } - public function isVisible(ActionDefinition $action, ActionVisibilityContext $context): bool + public function isVisible(ActionDefinition|BulkActionDefinition $action, ActionVisibilityContext $context): bool { $attribute = $action->getAttribute('permission'); diff --git a/src/Contract/IconResolverInterface.php b/src/Contract/IconResolverInterface.php new file mode 100644 index 0000000..d1823ee --- /dev/null +++ b/src/Contract/IconResolverInterface.php @@ -0,0 +1,10 @@ +definitionFactory->create($name); - $datatableRequest = $this->requestFactory->createFromRequest($request); + $datatableRequest = $this->requestFactory->createFromRequest($request, $definition); $provider = $this->providerRegistry->resolve($definition); $result = $provider->getData($definition, $datatableRequest); @@ -58,7 +58,7 @@ public function fragments(Request $request, string $name): JsonResponse public function export(Request $request, string $name, string $format = 'csv'): Response { $definition = $this->definitionFactory->create($name); - $datatableRequest = $this->requestFactory->createFromRequest($request); + $datatableRequest = $this->requestFactory->createFromRequest($request, $definition); $exportFormat = ExportFormat::fromString($format); $mode = $request->query->get('mode', 'current'); $filename = $request->query->get('filename'); diff --git a/src/Definition/AdvancedFilterFieldDefinition.php b/src/Definition/AdvancedFilterFieldDefinition.php new file mode 100644 index 0000000..93fed97 --- /dev/null +++ b/src/Definition/AdvancedFilterFieldDefinition.php @@ -0,0 +1,260 @@ + + */ + private array $resolvedChoices; + + /** + * @var class-string<\BackedEnum>|null + */ + private ?string $resolvedEnumClass; + + /** + * @var list + */ + private array $normalizedAllowedOperators; + + /** + * @param list $allowedOperators + * @param array $choices + * @param class-string<\BackedEnum>|null $enumClass + */ + public function __construct( + private string $name, + private string $field, + private ?string $label = null, + private FilterType $type = FilterType::Text, + array $allowedOperators = [], + array $choices = [], + ?string $enumClass = null, + private bool $nullable = false, + ) { + if ('' === trim($this->name)) { + throw new \InvalidArgumentException('The advanced filter field name cannot be empty.'); + } + + if ('' === trim($this->field)) { + throw new \InvalidArgumentException('The advanced filter field cannot be empty.'); + } + + $this->normalizedAllowedOperators = $this->normalizeAllowedOperators($allowedOperators); + + $this->resolvedEnumClass = $this->resolveEnumClass($enumClass); + + if ([] === $choices && null !== $this->resolvedEnumClass) { + $choices = $this->deriveChoicesFromEnum($this->resolvedEnumClass); + } + + $this->resolvedChoices = $choices; + } + + public function getName(): string + { + return $this->name; + } + + public function getField(): string + { + return $this->field; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function getType(): FilterType + { + return $this->type; + } + + /** + * Returns the developer-declared allowed operators, normalized to the advanced + * comparison operator model. + * + * An empty list means "no restriction beyond type-compatibility". + * + * @return list + */ + public function getAllowedOperators(): array + { + return $this->normalizedAllowedOperators; + } + + /** + * @return array + */ + public function getChoices(): array + { + return $this->resolvedChoices; + } + + /** + * @return class-string<\BackedEnum>|null + */ + public function getEnumClass(): ?string + { + return $this->resolvedEnumClass; + } + + public function isNullable(): bool + { + return $this->nullable; + } + + /** + * Returns the list of ComparisonOperator string values effectively allowed for this field, + * computed as the intersection of type-compatible operators and per-field allowed operators. + * + * Incompatible developer-declared operators are silently filtered out so they are + * never displayed in the UI nor accepted from the frontend. + * + * @return list + */ + public function getEffectiveOperatorValues(): array + { + $typeOperators = OperatorCompatibility::operatorsFor($this->type, $this->nullable); + + if ([] === $this->normalizedAllowedOperators) { + return array_map(static fn (ComparisonOperator $op): string => $op->value, $typeOperators); + } + + $result = []; + + foreach ($typeOperators as $operator) { + if (in_array($operator, $this->normalizedAllowedOperators, true)) { + $result[] = $operator->value; + } + } + + return $result; + } + + /** + * Returns the list of ComparisonOperator effectively allowed for this field. + * + * @return list + */ + public function getEffectiveOperators(): array + { + $typeOperators = OperatorCompatibility::operatorsFor($this->type, $this->nullable); + + if ([] === $this->normalizedAllowedOperators) { + return $typeOperators; + } + + $result = []; + + foreach ($typeOperators as $operator) { + if (in_array($operator, $this->normalizedAllowedOperators, true)) { + $result[] = $operator; + } + } + + return $result; + } + + /** + * @param list $allowedOperators + * + * @return list + */ + private function normalizeAllowedOperators(array $allowedOperators): array + { + $normalized = []; + + foreach ($allowedOperators as $operator) { + if ($operator instanceof ComparisonOperator) { + if (!in_array($operator, $normalized, true)) { + $normalized[] = $operator; + } + continue; + } + + foreach (self::mapFilterToComparison($operator) as $mapped) { + if (!in_array($mapped, $normalized, true)) { + $normalized[] = $mapped; + } + } + } + + return $normalized; + } + + /** + * Maps a legacy FilterOperator to one or more advanced ComparisonOperator values. + * + * @return list + */ + private static function mapFilterToComparison(FilterOperator $operator): array + { + return match ($operator) { + FilterOperator::Equals => [ComparisonOperator::Equals], + FilterOperator::NotEquals => [ComparisonOperator::NotEquals], + FilterOperator::GreaterThan => [ComparisonOperator::GreaterThan], + FilterOperator::GreaterThanOrEquals => [ComparisonOperator::GreaterThanOrEquals], + FilterOperator::LessThan => [ComparisonOperator::LessThan], + FilterOperator::LessThanOrEquals => [ComparisonOperator::LessThanOrEquals], + FilterOperator::In => [ComparisonOperator::In], + FilterOperator::NotIn => [ComparisonOperator::NotIn], + FilterOperator::IsNull => [ComparisonOperator::IsNull], + FilterOperator::IsNotNull => [ComparisonOperator::IsNotNull], + FilterOperator::Between => [ComparisonOperator::Between], + FilterOperator::Like => [ + ComparisonOperator::Contains, + ComparisonOperator::StartsWith, + ComparisonOperator::EndsWith, + ], + FilterOperator::NotLike => [ComparisonOperator::NotContains], + }; + } + + /** + * @param class-string<\BackedEnum>|string|null $enumClass + * + * @return class-string<\BackedEnum>|null + */ + private function resolveEnumClass(?string $enumClass): ?string + { + if (null === $enumClass || '' === trim($enumClass)) { + return null; + } + + if (!is_subclass_of($enumClass, \BackedEnum::class)) { + throw new \InvalidArgumentException(sprintf('Class "%s" must be a backed enum.', $enumClass)); + } + + /** @var class-string<\BackedEnum> $enumClass */ + return $enumClass; + } + + /** + * @param class-string<\BackedEnum> $enumClass + * + * @return array + */ + private function deriveChoicesFromEnum(string $enumClass): array + { + $choices = []; + + foreach ($enumClass::cases() as $case) { + $label = $case->name; + $value = (string) $case->value; + $choices[$label] = $value; + } + + return $choices; + } +} diff --git a/src/Definition/BulkActionDefinition.php b/src/Definition/BulkActionDefinition.php new file mode 100644 index 0000000..7fba107 --- /dev/null +++ b/src/Definition/BulkActionDefinition.php @@ -0,0 +1,104 @@ + $routeParameters + * @param array $attributes + */ +final readonly class BulkActionDefinition +{ + /** + * @param array $routeParameters + * @param array $attributes + */ + public function __construct( + private string $name, + private string $route, + private ?string $label = null, + private ?string $icon = null, + private ActionIconPosition $iconPosition = ActionIconPosition::Before, + private string $httpMethod = 'POST', + private ?string $confirmationMessage = null, + private ?string $className = null, + private array $routeParameters = [], + private array $attributes = [], + private string $selectedRowsParameterName = 'ids', + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getRoute(): string + { + return $this->route; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function getIcon(): ?string + { + return $this->icon; + } + + public function getIconPosition(): ActionIconPosition + { + return $this->iconPosition; + } + + public function getHttpMethod(): string + { + return $this->httpMethod; + } + + public function getConfirmationMessage(): ?string + { + return $this->confirmationMessage; + } + + public function getClassName(): ?string + { + return $this->className; + } + + /** + * @return array + */ + public function getRouteParameters(): array + { + return $this->routeParameters; + } + + /** + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } + + public function getAttribute(string $name, ?string $default = null): ?string + { + return $this->attributes[$name] ?? $default; + } + + public function getSelectedRowsParameterName(): string + { + return $this->selectedRowsParameterName; + } +} diff --git a/src/Definition/DatatableDefinition.php b/src/Definition/DatatableDefinition.php index e899a95..3d8aed5 100644 --- a/src/Definition/DatatableDefinition.php +++ b/src/Definition/DatatableDefinition.php @@ -9,6 +9,7 @@ use Zhortein\DatatableBundle\Enum\FilterOperator; use Zhortein\DatatableBundle\Enum\FilterType; use Zhortein\DatatableBundle\Enum\JoinType; +use Zhortein\DatatableBundle\Filter\Expression\ComparisonOperator; final class DatatableDefinition { @@ -34,6 +35,11 @@ final class DatatableDefinition */ private array $globalActions = []; + /** + * @var array + */ + private array $bulkActions = []; + /** * @var list */ @@ -64,6 +70,11 @@ final class DatatableDefinition */ private array $aggregateColumns = []; + /** + * @var array + */ + private array $advancedFilterFields = []; + public function __construct( private readonly string $name, ) { @@ -284,6 +295,54 @@ public function getGlobalActions(): array return $this->globalActions; } + /** + * @param array $routeParameters + * @param array $attributes + * + * NOTE: Visibility checks only control whether the action is rendered in the UI. + * The backend route MUST also enforce authorization and validate the request. + */ + public function addBulkAction( + string $name, + string $route, + ?string $label = null, + ?string $icon = null, + ActionIconPosition|string $iconPosition = ActionIconPosition::Before, + string $httpMethod = 'POST', + ?string $confirmationMessage = null, + ?string $className = null, + array $routeParameters = [], + array $attributes = [], + string $selectedRowsParameterName = 'ids', + ): self { + if (is_string($iconPosition)) { + $iconPosition = ActionIconPosition::tryFrom($iconPosition) ?? ActionIconPosition::Before; + } + $this->bulkActions[$name] = new BulkActionDefinition( + name: $name, + route: $route, + label: $label, + icon: $icon, + iconPosition: $iconPosition, + httpMethod: $httpMethod, + confirmationMessage: $confirmationMessage, + className: $className, + routeParameters: $routeParameters, + attributes: $attributes, + selectedRowsParameterName: $selectedRowsParameterName, + ); + + return $this; + } + + /** + * @return array + */ + public function getBulkActions(): array + { + return $this->bulkActions; + } + public function addPermanentFilter( string $field, FilterOperator $operator, @@ -392,4 +451,45 @@ public function getAggregateColumns(): array { return $this->aggregateColumns; } + + /** + * @param list $allowedOperators + * @param array $choices + * @param class-string<\BackedEnum>|null $enumClass + */ + public function addAdvancedFilterField( + string $name, + string $field, + ?string $label = null, + FilterType $type = FilterType::Text, + array $allowedOperators = [], + array $choices = [], + ?string $enumClass = null, + bool $nullable = false, + ): self { + if (null !== $enumClass && FilterType::Text === $type) { + $type = FilterType::Enum; + } + + $this->advancedFilterFields[$name] = new AdvancedFilterFieldDefinition( + name: $name, + field: $field, + label: $label, + type: $type, + allowedOperators: $allowedOperators, + choices: $choices, + enumClass: $enumClass, + nullable: $nullable, + ); + + return $this; + } + + /** + * @return array + */ + public function getAdvancedFilterFields(): array + { + return $this->advancedFilterFields; + } } diff --git a/src/Doctrine/DoctrineExpressionApplier.php b/src/Doctrine/DoctrineExpressionApplier.php new file mode 100644 index 0000000..e27edca --- /dev/null +++ b/src/Doctrine/DoctrineExpressionApplier.php @@ -0,0 +1,247 @@ +parameterIndex = 0; + + $doctrineExpression = $this->applyExpression( + queryBuilder: $queryBuilder, + expression: $expression->root, + definition: $definition, + entityManager: $entityManager, + entityClass: $entityClass, + ); + + if (null !== $doctrineExpression) { + $queryBuilder->andWhere($doctrineExpression); + } + } + + /** + * @param class-string $entityClass + */ + private function applyExpression( + QueryBuilder $queryBuilder, + ExpressionInterface $expression, + DatatableDefinition $definition, + EntityManagerInterface $entityManager, + string $entityClass, + ): string|object|null { + if ($expression instanceof Group) { + return $this->applyGroup($queryBuilder, $expression, $definition, $entityManager, $entityClass); + } + + if ($expression instanceof Condition) { + return $this->applyCondition($queryBuilder, $expression, $definition, $entityManager, $entityClass); + } + + return null; + } + + /** + * @param class-string $entityClass + */ + private function applyGroup( + QueryBuilder $queryBuilder, + Group $group, + DatatableDefinition $definition, + EntityManagerInterface $entityManager, + string $entityClass, + ): string|object|null { + $expressions = []; + + foreach ($group->children as $child) { + $doctrineExpression = $this->applyExpression($queryBuilder, $child, $definition, $entityManager, $entityClass); + + if (null !== $doctrineExpression) { + $expressions[] = $doctrineExpression; + } + } + + if ([] === $expressions) { + return null; + } + + if (1 === count($expressions)) { + return $expressions[0]; + } + + /** @var array $expressions */ + return match ($group->logic) { + LogicOperator::And => $queryBuilder->expr()->andX(...$expressions), + LogicOperator::Or => $queryBuilder->expr()->orX(...$expressions), + }; + } + + /** + * @param class-string $entityClass + */ + private function applyCondition( + QueryBuilder $queryBuilder, + Condition $condition, + DatatableDefinition $definition, + EntityManagerInterface $entityManager, + string $entityClass, + ): string|object|null { + try { + $reference = $this->fieldReferenceResolver->normalize($condition->field, $definition); + } catch (\InvalidArgumentException) { + return null; + } + + if (!$this->fieldMetadataResolver->hasField($entityManager, $entityClass, $definition, $reference)) { + return null; + } + + $field = $reference->toString(); + $parameterName = sprintf('advanced_filter_%d', $this->parameterIndex++); + + return match ($condition->operator) { + ComparisonOperator::Equals => $this->createEqExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::NotEquals => $this->createNeqExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::GreaterThan => $this->createGtExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::GreaterThanOrEquals => $this->createGteExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::LessThan => $this->createLtExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::LessThanOrEquals => $this->createLteExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::In => $this->createInExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::NotIn => $this->createNotInExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::IsNull => $queryBuilder->expr()->isNull($field), + ComparisonOperator::IsNotNull => $queryBuilder->expr()->isNotNull($field), + ComparisonOperator::Between => $this->createBetweenExpression($queryBuilder, $field, $parameterName, $condition->value), + ComparisonOperator::StartsWith => $this->createLikeExpression($queryBuilder, $field, $parameterName, (is_scalar($condition->value) ? (string) $condition->value : '').'%'), + ComparisonOperator::EndsWith => $this->createLikeExpression($queryBuilder, $field, $parameterName, '%'.(is_scalar($condition->value) ? (string) $condition->value : '')), + ComparisonOperator::Contains => $this->createLikeExpression($queryBuilder, $field, $parameterName, '%'.(is_scalar($condition->value) ? (string) $condition->value : '').'%'), + ComparisonOperator::NotContains => $this->createNotLikeExpression($queryBuilder, $field, $parameterName, '%'.(is_scalar($condition->value) ? (string) $condition->value : '').'%'), + }; + } + + private function createEqExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->eq($field, ':'.$parameterName); + } + + private function createNeqExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->neq($field, ':'.$parameterName); + } + + private function createGtExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->gt($field, ':'.$parameterName); + } + + private function createGteExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->gte($field, ':'.$parameterName); + } + + private function createLtExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->lt($field, ':'.$parameterName); + } + + private function createLteExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->lte($field, ':'.$parameterName); + } + + private function createInExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->in($field, ':'.$parameterName); + } + + private function createNotInExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): object + { + $queryBuilder->setParameter($parameterName, $value); + + return $queryBuilder->expr()->notIn($field, ':'.$parameterName); + } + + private function createBetweenExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, mixed $value): ?string + { + if (!is_array($value)) { + return null; + } + + if (isset($value['from'], $value['to'])) { + $start = $value['from']; + $end = $value['to']; + } else { + $values = array_values($value); + + if (2 !== count($values)) { + return null; + } + + [$start, $end] = $values; + } + + $startParam = $parameterName.'_start'; + $endParam = $parameterName.'_end'; + + $queryBuilder->setParameter($startParam, $start); + $queryBuilder->setParameter($endParam, $end); + + return $queryBuilder->expr()->between($field, ':'.$startParam, ':'.$endParam); + } + + private function createLikeExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, string $pattern): object + { + $queryBuilder->setParameter($parameterName, $pattern); + + return $queryBuilder->expr()->like(sprintf('LOWER(%s)', $field), sprintf('LOWER(:%s)', $parameterName)); + } + + private function createNotLikeExpression(QueryBuilder $queryBuilder, string $field, string $parameterName, string $pattern): object + { + $queryBuilder->setParameter($parameterName, $pattern); + + return $queryBuilder->expr()->notLike(sprintf('LOWER(%s)', $field), sprintf('LOWER(:%s)', $parameterName)); + } +} diff --git a/src/Enum/FilterType.php b/src/Enum/FilterType.php index cab82b2..f66877b 100644 --- a/src/Enum/FilterType.php +++ b/src/Enum/FilterType.php @@ -13,4 +13,5 @@ enum FilterType: string case DateRange = 'date_range'; case Number = 'number'; case NumberRange = 'number_range'; + case Enum = 'enum'; } diff --git a/src/Exception/InvalidExpressionException.php b/src/Exception/InvalidExpressionException.php new file mode 100644 index 0000000..91de0f7 --- /dev/null +++ b/src/Exception/InvalidExpressionException.php @@ -0,0 +1,9 @@ + $payload + */ + public function createFromArray(array $payload, ?DatatableDefinition $definition = null): ?AdvancedFilterExpression + { + if ([] === $payload) { + return null; + } + + try { + $root = $this->parseGroup($payload, $definition); + + return new AdvancedFilterExpression($root); + } catch (InvalidExpressionException) { + return null; + } + } + + /** + * @param array $payload + */ + private function parseGroup(array $payload, ?DatatableDefinition $definition): Group + { + $logicValue = $payload['logic'] ?? 'AND'; + $logic = is_string($logicValue) ? LogicOperator::tryFrom(strtoupper($logicValue)) : null; + $logic ??= LogicOperator::And; + + // Accept both "conditions" (spec) and "children" (legacy) as the group's children key. + $childrenPayload = $payload['conditions'] ?? $payload['children'] ?? []; + + if (!is_array($childrenPayload) || [] === $childrenPayload) { + throw new InvalidExpressionException('Group must have at least one child.'); + } + + $children = []; + + foreach ($childrenPayload as $childPayload) { + if (!is_array($childPayload)) { + continue; + } + + /** @var array $childPayload */ + if (isset($childPayload['conditions']) || isset($childPayload['children'])) { + try { + $children[] = $this->parseGroup($childPayload, $definition); + } catch (InvalidExpressionException) { + continue; + } + } else { + $condition = $this->parseCondition($childPayload, $definition); + if (null !== $condition) { + $children[] = $condition; + } + } + } + + return new Group($logic, $children); + } + + /** + * @param array $payload + */ + private function parseCondition(array $payload, ?DatatableDefinition $definition): ?Condition + { + $field = $payload['field'] ?? null; + $operatorValue = $payload['operator'] ?? null; + $operator = is_string($operatorValue) ? ComparisonOperator::tryFrom($operatorValue) : null; + $value = $payload['value'] ?? null; + + if (!is_string($field) || '' === trim($field) || null === $operator) { + return null; + } + + $fieldDefinition = null; + + if (null !== $definition) { + $advancedFilterFields = $definition->getAdvancedFilterFields(); + if (!isset($advancedFilterFields[$field])) { + return null; + } + + $fieldDefinition = $advancedFilterFields[$field]; + + if (!OperatorCompatibility::isCompatible($fieldDefinition->getType(), $operator, $fieldDefinition->isNullable())) { + return null; + } + + $allowedOperators = $fieldDefinition->getAllowedOperators(); + + if ([] !== $allowedOperators && !in_array($operator, $allowedOperators, true)) { + return null; + } + } + + $value = $this->normalizeValue($value, $operator, $fieldDefinition); + + try { + return new Condition($field, $operator, $value); + } catch (InvalidExpressionException) { + return null; + } + } + + private function normalizeValue(mixed $value, ComparisonOperator $operator, ?AdvancedFilterFieldDefinition $fieldDefinition): mixed + { + if (ComparisonOperator::IsNull === $operator || ComparisonOperator::IsNotNull === $operator) { + return null; + } + + if (ComparisonOperator::Between === $operator) { + if (!is_array($value)) { + return $value; + } + + $values = array_values($value); + + if (2 !== count($values)) { + if (isset($value['from'], $value['to'])) { + $values = [$value['from'], $value['to']]; + } + } + + return $values; + } + + if (ComparisonOperator::In === $operator || ComparisonOperator::NotIn === $operator) { + if (!is_array($value)) { + $value = [$value]; + } + + return array_values($value); + } + + if (null !== $fieldDefinition && null !== $fieldDefinition->getEnumClass() && is_scalar($value)) { + $enumClass = $fieldDefinition->getEnumClass(); + $allowedValues = array_map(static fn (\BackedEnum $case): string => (string) $case->value, $enumClass::cases()); + + if (!in_array((string) $value, $allowedValues, true)) { + return $value; + } + } + + return $value; + } +} diff --git a/src/Factory/DatatableRequestFactory.php b/src/Factory/DatatableRequestFactory.php index 528cc3b..9c138c3 100644 --- a/src/Factory/DatatableRequestFactory.php +++ b/src/Factory/DatatableRequestFactory.php @@ -5,6 +5,7 @@ namespace Zhortein\DatatableBundle\Factory; use Symfony\Component\HttpFoundation\Request; +use Zhortein\DatatableBundle\Definition\DatatableDefinition; use Zhortein\DatatableBundle\Enum\SortDirection; use Zhortein\DatatableBundle\Request\DatatableRequest; @@ -15,6 +16,7 @@ public const int MAX_PAGE_SIZE = 500; public function __construct( + private AdvancedFilterExpressionFactory $advancedFilterExpressionFactory, private int $defaultPage = self::DEFAULT_PAGE, private int $defaultPageSize = self::DEFAULT_PAGE_SIZE, private int $maxPageSize = self::MAX_PAGE_SIZE, @@ -32,13 +34,18 @@ public function __construct( } } - public function createFromRequest(Request $request): DatatableRequest + public function createFromRequest(Request $request, ?DatatableDefinition $definition = null): DatatableRequest { $parameters = array_replace( $request->query->all(), $request->request->all(), ); + $advancedFilters = $this->readArrayParameter($parameters, 'advancedFilters'); + if ([] === $advancedFilters) { + $advancedFilters = $this->readArrayParameter($parameters, 'filterExpression'); + } + return DatatableRequest::create( page: $this->readPositiveInteger($parameters, 'page', $this->defaultPage), pageSize: $this->readPageSize($parameters), @@ -49,6 +56,7 @@ public function createFromRequest(Request $request): DatatableRequest visibleColumns: $this->readStringListParameter($parameters, 'visibleColumns'), hiddenColumns: $this->readStringListParameter($parameters, 'hiddenColumns'), options: $this->readArrayParameter($parameters, 'options'), + advancedFilterExpression: $this->advancedFilterExpressionFactory->createFromArray($advancedFilters, $definition), ); } diff --git a/src/Filter/Expression/AdvancedFilterExpression.php b/src/Filter/Expression/AdvancedFilterExpression.php new file mode 100644 index 0000000..79b8472 --- /dev/null +++ b/src/Filter/Expression/AdvancedFilterExpression.php @@ -0,0 +1,13 @@ + $row + */ + public function evaluate(AdvancedFilterExpression $expression, array $row): bool + { + return $this->evaluateExpression($expression->root, $row); + } + + /** + * @param array $row + */ + private function evaluateExpression(ExpressionInterface $expression, array $row): bool + { + if ($expression instanceof Group) { + return $this->evaluateGroup($expression, $row); + } + + if ($expression instanceof Condition) { + return $this->evaluateCondition($expression, $row); + } + + return false; + } + + /** + * @param array $row + */ + private function evaluateGroup(Group $group, array $row): bool + { + if (LogicOperator::And === $group->logic) { + foreach ($group->children as $child) { + if (!$this->evaluateExpression($child, $row)) { + return false; + } + } + + return true; + } + + foreach ($group->children as $child) { + if ($this->evaluateExpression($child, $row)) { + return true; + } + } + + return false; + } + + /** + * @param array $row + */ + private function evaluateCondition(Condition $condition, array $row): bool + { + $rowValue = $this->readFieldValue($row, $condition->field); + + return match ($condition->operator) { + ComparisonOperator::Equals => $this->compareEquals($rowValue, $condition->value), + ComparisonOperator::NotEquals => !$this->compareEquals($rowValue, $condition->value), + ComparisonOperator::Contains => $this->compareContains($rowValue, $condition->value), + ComparisonOperator::NotContains => !$this->compareContains($rowValue, $condition->value), + ComparisonOperator::StartsWith => $this->compareStartsWith($rowValue, $condition->value), + ComparisonOperator::EndsWith => $this->compareEndsWith($rowValue, $condition->value), + ComparisonOperator::GreaterThan => $this->compareGreaterThan($rowValue, $condition->value), + ComparisonOperator::GreaterThanOrEquals => $this->compareGreaterThanOrEquals($rowValue, $condition->value), + ComparisonOperator::LessThan => $this->compareLessThan($rowValue, $condition->value), + ComparisonOperator::LessThanOrEquals => $this->compareLessThanOrEquals($rowValue, $condition->value), + ComparisonOperator::Between => $this->compareBetween($rowValue, $condition->value), + ComparisonOperator::IsNull => null === $rowValue, + ComparisonOperator::IsNotNull => null !== $rowValue, + ComparisonOperator::In => $this->compareIn($rowValue, $condition->value), + ComparisonOperator::NotIn => !$this->compareIn($rowValue, $condition->value), + }; + } + + private function compareEquals(mixed $rowValue, mixed $conditionValue): bool + { + if ($rowValue instanceof \BackedEnum) { + $rowValue = $rowValue->value; + } + + if ($conditionValue instanceof \BackedEnum) { + $conditionValue = $conditionValue->value; + } + + if (is_numeric($rowValue) && is_numeric($conditionValue)) { + return (float) $rowValue === (float) $conditionValue; + } + + if ($rowValue instanceof \DateTimeInterface || $this->isDateString($rowValue)) { + $rowDate = $this->normalizeDateString($rowValue); + $conditionDate = $this->normalizeDateString($conditionValue); + + return null !== $rowDate && $rowDate === $conditionDate; + } + + if (is_bool($rowValue) || $this->isBooleanRepresentable($conditionValue)) { + return $this->normalizeBooleanValue($rowValue) === $this->normalizeBooleanValue($conditionValue); + } + + return mb_strtolower(is_scalar($rowValue) ? (string) $rowValue : '') === mb_strtolower(is_scalar($conditionValue) ? (string) $conditionValue : ''); + } + + private function compareContains(mixed $rowValue, mixed $conditionValue): bool + { + if (!is_scalar($rowValue) || !is_scalar($conditionValue)) { + return false; + } + + return str_contains( + mb_strtolower((string) $rowValue), + mb_strtolower((string) $conditionValue), + ); + } + + private function compareStartsWith(mixed $rowValue, mixed $conditionValue): bool + { + if (!is_scalar($rowValue) || !is_scalar($conditionValue)) { + return false; + } + + return str_starts_with( + mb_strtolower((string) $rowValue), + mb_strtolower((string) $conditionValue), + ); + } + + private function compareEndsWith(mixed $rowValue, mixed $conditionValue): bool + { + if (!is_scalar($rowValue) || !is_scalar($conditionValue)) { + return false; + } + + return str_ends_with( + mb_strtolower((string) $rowValue), + mb_strtolower((string) $conditionValue), + ); + } + + private function compareGreaterThan(mixed $rowValue, mixed $conditionValue): bool + { + if (is_numeric($rowValue) && is_numeric($conditionValue)) { + return (float) $rowValue > (float) $conditionValue; + } + + $rowDate = $this->normalizeDateString($rowValue); + $conditionDate = $this->normalizeDateString($conditionValue); + + return null !== $rowDate && null !== $conditionDate && $rowDate > $conditionDate; + } + + private function compareGreaterThanOrEquals(mixed $rowValue, mixed $conditionValue): bool + { + if (is_numeric($rowValue) && is_numeric($conditionValue)) { + return (float) $rowValue >= (float) $conditionValue; + } + + $rowDate = $this->normalizeDateString($rowValue); + $conditionDate = $this->normalizeDateString($conditionValue); + + return null !== $rowDate && null !== $conditionDate && $rowDate >= $conditionDate; + } + + private function compareLessThan(mixed $rowValue, mixed $conditionValue): bool + { + if (is_numeric($rowValue) && is_numeric($conditionValue)) { + return (float) $rowValue < (float) $conditionValue; + } + + $rowDate = $this->normalizeDateString($rowValue); + $conditionDate = $this->normalizeDateString($conditionValue); + + return null !== $rowDate && null !== $conditionDate && $rowDate < $conditionDate; + } + + private function compareLessThanOrEquals(mixed $rowValue, mixed $conditionValue): bool + { + if (is_numeric($rowValue) && is_numeric($conditionValue)) { + return (float) $rowValue <= (float) $conditionValue; + } + + $rowDate = $this->normalizeDateString($rowValue); + $conditionDate = $this->normalizeDateString($conditionValue); + + return null !== $rowDate && null !== $conditionDate && $rowDate <= $conditionDate; + } + + private function compareBetween(mixed $rowValue, mixed $conditionValue): bool + { + if (!is_array($conditionValue) || 2 !== count($conditionValue)) { + return false; + } + + [$min, $max] = array_values($conditionValue); + + return $this->compareGreaterThanOrEquals($rowValue, $min) && $this->compareLessThanOrEquals($rowValue, $max); + } + + private function compareIn(mixed $rowValue, mixed $conditionValue): bool + { + if (!is_array($conditionValue)) { + return false; + } + + foreach ($conditionValue as $value) { + if ($this->compareEquals($rowValue, $value)) { + return true; + } + } + + return false; + } + + /** + * @param array $row + */ + private function readFieldValue(array $row, string $field): mixed + { + foreach ($this->getFieldCandidateKeys($field) as $candidateKey) { + if (array_key_exists($candidateKey, $row)) { + return $row[$candidateKey]; + } + } + + return null; + } + + /** + * @return list + */ + private function getFieldCandidateKeys(string $field): array + { + $candidateKeys = [$field]; + + if (str_contains($field, '.')) { + $candidateKeys[] = str_replace('.', '_', $field); + + $parts = explode('.', $field); + $lastPart = $parts[array_key_last($parts)]; + + if ('' !== $lastPart) { + $candidateKeys[] = $lastPart; + } + } + + return array_values(array_unique($candidateKeys)); + } + + private function normalizeBooleanValue(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + + if (is_int($value)) { + return match ($value) { + 1 => true, + 0 => false, + default => null, + }; + } + + if (!is_string($value)) { + return null; + } + + return match (mb_strtolower(trim($value))) { + '1', 'true', 'yes', 'on' => true, + '0', 'false', 'no', 'off' => false, + default => null, + }; + } + + private function isBooleanRepresentable(mixed $value): bool + { + return null !== $this->normalizeBooleanValue($value); + } + + private function normalizeDateString(mixed $value): ?string + { + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d'); + } + + if (!is_scalar($value)) { + return null; + } + + $value = trim((string) $value); + + if ('' === $value) { + return null; + } + + $date = \DateTimeImmutable::createFromFormat('Y-m-d', $value); + + if (!$date instanceof \DateTimeImmutable) { + return null; + } + + return $date->format('Y-m-d'); + } + + private function isDateString(mixed $value): bool + { + return null !== $this->normalizeDateString($value); + } +} diff --git a/src/Filter/Expression/ComparisonOperator.php b/src/Filter/Expression/ComparisonOperator.php new file mode 100644 index 0000000..996113c --- /dev/null +++ b/src/Filter/Expression/ComparisonOperator.php @@ -0,0 +1,24 @@ +validateValue($operator, $value); + } + + public function getDepth(): int + { + return 0; + } + + private function validateValue(ComparisonOperator $operator, mixed $value): void + { + switch ($operator) { + case ComparisonOperator::IsNull: + case ComparisonOperator::IsNotNull: + // No value expected + break; + case ComparisonOperator::Between: + if (!is_array($value) || 2 !== count($value)) { + throw new InvalidExpressionException('Between operator requires an array of exactly 2 values.'); + } + break; + case ComparisonOperator::In: + case ComparisonOperator::NotIn: + if (!is_array($value)) { + throw new InvalidExpressionException(sprintf('"%s" operator requires an array of values.', $operator->value)); + } + break; + default: + if (null === $value) { + throw new InvalidExpressionException(sprintf('"%s" operator requires a non-null value.', $operator->value)); + } + break; + } + } +} diff --git a/src/Filter/Expression/ExpressionInterface.php b/src/Filter/Expression/ExpressionInterface.php new file mode 100644 index 0000000..a669ef6 --- /dev/null +++ b/src/Filter/Expression/ExpressionInterface.php @@ -0,0 +1,10 @@ +getDepth() > self::MAX_DEPTH) { + throw new InvalidExpressionException(sprintf('Expression tree depth exceeds maximum allowed depth of %d.', self::MAX_DEPTH)); + } + } + + public function getDepth(): int + { + $maxChildDepth = 0; + foreach ($this->children as $child) { + $maxChildDepth = max($maxChildDepth, $child->getDepth()); + } + + return 1 + $maxChildDepth; + } +} diff --git a/src/Filter/Expression/LogicOperator.php b/src/Filter/Expression/LogicOperator.php new file mode 100644 index 0000000..567ac1c --- /dev/null +++ b/src/Filter/Expression/LogicOperator.php @@ -0,0 +1,11 @@ + + */ + public static function operatorsFor(FilterType $type, bool $nullable = true): array + { + $operators = match ($type) { + FilterType::Text => [ + ComparisonOperator::Equals, + ComparisonOperator::NotEquals, + ComparisonOperator::Contains, + ComparisonOperator::NotContains, + ComparisonOperator::StartsWith, + ComparisonOperator::EndsWith, + ComparisonOperator::In, + ComparisonOperator::NotIn, + ComparisonOperator::IsNull, + ComparisonOperator::IsNotNull, + ], + FilterType::Choice => [ + ComparisonOperator::Equals, + ComparisonOperator::NotEquals, + ComparisonOperator::In, + ComparisonOperator::NotIn, + ComparisonOperator::IsNull, + ComparisonOperator::IsNotNull, + ], + FilterType::Enum => [ + ComparisonOperator::Equals, + ComparisonOperator::NotEquals, + ComparisonOperator::In, + ComparisonOperator::NotIn, + ComparisonOperator::IsNull, + ComparisonOperator::IsNotNull, + ], + FilterType::Boolean => [ + ComparisonOperator::Equals, + ComparisonOperator::NotEquals, + ComparisonOperator::IsNull, + ComparisonOperator::IsNotNull, + ], + FilterType::Number => [ + ComparisonOperator::Equals, + ComparisonOperator::NotEquals, + ComparisonOperator::GreaterThan, + ComparisonOperator::GreaterThanOrEquals, + ComparisonOperator::LessThan, + ComparisonOperator::LessThanOrEquals, + ComparisonOperator::Between, + ComparisonOperator::In, + ComparisonOperator::NotIn, + ComparisonOperator::IsNull, + ComparisonOperator::IsNotNull, + ], + FilterType::NumberRange => [ + ComparisonOperator::Between, + ], + FilterType::Date => [ + ComparisonOperator::Equals, + ComparisonOperator::NotEquals, + ComparisonOperator::GreaterThan, + ComparisonOperator::GreaterThanOrEquals, + ComparisonOperator::LessThan, + ComparisonOperator::LessThanOrEquals, + ComparisonOperator::Between, + ComparisonOperator::IsNull, + ComparisonOperator::IsNotNull, + ], + FilterType::DateRange => [ + ComparisonOperator::Between, + ], + }; + + if (!$nullable) { + $filtered = []; + foreach ($operators as $operator) { + if (ComparisonOperator::IsNull !== $operator && ComparisonOperator::IsNotNull !== $operator) { + $filtered[] = $operator; + } + } + + return $filtered; + } + + return $operators; + } + + public static function isCompatible(FilterType $type, ComparisonOperator $operator, bool $nullable = true): bool + { + return in_array($operator, self::operatorsFor($type, $nullable), true); + } +} diff --git a/src/Icon/IconResolver.php b/src/Icon/IconResolver.php new file mode 100644 index 0000000..3523b4c --- /dev/null +++ b/src/Icon/IconResolver.php @@ -0,0 +1,49 @@ + 'bi bi-eye', + 'edit' => 'bi bi-pencil', + 'delete' => 'bi bi-trash', + 'add' => 'bi bi-plus-lg', + 'check' => 'bi bi-check-lg', + 'cancel' => 'bi bi-x-lg', + 'sort_neutral' => 'bi bi-arrow-down-up', + 'sort_asc' => 'bi bi-arrow-up', + 'sort_desc' => 'bi bi-arrow-down', + 'filter' => 'bi bi-funnel', + 'filter_active' => 'bi bi-funnel-fill', + 'export' => 'bi bi-download', + 'export_csv' => 'bi bi-filetype-csv', + 'export_xlsx' => 'bi bi-filetype-xlsx', + 'export_excel' => 'bi bi-filetype-xlsx', + 'action_view' => 'bi bi-eye', + 'action_edit' => 'bi bi-pencil', + 'action_delete' => 'bi bi-trash', + 'action_create' => 'bi bi-plus-lg', + 'bulk_actions' => 'bi bi-collection', + 'boolean_true' => 'bi bi-check-lg', + 'boolean_false' => 'bi bi-x-lg', + 'search_builder' => 'bi bi-sliders', + ]; + + /** + * @param array $icons + */ + public function __construct( + private array $icons = [], + ) { + } + + public function resolve(string $key): ?string + { + return $this->icons[$key] ?? self::DEFAULT_ICONS[$key] ?? null; + } +} diff --git a/src/Provider/ArrayDataProvider.php b/src/Provider/ArrayDataProvider.php index f4c6cf7..be6936a 100644 --- a/src/Provider/ArrayDataProvider.php +++ b/src/Provider/ArrayDataProvider.php @@ -10,6 +10,7 @@ use Zhortein\DatatableBundle\Definition\UserFilterDefinition; use Zhortein\DatatableBundle\Enum\FilterType; use Zhortein\DatatableBundle\Enum\SortDirection; +use Zhortein\DatatableBundle\Filter\Expression\ArrayExpressionEvaluator; use Zhortein\DatatableBundle\Request\DatatableRequest; use Zhortein\DatatableBundle\Result\DatatableResult; @@ -31,6 +32,7 @@ public function getData(DatatableDefinition $definition, DatatableRequest $reque $totalItems = count($rows); $rows = $this->applyUserFilters($rows, $definition, $request); + $rows = $this->applyAdvancedFilters($rows, $request); $rows = $this->applySearch($rows, $definition, $request); $filteredItems = count($rows); @@ -107,7 +109,7 @@ private function rowMatchesFilter(array $row, UserFilterDefinition $filter, mixe return match ($filter->getType()) { FilterType::Text => $this->matchesTextFilter($rowValue, $filterValue), - FilterType::Choice => $this->matchesChoiceFilter($rowValue, $filterValue), + FilterType::Choice, FilterType::Enum => $this->matchesChoiceFilter($rowValue, $filterValue), FilterType::Boolean => $this->matchesBooleanFilter($rowValue, $filterValue), FilterType::Date => $this->matchesDateFilter($rowValue, $filterValue), FilterType::DateRange => $this->matchesRangeFilter($rowValue, $filterValue), @@ -424,4 +426,29 @@ private function compareValues(mixed $leftValue, mixed $rightValue): int return strnatcasecmp(get_debug_type($leftValue), get_debug_type($rightValue)); } + + /** + * @param list> $rows + * + * @return list> + */ + private function applyAdvancedFilters(array $rows, DatatableRequest $request): array + { + if (!$request->hasAdvancedFilters()) { + return $rows; + } + + $expression = $request->getAdvancedFilterExpression(); + + if (null === $expression) { + return $rows; + } + + $evaluator = new ArrayExpressionEvaluator(); + + return array_values(array_filter( + $rows, + fn (array $row): bool => $evaluator->evaluate($expression, $row), + )); + } } diff --git a/src/Provider/DoctrineOrmDataProvider.php b/src/Provider/DoctrineOrmDataProvider.php index 4228242..a5ba49d 100644 --- a/src/Provider/DoctrineOrmDataProvider.php +++ b/src/Provider/DoctrineOrmDataProvider.php @@ -15,6 +15,7 @@ use Zhortein\DatatableBundle\Definition\FilterDefinition; use Zhortein\DatatableBundle\Definition\UserFilterDefinition; use Zhortein\DatatableBundle\Doctrine\DoctrineCountExpressionFactory; +use Zhortein\DatatableBundle\Doctrine\DoctrineExpressionApplier; use Zhortein\DatatableBundle\Doctrine\DoctrineFieldMetadataResolver; use Zhortein\DatatableBundle\Doctrine\DoctrineFieldReferenceResolver; use Zhortein\DatatableBundle\Doctrine\DoctrineJoinApplier; @@ -39,6 +40,8 @@ private DoctrineCountExpressionFactory $countExpressionFactory; + private DoctrineExpressionApplier $expressionApplier; + public function __construct( private ManagerRegistry $managerRegistry, ?DoctrineFieldReferenceResolver $fieldReferenceResolver = null, @@ -46,12 +49,17 @@ public function __construct( ?DoctrineJoinApplier $joinApplier = null, ?DoctrinePaginationApplier $paginationApplier = null, ?DoctrineCountExpressionFactory $countExpressionFactory = null, + ?DoctrineExpressionApplier $expressionApplier = null, ) { $this->fieldReferenceResolver = $fieldReferenceResolver ?? new DoctrineFieldReferenceResolver(); $this->fieldMetadataResolver = $fieldMetadataResolver ?? new DoctrineFieldMetadataResolver(); $this->joinApplier = $joinApplier ?? new DoctrineJoinApplier(); $this->paginationApplier = $paginationApplier ?? new DoctrinePaginationApplier(); $this->countExpressionFactory = $countExpressionFactory ?? new DoctrineCountExpressionFactory(); + $this->expressionApplier = $expressionApplier ?? new DoctrineExpressionApplier( + fieldReferenceResolver: $this->fieldReferenceResolver, + fieldMetadataResolver: $this->fieldMetadataResolver, + ); } public function supports(DatatableDefinition $definition): bool @@ -72,7 +80,7 @@ public function getData(DatatableDefinition $definition, DatatableRequest $reque $rows = $this->loadRows($entityManager, $entityClass, $selectedColumns, $definition, $request); $totalItems = $this->countRows($entityManager, $entityClass, $definition); - $filteredItems = $request->hasSearchQuery() || $request->hasFilters() + $filteredItems = $request->hasSearchQuery() || $request->hasFilters() || $request->hasAdvancedFilters() ? $this->countRows($entityManager, $entityClass, $definition, $request) : $totalItems; @@ -163,6 +171,7 @@ private function loadRows( $this->applyPermanentFilters($queryBuilder, $definition); $this->applyUserFilters($queryBuilder, $entityManager, $entityClass, $definition, $request); + $this->applyAdvancedFilters($queryBuilder, $entityManager, $entityClass, $definition, $request); $this->applySearch($queryBuilder, $entityManager, $entityClass, $definition, $request); $this->applySorting($queryBuilder, $entityManager, $entityClass, $definition, $request); @@ -199,6 +208,7 @@ private function countRows( if (null !== $request) { $this->applyUserFilters($queryBuilder, $entityManager, $entityClass, $definition, $request); + $this->applyAdvancedFilters($queryBuilder, $entityManager, $entityClass, $definition, $request); $this->applySearch($queryBuilder, $entityManager, $entityClass, $definition, $request); } @@ -318,6 +328,35 @@ private function applyUserFilters( } } + /** + * @param class-string $entityClass + */ + private function applyAdvancedFilters( + QueryBuilder $queryBuilder, + EntityManagerInterface $entityManager, + string $entityClass, + DatatableDefinition $definition, + DatatableRequest $request, + ): void { + if (!$request->hasAdvancedFilters()) { + return; + } + + $expression = $request->getAdvancedFilterExpression(); + + if (null === $expression) { + return; + } + + $this->expressionApplier->apply( + queryBuilder: $queryBuilder, + expression: $expression, + definition: $definition, + entityManager: $entityManager, + entityClass: $entityClass, + ); + } + /** * @param class-string $entityClass */ @@ -340,7 +379,7 @@ private function applyUserFilter( match ($filter->getType()) { FilterType::Text => $this->applyTextUserFilter($queryBuilder, $fieldReference, $parameterName, $value), - FilterType::Choice => $this->applyChoiceUserFilter($queryBuilder, $fieldReference, $parameterName, $value), + FilterType::Choice, FilterType::Enum => $this->applyChoiceUserFilter($queryBuilder, $fieldReference, $parameterName, $value), FilterType::Boolean => $this->applyBooleanUserFilter($queryBuilder, $fieldReference, $parameterName, $value), FilterType::Date => $this->applyDateUserFilter($queryBuilder, $fieldReference, $parameterName, $value), FilterType::DateRange => $this->applyRangeUserFilter($queryBuilder, $fieldReference, $parameterName, $value), diff --git a/src/Renderer/DatatableRenderer.php b/src/Renderer/DatatableRenderer.php index a3e2980..206d580 100644 --- a/src/Renderer/DatatableRenderer.php +++ b/src/Renderer/DatatableRenderer.php @@ -10,7 +10,9 @@ use Zhortein\DatatableBundle\Action\ActionVisibilityCheckerInterface; use Zhortein\DatatableBundle\Action\ActionVisibilityContext; use Zhortein\DatatableBundle\Action\RowActionRouteParameterResolver; +use Zhortein\DatatableBundle\Contract\IconResolverInterface; use Zhortein\DatatableBundle\Definition\ActionDefinition; +use Zhortein\DatatableBundle\Definition\BulkActionDefinition; use Zhortein\DatatableBundle\Definition\ColumnDefinition; use Zhortein\DatatableBundle\Definition\DatatableDefinition; use Zhortein\DatatableBundle\Enum\ActionDisplayMode; @@ -27,6 +29,7 @@ */ public function __construct( private Environment $twig, + private ?IconResolverInterface $iconResolver = null, private ?UrlGeneratorInterface $urlGenerator = null, private ?RowActionRouteParameterResolver $routeParameterResolver = null, private ?ActionVisibilityCheckerInterface $actionVisibilityChecker = null, @@ -34,6 +37,7 @@ public function __construct( private string $theme = 'bootstrap', private int $defaultPageSize = 25, private bool $searchEnabled = false, + private bool $searchBuilderEnabled = false, private array $defaultTableOptions = [], ) { } @@ -49,17 +53,21 @@ public function render(DatatableDefinition $definition, array $options = []): st $options['paginationSize'] = $this->resolvePaginationSize($options)->value; $filters = $options['filters'] ?? []; - return $this->twig->render(sprintf('@ZhorteinDatatable/%s/datatable.html.twig', $this->theme), [ + $bulkActions = $this->normalizeBulkActions($definition); + + return $this->twig->render(sprintf('@ZhorteinDatatable/%s/datatable.html.twig', $this->theme), array_merge([ 'definition' => $definition, 'visibleColumns' => $this->getVisibleColumns($definition, $options), 'rowActions' => $definition->getRowActions(), 'globalActions' => $this->normalizeGlobalActions($definition), + 'bulkActions' => $bulkActions, 'hasRowActions' => [] !== $definition->getRowActions(), + 'hasBulkActions' => [] !== $bulkActions, 'htmlId' => $this->createHtmlId($definition), 'options' => $options, 'filters' => $filters, 'rowActionDisplayMode' => $this->resolveRowActionDisplayMode($definition, $options)->value, - ]); + ], $this->resolveCommonIcons())); } /** @@ -69,14 +77,15 @@ public function renderHeader(DatatableDefinition $definition, array $options = [ { $options = $this->resolveOptions($options); - return $this->twig->render(sprintf('@ZhorteinDatatable/%s/_header.html.twig', $this->theme), [ + return $this->twig->render(sprintf('@ZhorteinDatatable/%s/_header.html.twig', $this->theme), array_merge([ 'definition' => $definition, 'visibleColumns' => $this->getVisibleColumns($definition, $options), 'hasRowActions' => [] !== $definition->getRowActions(), + 'hasBulkActions' => $this->hasBulkActions($definition), 'htmlId' => $this->createHtmlId($definition), 'options' => $options, 'filters' => $options['filters'] ?? [], - ]); + ], $this->resolveCommonIcons())); } /** @@ -92,6 +101,7 @@ public function renderBody(DatatableDefinition $definition, DatatableResult $res return $this->twig->render(sprintf('@ZhorteinDatatable/%s/_body.html.twig', $this->theme), [ 'rows' => $this->normalizeRows($definition, $result, $options), + 'hasBulkActions' => $this->hasBulkActions($definition), 'htmlId' => $this->createHtmlId($definition), 'rowActionDisplayMode' => $this->resolveRowActionDisplayMode($definition, $options)->value, ]); @@ -107,6 +117,7 @@ public function renderEmptyBody(DatatableDefinition $definition, array $options return $this->twig->render(sprintf('@ZhorteinDatatable/%s/_empty.html.twig', $this->theme), [ 'visibleColumns' => $this->getVisibleColumns($definition, $options), 'hasRowActions' => [] !== $definition->getRowActions(), + 'hasBulkActions' => $this->hasBulkActions($definition), ]); } @@ -150,6 +161,7 @@ private function resolveOptions(array $options): array $this->defaultTableOptions, [ 'search' => $this->searchEnabled, + 'searchBuilder' => $this->searchBuilderEnabled, 'pageSize' => $this->defaultPageSize, ], $options, @@ -235,10 +247,46 @@ private function normalizeGlobalActions(DatatableDefinition $definition): array return $actions; } + /** + * @return list, selectedRowsParameterName: string|null}> + */ + private function normalizeBulkActions(DatatableDefinition $definition): array + { + if (null === $this->urlGenerator) { + return []; + } + + $actions = []; + + foreach ($definition->getBulkActions() as $action) { + if (!$this->isActionVisible($action, $definition, null)) { + continue; + } + + $actions[] = $this->normalizeAction( + action: $action, + url: $this->urlGenerator->generate($action->getRoute(), $action->getRouteParameters()), + ); + } + + return $actions; + } + + private function hasBulkActions(DatatableDefinition $definition): bool + { + foreach ($definition->getBulkActions() as $action) { + if ($this->isActionVisible($action, $definition, null)) { + return true; + } + } + + return false; + } + /** * @return array */ - private function normalizeStaticRouteParameters(ActionDefinition $action): array + private function normalizeStaticRouteParameters(ActionDefinition|BulkActionDefinition $action): array { return $action->getRouteParameters(); } @@ -246,11 +294,15 @@ private function normalizeStaticRouteParameters(ActionDefinition $action): array /** * @param array $options * - * @return list, actions: list}>}> + * @return list, actions: list}>, identifier: string|null}> */ private function normalizeRows(DatatableDefinition $definition, DatatableResult $result, array $options = []): array { $visibleColumns = $this->getVisibleColumns($definition, $options); + $hasBulkActions = $this->hasBulkActions($definition); + $booleanDisplayMode = $this->resolveBooleanDisplayMode($options); + $booleanTrueIcon = $this->iconResolver?->resolve('boolean_true'); + $booleanFalseIcon = $this->iconResolver?->resolve('boolean_false'); $normalizedRows = []; foreach ($result->getRows() as $row) { @@ -262,19 +314,52 @@ private function normalizeRows(DatatableDefinition $definition, DatatableResult 'value' => $this->readColumnValue($row, $column), 'template' => $this->resolveCellTemplate($column), 'className' => $this->resolveCellClassName($column), - 'booleanDisplayMode' => $this->resolveBooleanDisplayMode($options)->value, + 'booleanDisplayMode' => $booleanDisplayMode->value, + 'booleanTrueIcon' => $booleanTrueIcon, + 'booleanFalseIcon' => $booleanFalseIcon, ]; } - $normalizedRows[] = [ + $normalizedRow = [ 'cells' => $cells, 'actions' => $this->normalizeRowActions($definition, $row), + 'identifier' => null, ]; + + if ($hasBulkActions) { + $normalizedRow['identifier'] = $this->resolveRowIdentifier($row, $definition); + } + + $normalizedRows[] = $normalizedRow; } return $normalizedRows; } + /** + * @param array $row + */ + private function resolveRowIdentifier(array $row, DatatableDefinition $definition): ?string + { + $identifierKey = $definition->getOption('identifier'); + + if (is_string($identifierKey)) { + $value = $row[$identifierKey] ?? null; + + return is_scalar($value) ? (string) $value : null; + } + + foreach (['id', 'e_id'] as $candidate) { + if (array_key_exists($candidate, $row)) { + $value = $row[$candidate]; + + return is_scalar($value) ? (string) $value : null; + } + } + + return null; + } + /** * @param array $row * @@ -308,7 +393,7 @@ private function normalizeRowActions(DatatableDefinition $definition, array $row /** * @param array|null $row */ - private function isActionVisible(ActionDefinition $action, DatatableDefinition $definition, ?array $row): bool + private function isActionVisible(ActionDefinition|BulkActionDefinition $action, DatatableDefinition $definition, ?array $row): bool { if (null === $this->actionVisibilityChecker) { return true; @@ -324,16 +409,16 @@ private function isActionVisible(ActionDefinition $action, DatatableDefinition $ } /** - * @return array{name: string, label: string|null, icon: string|null, iconPosition: string, url: string, httpMethod: string, confirmationMessage: string|null, csrfToken: string|null, className: string|null, attributes: array} + * @return array{name: string, label: string|null, icon: string|null, iconPosition: string, url: string, httpMethod: string, confirmationMessage: string|null, csrfToken: string|null, className: string|null, attributes: array, selectedRowsParameterName: string|null} */ - private function normalizeAction(ActionDefinition $action, string $url): array + private function normalizeAction(ActionDefinition|BulkActionDefinition $action, string $url): array { $httpMethod = strtoupper($action->getHttpMethod()); return [ 'name' => $action->getName(), 'label' => $action->getLabel(), - 'icon' => $action->getIcon(), + 'icon' => $this->resolveActionIcon($action), 'iconPosition' => $action->getIconPosition()->value, 'url' => $url, 'httpMethod' => $httpMethod, @@ -341,9 +426,41 @@ private function normalizeAction(ActionDefinition $action, string $url): array 'csrfToken' => $this->generateCsrfToken($action, $httpMethod), 'className' => $action->getClassName(), 'attributes' => $action->getAttributes(), + 'selectedRowsParameterName' => $action instanceof BulkActionDefinition ? $action->getSelectedRowsParameterName() : null, ]; } + private function resolveActionIcon(ActionDefinition|BulkActionDefinition $action): ?string + { + if (null !== $action->getIcon()) { + return $action->getIcon(); + } + + if (null === $this->iconResolver) { + return null; + } + + $name = $action->getName(); + + $icon = match ($name) { + 'view', 'show' => $this->iconResolver->resolve('action_view'), + 'edit' => $this->iconResolver->resolve('action_edit'), + 'delete', 'remove' => $this->iconResolver->resolve('action_delete'), + 'create' => $this->iconResolver->resolve('action_create'), + default => $this->iconResolver->resolve(sprintf('action_%s', $name)), + }; + + if (null !== $icon) { + return $icon; + } + + if ($action instanceof BulkActionDefinition) { + return $this->iconResolver->resolve('bulk_actions'); + } + + return null; + } + /** * @param array $options */ @@ -360,7 +477,7 @@ private function resolveRowActionDisplayMode(DatatableDefinition $definition, ar return ActionDisplayMode::fromNullableString(is_string($definitionMode) ? $definitionMode : null); } - private function generateCsrfToken(ActionDefinition $action, string $httpMethod): ?string + private function generateCsrfToken(ActionDefinition|BulkActionDefinition $action, string $httpMethod): ?string { if ('GET' === $httpMethod || null === $this->csrfTokenManager) { return null; @@ -475,4 +592,22 @@ private function resolvePaginationSize(array $options): PaginationSize return PaginationSize::fromNullableString(is_string($size) ? $size : null); } + + /** + * @return array{sort_neutral: string|null, sort_asc: string|null, sort_desc: string|null, filter_icon: string|null, filter_active_icon: string|null, export_icon: string|null, export_csv_icon: string|null, export_xlsx_icon: string|null} + */ + private function resolveCommonIcons(): array + { + return [ + 'sort_neutral' => $this->iconResolver?->resolve('sort_neutral'), + 'sort_asc' => $this->iconResolver?->resolve('sort_asc'), + 'sort_desc' => $this->iconResolver?->resolve('sort_desc'), + 'filter_icon' => $this->iconResolver?->resolve('filter'), + 'filter_active_icon' => $this->iconResolver?->resolve('filter_active'), + 'export_icon' => $this->iconResolver?->resolve('export'), + 'export_csv_icon' => $this->iconResolver?->resolve('export_csv'), + 'export_xlsx_icon' => $this->iconResolver?->resolve('export_xlsx'), + 'search_builder_icon' => $this->iconResolver?->resolve('search_builder'), + ]; + } } diff --git a/src/Request/DatatableRequest.php b/src/Request/DatatableRequest.php index 1b9f14a..7f81359 100644 --- a/src/Request/DatatableRequest.php +++ b/src/Request/DatatableRequest.php @@ -5,6 +5,7 @@ namespace Zhortein\DatatableBundle\Request; use Zhortein\DatatableBundle\Enum\SortDirection; +use Zhortein\DatatableBundle\Filter\Expression\AdvancedFilterExpression; final readonly class DatatableRequest { @@ -24,6 +25,7 @@ public function __construct( private array $visibleColumns = [], private array $hiddenColumns = [], private array $options = [], + private ?AdvancedFilterExpression $advancedFilterExpression = null, ) { if ($this->page < 1) { throw new \InvalidArgumentException('The datatable page must be greater than or equal to 1.'); @@ -50,6 +52,7 @@ public static function create( array $visibleColumns = [], array $hiddenColumns = [], array $options = [], + ?AdvancedFilterExpression $advancedFilterExpression = null, ): self { return new self( page: $page, @@ -61,6 +64,7 @@ public static function create( visibleColumns: self::normalizeColumnList($visibleColumns), hiddenColumns: self::normalizeColumnList($hiddenColumns), options: $options, + advancedFilterExpression: $advancedFilterExpression, ); } @@ -78,6 +82,7 @@ public function withoutPagination(): self options: array_replace($this->options, [ 'disablePagination' => true, ]), + advancedFilterExpression: $this->advancedFilterExpression, ); } @@ -149,6 +154,16 @@ public function getFilter(string $name, mixed $default = null): mixed return $this->filters[$name] ?? $default; } + public function getAdvancedFilterExpression(): ?AdvancedFilterExpression + { + return $this->advancedFilterExpression; + } + + public function hasAdvancedFilters(): bool + { + return null !== $this->advancedFilterExpression; + } + /** * @return list */ diff --git a/src/ZhorteinDatatableBundle.php b/src/ZhorteinDatatableBundle.php index b700906..129a30a 100644 --- a/src/ZhorteinDatatableBundle.php +++ b/src/ZhorteinDatatableBundle.php @@ -65,6 +65,8 @@ public function loadExtension(array $config, ContainerConfigurator $configurator ->set('zhortein_datatable.default_page_size', $config['default_page_size']) ->set('zhortein_datatable.max_page_size', $config['max_page_size']) ->set('zhortein_datatable.search_enabled', $config['search_enabled']) + ->set('zhortein_datatable.search_builder_enabled', $config['search_builder_enabled']) + ->set('zhortein_datatable.icons', $config['icons'] ?? []) ->set('zhortein_datatable.bootstrap.table_striped', $tableConfig['striped']) ->set('zhortein_datatable.bootstrap.table_hover', $tableConfig['hover']) ->set('zhortein_datatable.bootstrap.table_bordered', $tableConfig['bordered']) @@ -134,6 +136,13 @@ private function configureRootNode(DefinitionConfigurator $definition): void ->booleanNode('search_enabled') ->defaultFalse() ->end() + ->booleanNode('search_builder_enabled') + ->defaultFalse() + ->end() + ->arrayNode('icons') + ->useAttributeAsKey('name') + ->scalarPrototype()->end() + ->end() ->arrayNode('bootstrap') ->addDefaultsIfNotSet() ->children() diff --git a/templates/bootstrap/_body.html.twig b/templates/bootstrap/_body.html.twig index 73f038e..73f09d4 100644 --- a/templates/bootstrap/_body.html.twig +++ b/templates/bootstrap/_body.html.twig @@ -3,6 +3,7 @@ row: row, htmlId: htmlId, rowIndex: loop.index, - rowActionDisplayMode: rowActionDisplayMode + rowActionDisplayMode: rowActionDisplayMode, + hasBulkActions: hasBulkActions|default(false) } only %} {% endfor %} diff --git a/templates/bootstrap/_bulk_actions.html.twig b/templates/bootstrap/_bulk_actions.html.twig new file mode 100644 index 0000000..7c63cfd --- /dev/null +++ b/templates/bootstrap/_bulk_actions.html.twig @@ -0,0 +1,55 @@ +{% if bulkActions is not empty %} + +{% endif %} diff --git a/templates/bootstrap/_cell.html.twig b/templates/bootstrap/_cell.html.twig index 4984d38..b3984fc 100644 --- a/templates/bootstrap/_cell.html.twig +++ b/templates/bootstrap/_cell.html.twig @@ -2,6 +2,8 @@ {% include template with { column: column, value: value, - boolean_display_mode: boolean_display_mode + boolean_display_mode: boolean_display_mode, + boolean_true_icon: boolean_true_icon, + boolean_false_icon: boolean_false_icon } only %} diff --git a/templates/bootstrap/_column_filter.html.twig b/templates/bootstrap/_column_filter.html.twig index 86f4be1..c0ebd21 100644 --- a/templates/bootstrap/_column_filter.html.twig +++ b/templates/bootstrap/_column_filter.html.twig @@ -18,7 +18,13 @@ '%column%': filter_label }, 'zhortein_datatable') }}" > - + {% if is_active and filter_active_icon %} + + {% elseif filter_icon %} + + {% else %} + + {% endif %}
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 @@ @@ -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 @@ +
+
+
+
{{ 'zhortein_datatable.search_builder.title'|trans({}, 'zhortein_datatable') }}
+
+ + +
+
+ +
+ {# 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 ` +
+ + + + + + + + + + + + + + + + + + + +
+ + Email
+ + alice@example.test
+ + bob@example.test
+
+ `; +} + +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('