From 70f52d93ac2ef86f556a0f7d0b20d239dc25bc90 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 07:08:14 +0200 Subject: [PATCH 01/39] docs: update roadmap after second alpha (#389) --- docs/roadmap.md | 248 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 209 insertions(+), 39 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 6224ab4..92cf639 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,11 +751,157 @@ 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. -## 0.21 - Frontend E2E and accessibility evaluation 🚧 +## 0.24 - Frontend E2E and accessibility evaluation 🚧 Goal: @@ -770,6 +916,7 @@ Possible work: - test keyboard navigation; - test column header filter dropdown UX; - test action dropdown UX; +- test modal confirmation UX; - test CSV/XLSX export link behavior; - test loading and error state visibility; - add basic accessibility checks where practical; @@ -790,29 +937,53 @@ Out of scope for the first pass: --- -## 0.22 - Documentation overhaul ✅ +## 0.25 - Bulk actions and row selection 🕒 Goal: ```text -Audit, reorganize and rewrite the documentation before moving closer to beta/stable releases. +Add first-class support for selecting rows and executing bulk actions safely. ``` -Delivered: +Possible work: -- 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. +- selector column; +- selected-row state in Stimulus; +- current-page and filtered-dataset action modes; +- CSRF-aware POST actions; +- authorization-aware visibility; +- backend payload normalization; +- documentation and smoke tests. -Main outcome: +Main expected outcome: ```text -The documentation is now structured, clear and professional, providing a solid foundation for external users. +Datatables can support common back-office bulk workflows without each application reinventing row selection. +``` + +--- + +## 0.26 - Hierarchical tables / expandable child datatables 🕒 + +Goal: + +```text +Support expandable detail rows and nested datatable scenarios for hierarchical business data. +``` + +Possible work: + +- expandable detail rows; +- nested datatable rendering; +- parent-row context propagation; +- lazy Ajax loading; +- recursion and performance safeguards; +- accessibility review. + +Main expected outcome: + +```text +Applications can represent parent/child business data without leaving the datatable interaction model. ``` --- @@ -825,22 +996,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. @@ -853,29 +1028,20 @@ 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; +- browser E2E test suite; +- 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 +1071,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; From 59fafeed40e0e89c17cb79b2d03dfcaff15fca5b Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 17 May 2026 07:58:04 +0200 Subject: [PATCH 02/39] chore: prepare bulk actions milestone --- docs/roadmap.md | 159 ++++++-- ...reate-bulk-actions-row-selection-issues.sh | 370 ++++++++++++++++++ tools/github/sync-labels.sh | 2 + 3 files changed, 491 insertions(+), 40 deletions(-) create mode 100755 tools/github/create-bulk-actions-row-selection-issues.sh diff --git a/docs/roadmap.md b/docs/roadmap.md index 92cf639..a42c61b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -901,89 +901,168 @@ This decision should be revisited after more real-world installations. The next milestone should focus on browser-level validation and accessibility. -## 0.24 - Frontend E2E and accessibility evaluation 🚧 +The next milestone should focus on production-oriented table actions. + +## 0.24 - Bulk actions and row selection 🚧 Goal: ```text -Validate the most interactive datatable behavior in a real browser and define the accessibility baseline before moving toward 1.0. +Add first-class support for row selection and bulk actions in business datatables. ``` -Possible work: +Planned: + +- define the bulk action declaration API; +- add row selection column rendering; +- add selected row state management in Stimulus; +- support selecting/unselecting one row; +- support selecting/unselecting all visible rows; +- render bulk action toolbar or bottom action area; +- submit selected identifiers through CSRF-aware non-GET forms; +- support confirmation metadata for bulk actions; +- integrate action visibility/security for bulk actions; +- support current-page selected rows first; +- document limitations around filtered dataset / all matching rows; +- smoke test bulk actions in the fresh Symfony app. -- choose whether Playwright is useful for this bundle; -- test Bootstrap dropdown behavior in a real browser; -- test keyboard navigation; -- test column header filter dropdown UX; -- test action dropdown UX; -- test modal confirmation UX; -- test CSV/XLSX export link behavior; -- test loading and error state visibility; -- add basic accessibility checks where practical; -- document findings and limitations. +Main expected outcome: + +```text +Datatables can perform safe backend-defined actions on multiple selected rows, which is required for production back-office workflows. +``` + +Out of scope for first implementation: + +- selecting all rows across all filtered pages; +- async bulk operations; +- queueing; +- progress UI; +- persisted selection across navigation; +- complex permission matrix; +- bulk edit forms; +- tree/hierarchical bulk actions. + +--- + +## 0.25 - Icon system and visual consistency 🕒 + +Goal: + +```text +Provide a consistent, configurable icon strategy across actions, booleans, sorting, filters and exports. +``` + +Planned: + +- define a lightweight icon strategy; +- keep CSS-class based icons supported; +- document Bootstrap Icons and FontAwesome usage; +- provide default icon names/classes; +- support icons for: + - row actions; + - global actions; + - bulk actions; + - booleans; + - sort indicators; + - filters; + - exports; +- keep icon libraries optional; +- avoid hard dependency on a specific icon set. Main expected outcome: ```text -The bundle has a clear browser-level and accessibility validation strategy for its Bootstrap/Stimulus UI. +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. ``` -Out of scope for the first pass: +Planned: + +- design a filter expression model; +- support grouped conditions; +- support AND / OR combinations; +- support type-aware operators; +- support Doctrine-safe mapping; +- keep frontend input declarative and validated; +- document limitations; +- add tests for expression normalization and Doctrine application. + +Suggested public terminology: -- full visual regression testing; -- cross-browser matrix; -- complete WCAG audit; -- hosted demo application. +```text +Advanced filter expressions +``` + +Avoid public wording such as “query builder” if it could imply exposing Doctrine internals. + +Main expected outcome: + +```text +Users can build richer filters safely while the backend remains in control of query generation. +``` --- -## 0.25 - Bulk actions and row selection 🕒 +## 0.27 - Frontend E2E and accessibility evaluation 🕒 Goal: ```text -Add first-class support for selecting rows and executing bulk actions safely. +Validate the most interactive UI behavior in a real browser and define an accessibility baseline. ``` -Possible work: +Planned: -- selector column; -- selected-row state in Stimulus; -- current-page and filtered-dataset action modes; -- CSRF-aware POST actions; -- authorization-aware visibility; -- backend payload normalization; -- documentation and smoke tests. +- decide whether Playwright or another browser-level tool is needed; +- test Bootstrap dropdown behavior in a real browser; +- test keyboard navigation; +- 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 -Datatables can support common back-office bulk workflows without each application reinventing row selection. +The most interactive Bootstrap/Stimulus behaviors are validated beyond jsdom unit tests. ``` --- -## 0.26 - Hierarchical tables / expandable child datatables 🕒 +## 0.28 - Hierarchical tables / expandable child datatables 🕒 Goal: ```text -Support expandable detail rows and nested datatable scenarios for hierarchical business data. +Support expandable rows and child datatables for hierarchical business data. ``` -Possible work: +Planned: -- expandable detail rows; -- nested datatable rendering; -- parent-row context propagation; -- lazy Ajax loading; -- recursion and performance safeguards; -- accessibility review. +- 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 expected outcome: ```text -Applications can represent parent/child business data without leaving the datatable interaction model. +Datatables can represent parent/child business structures without custom per-project table code. ``` --- diff --git a/tools/github/create-bulk-actions-row-selection-issues.sh b/tools/github/create-bulk-actions-row-selection-issues.sh new file mode 100755 index 0000000..5c591f5 --- /dev/null +++ b/tools/github/create-bulk-actions-row-selection-issues.sh @@ -0,0 +1,370 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI is required. Install it first: https://cli.github.com/" + exit 1 +fi + +gh auth status >/dev/null + +MILESTONE_TITLE="0.24 - Bulk actions and row selection" + +ensure_milestone() { + if gh api repos/:owner/:repo/milestones --jq ".[] | select(.title == \"${MILESTONE_TITLE}\") | .title" | grep -q "${MILESTONE_TITLE}"; then + echo "Milestone already exists: ${MILESTONE_TITLE}" + else + echo "Creating milestone: ${MILESTONE_TITLE}" + gh api repos/:owner/:repo/milestones \ + -f title="${MILESTONE_TITLE}" \ + -f description="Add production-oriented row selection and bulk actions for business datatables." + fi +} + +issue_exists() { + local title="$1" + gh issue list --state all --search "$title in:title" --json title --jq ".[].title" | grep -Fxq "$title" +} + +create_issue() { + local title="$1" + local labels="$2" + local body_file="$3" + + if issue_exists "$title"; then + echo "Issue already exists: $title" + return + fi + + local label_args=() + IFS=',' read -ra label_list <<< "$labels" + + for raw_label in "${label_list[@]}"; do + local label + label="$(printf "%s" "$raw_label" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + + if [ -n "$label" ]; then + label_args+=(--label "$label") + fi + done + + echo "Creating issue: $title" + + gh issue create \ + --title "$title" \ + --body-file "$body_file" \ + --milestone "$MILESTONE_TITLE" \ + "${label_args[@]}" +} + +make_body() { + local tmpfile + tmpfile="$(mktemp)" + cat > "$tmpfile" + echo "$tmpfile" +} + +ensure_milestone + +body="$(make_body <<'BODY' +## Objective + +Design the backend and public API for bulk actions. + +## Context + +Bulk actions are required for production back-office datatables. The bundle already supports row/global actions, CSRF-aware non-GET actions, confirmation metadata and action visibility. + +Bulk actions must reuse the same philosophy: + +- backend-defined; +- explicit; +- CSRF-aware; +- authorization-aware through extension points; +- Bootstrap-first rendering; +- Stimulus-enhanced. + +## Scope + +- Define `BulkActionDefinition` or equivalent. +- Define API on `DatatableDefinition`, for example: + - `addBulkAction(...)` + - `getBulkActions()` +- Decide supported metadata: + - name; + - route; + - label; + - icon; + - HTTP method; + - confirmation message; + - CSS class; + - attributes; + - route parameters if needed. +- Decide payload format for selected row identifiers. +- Decide whether first version supports selected rows only or all filtered rows. +- Document accepted limitations. + +## Out of scope + +- UI implementation. +- Stimulus selection state. +- Backend action controllers. +- All-filtered-rows selection. +- Async bulk jobs. + +## Acceptance criteria + +- [ ] Bulk action API is designed. +- [ ] Public API decision is documented. +- [ ] Tests cover definition object and datatable definition storage. +- [ ] Current row/global action API remains unchanged. +- [ ] QA passes. +BODY +)" +create_issue "Design bulk action declaration API" "type: architecture,area: actions,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Render a row selection column when bulk actions are enabled. + +## Scope + +- Add selector column rendering when datatable has bulk actions. +- Render a header checkbox for current visible rows. +- Render a row checkbox per row. +- Use stable row identifiers. +- Use accessible labels. +- Add renderer tests. +- Keep layout Bootstrap-first. + +## Out of scope + +- Selection state persistence across pages. +- Backend bulk action execution. +- All-filtered-rows selection. + +## Acceptance criteria + +- [ ] Header checkbox renders when bulk actions exist. +- [ ] Row checkboxes render for each row. +- [ ] Checkbox values contain row identifiers. +- [ ] No selector column renders when no bulk actions exist. +- [ ] Tests cover selector rendering. +- [ ] QA passes. +BODY +)" +create_issue "Render row selection column for bulk actions" "type: feature,area: twig,area: bootstrap,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Add Stimulus state management for selected rows. + +## Scope + +- Track selected row identifiers in the datatable controller. +- Support selecting/unselecting one row. +- Support selecting/unselecting all visible rows. +- Update header checkbox state. +- Expose selected count in the DOM. +- Reset selection when data refreshes if needed. +- Add frontend tests. + +## Out of scope + +- Persisting selection across pages. +- Selecting all filtered rows. +- Backend execution. + +## Acceptance criteria + +- [ ] Single row selection works. +- [ ] Select all visible rows works. +- [ ] Unselect all visible rows works. +- [ ] Header checkbox state updates. +- [ ] Selected count updates. +- [ ] Tests cover state behavior. +- [ ] Frontend tests pass. +BODY +)" +create_issue "Implement Stimulus row selection state" "type: feature,area: stimulus,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Render a bulk action toolbar when bulk actions are available. + +## Scope + +- Add a bulk action area to the datatable UI. +- Render bulk action buttons/forms. +- Show selected count. +- Disable bulk actions when no rows are selected. +- Keep layout compatible with default and split controls layout. +- Add renderer tests. + +## Out of scope + +- Actual backend execution. +- Async jobs. +- Advanced responsive toolbar. + +## Acceptance criteria + +- [ ] Bulk action toolbar renders only when bulk actions exist. +- [ ] Selected count target exists. +- [ ] Bulk action controls are disabled with no selection. +- [ ] UI works in default and split layouts. +- [ ] Tests cover rendering. +- [ ] QA passes. +BODY +)" +create_issue "Render bulk action toolbar" "type: feature,area: twig,area: bootstrap,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Submit selected row identifiers with bulk actions. + +## Scope + +- Serialize selected row IDs into bulk action form payload. +- Support non-GET bulk actions as CSRF-aware forms. +- Support GET bulk actions only if explicitly useful. +- Use existing confirmation metadata if possible. +- Add frontend tests for payload generation. +- Add renderer/controller tests where useful. + +## Out of scope + +- Implementing host application action controllers. +- Async bulk action processing. +- All-filtered-rows mode. + +## Acceptance criteria + +- [ ] Selected identifiers are submitted. +- [ ] Non-GET bulk actions include CSRF token. +- [ ] Confirmation works for bulk actions. +- [ ] Empty selection prevents submit or keeps action disabled. +- [ ] Tests cover payload generation. +- [ ] QA passes. +BODY +)" +create_issue "Submit selected row identifiers for bulk actions" "type: feature,area: actions,area: stimulus,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Integrate bulk action visibility and security extension points. + +## Scope + +- Decide whether existing `ActionVisibilityCheckerInterface` can handle bulk actions or whether a dedicated interface is needed. +- Ensure hidden bulk actions do not render URLs/forms. +- Ensure CSRF behavior is preserved. +- Document that backend routes must enforce authorization. +- Add tests. + +## Out of scope + +- Built-in voters. +- Security expression language. +- Backend action controller implementation. + +## Acceptance criteria + +- [ ] Bulk action visibility is controllable. +- [ ] Hidden bulk actions are not rendered. +- [ ] CSRF-aware behavior is tested. +- [ ] Documentation explains backend authorization responsibility. +- [ ] QA passes. +BODY +)" +create_issue "Integrate bulk action visibility and security" "type: security,area: actions,priority: medium,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Smoke test bulk actions in the fresh Symfony application. + +## Scope + +- Add one or more bulk actions to the smoke datatable. +- Validate selector column. +- Validate select one row. +- Validate select all visible rows. +- Validate selected count. +- Validate disabled/enabled bulk action controls. +- Validate bulk action payload. +- Validate CSRF and confirmation. +- Record findings. + +## Acceptance criteria + +- [ ] Bulk action smoke path passes. +- [ ] Findings are recorded. +- [ ] Non-blocking issues are tracked. +BODY +)" +create_issue "Smoke test bulk actions and row selection" "type: tests,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Document bulk actions and row selection. + +## Scope + +- Add user-facing documentation. +- Explain selector column. +- Explain selected row payload. +- Explain CSRF and confirmation. +- Explain security responsibilities. +- Explain limitations: + - no all-filtered-rows selection yet; + - no persisted selection across pages; + - no async bulk jobs. +- Add examples. + +## Acceptance criteria + +- [ ] Bulk actions are documented. +- [ ] Limitations are clear. +- [ ] README/docs index link is updated if needed. +- [ ] QA passes. +BODY +)" +create_issue "Document bulk actions and row selection" "type: docs,area: actions,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Update roadmap after bulk actions milestone. + +## Scope + +- Mark 0.24 as completed. +- Clarify remaining limitations. +- Move next milestone to icon system and visual consistency unless priorities change. + +## Acceptance criteria + +- [ ] Roadmap updated. +- [ ] QA passes. +BODY +)" +create_issue "Update roadmap after bulk actions" "type: docs,priority: medium,ai-ready" "$body" +rm -f "$body" + +echo "Bulk actions milestone issues created successfully." diff --git a/tools/github/sync-labels.sh b/tools/github/sync-labels.sh index dcc6ec1..5c6f5a8 100755 --- a/tools/github/sync-labels.sh +++ b/tools/github/sync-labels.sh @@ -38,6 +38,7 @@ sync_label "type: architecture" "5319e7" "Architecture or design decision" sync_label "type: feature" "1d76db" "New feature" sync_label "type: enhancement" "1d76db" "New feature" sync_label "type: release" "0e8a16" "Release" +sync_label "type: security" "1d76db" "Release" sync_label "type: chore" "fef2c0" "Chore" sync_label "type: bug" "d73a4a" "Bug fix" sync_label "type: docs" "0075ca" "Documentation task" @@ -56,6 +57,7 @@ sync_label "area: configuration" "006b75" "Bundle configuration" sync_label "area: export" "006b75" "CSV/XLSX or other export features" sync_label "area: security" "006b75" "Security, CSRF, permissions or safe defaults" sync_label "area: i18n" "006b75" "Translations and localization" +sync_label "area: actions" "006b75" "Translations and localization" # Priorities sync_label "priority: high" "b60205" "High priority" From 667c40468ed8f7230aab128d72417fd197143d7f Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 08:26:32 +0200 Subject: [PATCH 03/39] feat: Design bulk action declaration API (#399) --- src/Definition/BulkActionDefinition.php | 95 +++++++++++++++++++ src/Definition/DatatableDefinition.php | 50 ++++++++++ .../Definition/BulkActionDefinitionTest.php | 53 +++++++++++ .../DatatableDefinitionBulkActionTest.php | 33 +++++++ 4 files changed, 231 insertions(+) create mode 100644 src/Definition/BulkActionDefinition.php create mode 100644 tests/Unit/Definition/BulkActionDefinitionTest.php create mode 100644 tests/Unit/Definition/DatatableDefinitionBulkActionTest.php diff --git a/src/Definition/BulkActionDefinition.php b/src/Definition/BulkActionDefinition.php new file mode 100644 index 0000000..c117417 --- /dev/null +++ b/src/Definition/BulkActionDefinition.php @@ -0,0 +1,95 @@ + $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..f461bec 100644 --- a/src/Definition/DatatableDefinition.php +++ b/src/Definition/DatatableDefinition.php @@ -34,6 +34,11 @@ final class DatatableDefinition */ private array $globalActions = []; + /** + * @var array + */ + private array $bulkActions = []; + /** * @var list */ @@ -284,6 +289,51 @@ public function getGlobalActions(): array return $this->globalActions; } + /** + * @param array $routeParameters + * @param array $attributes + */ + 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, 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/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()); + } +} From fee71402430b8d0e059a1deda5f37bca284207d6 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 08:37:25 +0200 Subject: [PATCH 04/39] feat: Render row selection column for bulk actions (#400) --- src/Renderer/DatatableRenderer.php | 40 ++++- templates/bootstrap/_body.html.twig | 3 +- templates/bootstrap/_empty.html.twig | 2 +- templates/bootstrap/_header.html.twig | 11 ++ templates/bootstrap/_row.html.twig | 13 ++ .../Unit/Renderer/BulkActionRendererTest.php | 141 ++++++++++++++++++ translations/zhortein_datatable.en.yaml | 3 + translations/zhortein_datatable.fr.yaml | 3 + 8 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Renderer/BulkActionRendererTest.php diff --git a/src/Renderer/DatatableRenderer.php b/src/Renderer/DatatableRenderer.php index a3e2980..8759c2a 100644 --- a/src/Renderer/DatatableRenderer.php +++ b/src/Renderer/DatatableRenderer.php @@ -55,6 +55,7 @@ public function render(DatatableDefinition $definition, array $options = []): st 'rowActions' => $definition->getRowActions(), 'globalActions' => $this->normalizeGlobalActions($definition), 'hasRowActions' => [] !== $definition->getRowActions(), + 'hasBulkActions' => [] !== $definition->getBulkActions(), 'htmlId' => $this->createHtmlId($definition), 'options' => $options, 'filters' => $filters, @@ -73,6 +74,7 @@ public function renderHeader(DatatableDefinition $definition, array $options = [ 'definition' => $definition, 'visibleColumns' => $this->getVisibleColumns($definition, $options), 'hasRowActions' => [] !== $definition->getRowActions(), + 'hasBulkActions' => [] !== $definition->getBulkActions(), 'htmlId' => $this->createHtmlId($definition), 'options' => $options, 'filters' => $options['filters'] ?? [], @@ -92,6 +94,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' => [] !== $definition->getBulkActions(), 'htmlId' => $this->createHtmlId($definition), 'rowActionDisplayMode' => $this->resolveRowActionDisplayMode($definition, $options)->value, ]); @@ -107,6 +110,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' => [] !== $definition->getBulkActions(), ]); } @@ -246,11 +250,12 @@ 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 = [] !== $definition->getBulkActions(); $normalizedRows = []; foreach ($result->getRows() as $row) { @@ -266,15 +271,46 @@ private function normalizeRows(DatatableDefinition $definition, DatatableResult ]; } - $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 * 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/_empty.html.twig b/templates/bootstrap/_empty.html.twig index ce4ab46..fdad25d 100644 --- a/templates/bootstrap/_empty.html.twig +++ b/templates/bootstrap/_empty.html.twig @@ -1,4 +1,4 @@ -{% set colspan = visibleColumns|length + (hasRowActions is defined and hasRowActions ? 1 : 0) %} +{% set colspan = visibleColumns|length + (hasRowActions is defined and hasRowActions ? 1 : 0) + (hasBulkActions is defined and hasBulkActions ? 1 : 0) %} diff --git a/templates/bootstrap/_header.html.twig b/templates/bootstrap/_header.html.twig index b6f3c8e..0c1f28e 100644 --- a/templates/bootstrap/_header.html.twig +++ b/templates/bootstrap/_header.html.twig @@ -5,6 +5,17 @@ + {% if hasBulkActions|default(false) %} + + + + {% endif %} + {% for column in visibleColumns %} {% set is_current_sort = column.name == current_sort_field %} {% set aria_sort = null %} diff --git a/templates/bootstrap/_row.html.twig b/templates/bootstrap/_row.html.twig index 42d044a..cc6a0a6 100644 --- a/templates/bootstrap/_row.html.twig +++ b/templates/bootstrap/_row.html.twig @@ -1,4 +1,17 @@ + {% if hasBulkActions and row.identifier is defined %} + + + + {% endif %} + {% for cell in row.cells %} {% include '@ZhorteinDatatable/bootstrap/_cell.html.twig' with { column: cell.column, diff --git a/tests/Unit/Renderer/BulkActionRendererTest.php b/tests/Unit/Renderer/BulkActionRendererTest.php new file mode 100644 index 0000000..3523ae3 --- /dev/null +++ b/tests/Unit/Renderer/BulkActionRendererTest.php @@ -0,0 +1,141 @@ +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); + } + + 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/translations/zhortein_datatable.en.yaml b/translations/zhortein_datatable.en.yaml index 0c452aa..3fec335 100644 --- a/translations/zhortein_datatable.en.yaml +++ b/translations/zhortein_datatable.en.yaml @@ -18,6 +18,9 @@ zhortein_datatable: csv_full: 'CSV full dataset' xlsx_current: 'XLSX current view' xlsx_full: 'XLSX full dataset' + bulk_actions: + select_all: 'Select all' + select_row: 'Select row %id%' loading: 'Loading...' error: generic: 'Unable to load datatable data.' diff --git a/translations/zhortein_datatable.fr.yaml b/translations/zhortein_datatable.fr.yaml index 2a8b789..0e82a29 100644 --- a/translations/zhortein_datatable.fr.yaml +++ b/translations/zhortein_datatable.fr.yaml @@ -18,6 +18,9 @@ zhortein_datatable: csv_full: 'CSV complet' xlsx_current: 'XLSX vue courante' xlsx_full: 'XLSX complet' + bulk_actions: + select_all: 'Tout sélectionner' + select_row: 'Sélectionner la ligne %id%' loading: 'Chargement...' error: generic: 'Impossible de charger les données du tableau.' From d2ffb92640aff92b408fed8a7e006cbd70fa9a1a Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 08:45:25 +0200 Subject: [PATCH 05/39] feat: Implement stimulus row selection state (#401) --- assets/controllers/datatable_controller.js | 52 +++++ templates/bootstrap/_header.html.twig | 1 + templates/bootstrap/_row.html.twig | 1 + .../datatable_controller_selection.test.js | 218 ++++++++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 tests/Frontend/datatable_controller_selection.test.js diff --git a/assets/controllers/datatable_controller.js b/assets/controllers/datatable_controller.js index dd8625b..7c29e07 100644 --- a/assets/controllers/datatable_controller.js +++ b/assets/controllers/datatable_controller.js @@ -22,6 +22,9 @@ export default class extends Controller { 'confirmationModal', 'confirmationMessage', 'confirmationConfirmButton', + 'selectAllCheckbox', + 'rowCheckbox', + 'selectedCount', ]; static values = { @@ -48,6 +51,7 @@ export default class extends Controller { this.pendingConfirmationTarget = null; this.pendingConfirmationType = null; this.confirmationModalInstance = null; + this.selectedIds = new Set(); this.updateActiveFilterState(); @@ -322,6 +326,52 @@ 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.selectedCountTarget.textContent = String(selectedCount); + } + } + buildFragmentsUrl() { const url = new URL(this.fragmentsUrlValue, window.location.origin); @@ -467,6 +517,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') { diff --git a/templates/bootstrap/_header.html.twig b/templates/bootstrap/_header.html.twig index 0c1f28e..5e1a99d 100644 --- a/templates/bootstrap/_header.html.twig +++ b/templates/bootstrap/_header.html.twig @@ -10,6 +10,7 @@ diff --git a/templates/bootstrap/_row.html.twig b/templates/bootstrap/_row.html.twig index cc6a0a6..d3e5252 100644 --- a/templates/bootstrap/_row.html.twig +++ b/templates/bootstrap/_row.html.twig @@ -6,6 +6,7 @@ class="form-check-input" name="selected_rows[]" value="{{ row.identifier }}" + data-zhortein--datatable-bundle--datatable-target="rowCheckbox" data-action="zhortein--datatable-bundle--datatable#selectRow" aria-label="{{ 'zhortein_datatable.bulk_actions.select_row'|trans({'%id%': row.identifier}, 'zhortein_datatable') }}" > diff --git a/tests/Frontend/datatable_controller_selection.test.js b/tests/Frontend/datatable_controller_selection.test.js new file mode 100644 index 0000000..e9b2cde --- /dev/null +++ b/tests/Frontend/datatable_controller_selection.test.js @@ -0,0 +1,218 @@ +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 ` +
+ 0 + + + + + + + + + + + + + + + + + + +
+ + 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); + }); +}); From ba62ab2fdc7415adb1feb8dacbb8d39ef40bb3b9 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 08:58:12 +0200 Subject: [PATCH 06/39] feat: Render bulk action toolbar (#402) --- assets/controllers/datatable_controller.js | 16 +++++- src/Renderer/DatatableRenderer.php | 32 +++++++++-- templates/bootstrap/_bulk_actions.html.twig | 54 +++++++++++++++++++ templates/bootstrap/datatable.html.twig | 4 ++ .../datatable_controller_selection.test.js | 32 ++++++++++- .../Unit/Renderer/BulkActionRendererTest.php | 49 +++++++++++++++++ translations/zhortein_datatable.en.yaml | 1 + translations/zhortein_datatable.fr.yaml | 1 + 8 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 templates/bootstrap/_bulk_actions.html.twig diff --git a/assets/controllers/datatable_controller.js b/assets/controllers/datatable_controller.js index 7c29e07..236b9ba 100644 --- a/assets/controllers/datatable_controller.js +++ b/assets/controllers/datatable_controller.js @@ -25,6 +25,8 @@ export default class extends Controller { 'selectAllCheckbox', 'rowCheckbox', 'selectedCount', + 'bulkToolbar', + 'bulkAction', ]; static values = { @@ -368,7 +370,19 @@ export default class extends Controller { } if (this.hasSelectedCountTarget) { - this.selectedCountTarget.textContent = String(selectedCount); + 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; + }); } } diff --git a/src/Renderer/DatatableRenderer.php b/src/Renderer/DatatableRenderer.php index 8759c2a..371f017 100644 --- a/src/Renderer/DatatableRenderer.php +++ b/src/Renderer/DatatableRenderer.php @@ -11,6 +11,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\ColumnDefinition; use Zhortein\DatatableBundle\Definition\DatatableDefinition; use Zhortein\DatatableBundle\Enum\ActionDisplayMode; @@ -54,6 +55,7 @@ public function render(DatatableDefinition $definition, array $options = []): st 'visibleColumns' => $this->getVisibleColumns($definition, $options), 'rowActions' => $definition->getRowActions(), 'globalActions' => $this->normalizeGlobalActions($definition), + 'bulkActions' => $this->normalizeBulkActions($definition), 'hasRowActions' => [] !== $definition->getRowActions(), 'hasBulkActions' => [] !== $definition->getBulkActions(), 'htmlId' => $this->createHtmlId($definition), @@ -239,10 +241,31 @@ 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) { + $actions[] = $this->normalizeAction( + action: $action, + url: $this->urlGenerator->generate($action->getRoute(), $action->getRouteParameters()), + ); + } + + return $actions; + } + /** * @return array */ - private function normalizeStaticRouteParameters(ActionDefinition $action): array + private function normalizeStaticRouteParameters(ActionDefinition|BulkActionDefinition $action): array { return $action->getRouteParameters(); } @@ -360,9 +383,9 @@ 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()); @@ -377,6 +400,7 @@ 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, ]; } @@ -396,7 +420,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; diff --git a/templates/bootstrap/_bulk_actions.html.twig b/templates/bootstrap/_bulk_actions.html.twig new file mode 100644 index 0000000..4d76c75 --- /dev/null +++ b/templates/bootstrap/_bulk_actions.html.twig @@ -0,0 +1,54 @@ +{% if bulkActions is not empty %} + +{% endif %} diff --git a/templates/bootstrap/datatable.html.twig b/templates/bootstrap/datatable.html.twig index f27030a..c840bd5 100644 --- a/templates/bootstrap/datatable.html.twig +++ b/templates/bootstrap/datatable.html.twig @@ -81,6 +81,10 @@ filters: filters|default({}) } only %} + {% include '@ZhorteinDatatable/bootstrap/_bulk_actions.html.twig' with { + bulkActions: bulkActions|default([]) + } only %} + + `; +} + +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/Unit/Renderer/BulkActionRendererTest.php b/tests/Unit/Renderer/BulkActionRendererTest.php index 1f0095f..ce33047 100644 --- a/tests/Unit/Renderer/BulkActionRendererTest.php +++ b/tests/Unit/Renderer/BulkActionRendererTest.php @@ -131,6 +131,7 @@ 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(), @@ -142,10 +143,19 @@ public function test_it_renders_bulk_action_toolbar(): void 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 From 235c4c38fe7527dfb98b8357a06c922ba744d598 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 09:26:23 +0200 Subject: [PATCH 08/39] feat: Integrate bulk action visibility and security (#404) --- .../ActionVisibilityCheckerInterface.php | 3 +- .../AllowAllActionVisibilityChecker.php | 3 +- .../AuthorizationActionVisibilityChecker.php | 3 +- src/Definition/BulkActionDefinition.php | 9 ++ src/Definition/DatatableDefinition.php | 3 + src/Renderer/DatatableRenderer.php | 31 +++- .../AllowAllActionVisibilityCheckerTest.php | 13 ++ ...thorizationActionVisibilityCheckerTest.php | 27 ++++ ...atableRendererBulkActionVisibilityTest.php | 143 ++++++++++++++++++ ...ableRendererGlobalActionVisibilityTest.php | 5 +- ...tatableRendererRowActionVisibilityTest.php | 5 +- 11 files changed, 231 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/Renderer/DatatableRendererBulkActionVisibilityTest.php 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/Definition/BulkActionDefinition.php b/src/Definition/BulkActionDefinition.php index c117417..7fba107 100644 --- a/src/Definition/BulkActionDefinition.php +++ b/src/Definition/BulkActionDefinition.php @@ -6,6 +6,15 @@ use Zhortein\DatatableBundle\Enum\ActionIconPosition; +/** + * Bulk actions are used to perform actions on multiple rows at once. + * + * NOTE: Visibility checks only control whether the action is rendered in the UI. + * The backend route MUST also enforce authorization and validate the request. + * + * @param array $routeParameters + * @param array $attributes + */ final readonly class BulkActionDefinition { /** diff --git a/src/Definition/DatatableDefinition.php b/src/Definition/DatatableDefinition.php index f461bec..3e291cf 100644 --- a/src/Definition/DatatableDefinition.php +++ b/src/Definition/DatatableDefinition.php @@ -292,6 +292,9 @@ public function getGlobalActions(): array /** * @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, diff --git a/src/Renderer/DatatableRenderer.php b/src/Renderer/DatatableRenderer.php index 371f017..9b9a05b 100644 --- a/src/Renderer/DatatableRenderer.php +++ b/src/Renderer/DatatableRenderer.php @@ -50,14 +50,16 @@ public function render(DatatableDefinition $definition, array $options = []): st $options['paginationSize'] = $this->resolvePaginationSize($options)->value; $filters = $options['filters'] ?? []; + $bulkActions = $this->normalizeBulkActions($definition); + return $this->twig->render(sprintf('@ZhorteinDatatable/%s/datatable.html.twig', $this->theme), [ 'definition' => $definition, 'visibleColumns' => $this->getVisibleColumns($definition, $options), 'rowActions' => $definition->getRowActions(), 'globalActions' => $this->normalizeGlobalActions($definition), - 'bulkActions' => $this->normalizeBulkActions($definition), + 'bulkActions' => $bulkActions, 'hasRowActions' => [] !== $definition->getRowActions(), - 'hasBulkActions' => [] !== $definition->getBulkActions(), + 'hasBulkActions' => [] !== $bulkActions, 'htmlId' => $this->createHtmlId($definition), 'options' => $options, 'filters' => $filters, @@ -76,7 +78,7 @@ public function renderHeader(DatatableDefinition $definition, array $options = [ 'definition' => $definition, 'visibleColumns' => $this->getVisibleColumns($definition, $options), 'hasRowActions' => [] !== $definition->getRowActions(), - 'hasBulkActions' => [] !== $definition->getBulkActions(), + 'hasBulkActions' => $this->hasBulkActions($definition), 'htmlId' => $this->createHtmlId($definition), 'options' => $options, 'filters' => $options['filters'] ?? [], @@ -96,7 +98,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' => [] !== $definition->getBulkActions(), + 'hasBulkActions' => $this->hasBulkActions($definition), 'htmlId' => $this->createHtmlId($definition), 'rowActionDisplayMode' => $this->resolveRowActionDisplayMode($definition, $options)->value, ]); @@ -112,7 +114,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' => [] !== $definition->getBulkActions(), + 'hasBulkActions' => $this->hasBulkActions($definition), ]); } @@ -253,6 +255,10 @@ private function normalizeBulkActions(DatatableDefinition $definition): array $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()), @@ -262,6 +268,17 @@ private function normalizeBulkActions(DatatableDefinition $definition): array 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 */ @@ -278,7 +295,7 @@ private function normalizeStaticRouteParameters(ActionDefinition|BulkActionDefin private function normalizeRows(DatatableDefinition $definition, DatatableResult $result, array $options = []): array { $visibleColumns = $this->getVisibleColumns($definition, $options); - $hasBulkActions = [] !== $definition->getBulkActions(); + $hasBulkActions = $this->hasBulkActions($definition); $normalizedRows = []; foreach ($result->getRows() as $row) { @@ -367,7 +384,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; 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/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/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(); From 588e1d15c7ddfbe3242cc56a2069dfce0b47ff29 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 09:34:51 +0200 Subject: [PATCH 09/39] docs: Document bulk actions and row selection (#405) --- README.md | 1 + docs/actions.md | 18 ++++++- docs/bulk-actions.md | 124 +++++++++++++++++++++++++++++++++++++++++++ docs/index.md | 1 + docs/ui-ux.md | 2 + 5 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 docs/bulk-actions.md diff --git a/README.md b/README.md index 9d62b76..d36a79a 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ final class UserDatatable implements DatatableInterface - [Doctrine Provider](docs/doctrine-provider.md) - [Filters](docs/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) diff --git a/docs/actions.md b/docs/actions.md index 9771ecf..f3a982d 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 @@ -107,6 +120,7 @@ By default, this uses `window.confirm()`. If Bootstrap JavaScript and a modal ta ## Related documentation +- [Bulk Actions and Selection](bulk-actions.md) - [UI/UX customization](ui-ux.md) - [Theming](theming.md) - [Architecture](architecture/overview.md) 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/index.md b/docs/index.md index aa1a279..b8f67e2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,6 +15,7 @@ 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. - [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. - [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/ui-ux.md b/docs/ui-ux.md index 0fb1416..51a2497 100644 --- a/docs/ui-ux.md +++ b/docs/ui-ux.md @@ -8,6 +8,7 @@ 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. - **Customization**: Action icons, display modes (inline, dropdown), boolean rendering modes. @@ -95,5 +96,6 @@ The bundle follows a strong accessibility baseline: ## Related documentation - [Actions and Security](actions.md) +- [Bulk Actions and Selection](bulk-actions.md) - [Theming and Templates](theming.md) - [Architecture](architecture/overview.md) From 2c3b9ac3e427aec41c6c217550a0babbea509be6 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 09:42:03 +0200 Subject: [PATCH 10/39] docs: Update roadmap after implementing 0.24 Milestone (#406) --- docs/roadmap.md | 51 +++++++++++++++++++------------------------------ 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index a42c61b..147ac59 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -903,49 +903,38 @@ 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 🚧 +## 0.24 - Bulk actions and row selection ✅ -Goal: - -```text -Add first-class support for row selection and bulk actions in business datatables. -``` - -Planned: +Delivered: -- define the bulk action declaration API; -- add row selection column rendering; -- add selected row state management in Stimulus; -- support selecting/unselecting one row; -- support selecting/unselecting all visible rows; -- render bulk action toolbar or bottom action area; -- submit selected identifiers through CSRF-aware non-GET forms; -- support confirmation metadata for bulk actions; -- integrate action visibility/security for bulk actions; -- support current-page selected rows first; -- document limitations around filtered dataset / all matching rows; -- smoke test bulk actions in the fresh Symfony app. +- `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 expected outcome: +Main outcome: ```text Datatables can perform safe backend-defined actions on multiple selected rows, which is required for production back-office workflows. ``` -Out of scope for first implementation: +Current limitations: -- selecting all rows across all filtered pages; -- async bulk operations; -- queueing; -- progress UI; -- persisted selection across navigation; -- complex permission matrix; -- bulk edit forms; -- tree/hierarchical bulk actions. +- 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 🕒 +## 0.25 - Icon system and visual consistency 🚧 Goal: From fdab642c31dea10ec44a22915407a5b253d4b0b5 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 17 May 2026 10:04:11 +0200 Subject: [PATCH 11/39] chore: prepare icon system milestone --- ...e-icon-system-visual-consistency-issues.sh | 383 ++++++++++++++++++ tools/github/sync-labels.sh | 1 + 2 files changed, 384 insertions(+) create mode 100755 tools/github/create-icon-system-visual-consistency-issues.sh diff --git a/tools/github/create-icon-system-visual-consistency-issues.sh b/tools/github/create-icon-system-visual-consistency-issues.sh new file mode 100755 index 0000000..1ff8add --- /dev/null +++ b/tools/github/create-icon-system-visual-consistency-issues.sh @@ -0,0 +1,383 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI is required. Install it first: https://cli.github.com/" + exit 1 +fi + +gh auth status >/dev/null + +MILESTONE_TITLE="0.25 - Icon system and visual consistency" + +ensure_milestone() { + if gh api repos/:owner/:repo/milestones --jq ".[] | select(.title == \"${MILESTONE_TITLE}\") | .title" | grep -q "${MILESTONE_TITLE}"; then + echo "Milestone already exists: ${MILESTONE_TITLE}" + else + echo "Creating milestone: ${MILESTONE_TITLE}" + gh api repos/:owner/:repo/milestones \ + -f title="${MILESTONE_TITLE}" \ + -f description="Provide a consistent optional icon strategy across actions, booleans, sorting, filters, exports and bulk actions." + fi +} + +issue_exists() { + local title="$1" + + gh issue list \ + --state all \ + --search "$title in:title" \ + --json title \ + --jq ".[].title" \ + | grep -Fxq "$title" +} + +create_issue() { + local title="$1" + local labels="$2" + local body_file="$3" + + if issue_exists "$title"; then + echo "Issue already exists: $title" + return + fi + + local label_args=() + IFS=',' read -ra label_list <<< "$labels" + + for raw_label in "${label_list[@]}"; do + local label + label="$(printf "%s" "$raw_label" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + + if [ -n "$label" ]; then + label_args+=(--label "$label") + fi + done + + echo "Creating issue: $title" + + gh issue create \ + --title "$title" \ + --body-file "$body_file" \ + --milestone "$MILESTONE_TITLE" \ + "${label_args[@]}" +} + +make_body() { + local tmpfile + tmpfile="$(mktemp)" + cat > "$tmpfile" + echo "$tmpfile" +} + +ensure_milestone + +body="$(make_body <<'BODY' +## Objective + +Design the icon strategy for the bundle. + +## Context + +The bundle already supports action icons and textual indicators. After bulk actions, the UI needs a consistent icon system across row actions, global actions, bulk actions, booleans, sorting, filters and exports. + +## Scope + +- Define icon strategy principles. +- Decide whether icons remain CSS-class based for now. +- Define icon keys. +- Define configuration shape. +- Decide default icon classes. +- Decide accessibility rules. +- Keep icon libraries optional. +- Document public API decisions. + +## Out of scope + +- Mandatory icon package dependency. +- SVG icon provider. +- Symfony UX Icons hard dependency. +- Icon-only actions without accessibility design. + +## Acceptance criteria + +- [ ] Icon strategy decision is documented. +- [ ] Icon keys are listed. +- [ ] Configuration shape is proposed. +- [ ] Accessibility rules are explicit. +- [ ] No runtime code is changed unless needed for tests/docs. +- [ ] QA passes. +BODY +)" +create_issue "Design icon strategy and configuration model" "type: architecture,area: ui,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Add a central icon configuration model and resolver. + +## Scope + +- Add bundle configuration for icons if appropriate. +- Add `IconSet` / `IconRegistry` / `IconResolver` or similar lightweight service. +- Support default icon keys. +- Support host application overrides. +- Keep icon values as CSS classes for the first implementation. +- Add tests. + +## Expected icon keys + +Initial candidates: + +- `action_view` +- `action_edit` +- `action_delete` +- `action_create` +- `bulk_actions` +- `boolean_true` +- `boolean_false` +- `sort_neutral` +- `sort_asc` +- `sort_desc` +- `filter` +- `filter_active` +- `export` +- `export_csv` +- `export_xlsx` +- `confirmation_warning` + +## Out of scope + +- SVG rendering. +- Mandatory Bootstrap Icons/FontAwesome dependency. +- UX Icons integration. + +## Acceptance criteria + +- [ ] Icon resolver exists. +- [ ] Default icon keys exist. +- [ ] Config override works. +- [ ] Tests cover defaults and overrides. +- [ ] QA passes. +BODY +)" +create_issue "Implement icon configuration and resolver" "type: feature,area: configuration,area: ui,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Apply the icon resolver to row, global and bulk actions. + +## Scope + +- Preserve explicit action icons. +- Allow default icons by action name where possible. +- Apply icon position consistently. +- Support row actions. +- Support global actions. +- Support bulk actions. +- Keep accessible labels visible. +- Add renderer tests. + +## Out of scope + +- Icon-only actions. +- SVG provider. +- Changing action declaration API unless necessary. + +## Acceptance criteria + +- [ ] Explicit action icons still work. +- [ ] Default action icons can be resolved. +- [ ] Bulk action icons work. +- [ ] Accessibility remains correct. +- [ ] Tests cover row/global/bulk icon rendering. +- [ ] QA passes. +BODY +)" +create_issue "Apply icon resolver to actions and bulk actions" "type: feature,area: actions,area: twig,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Apply the icon strategy to boolean cell display modes. + +## Scope + +- Use icon resolver for boolean icon mode. +- Keep text/badge/switch modes unchanged. +- Support true/false icon keys. +- Keep visually hidden labels. +- Add tests. +- Update docs. + +## Out of scope + +- Editable boolean toggles. +- Icon-only table cells without accessible labels. + +## Acceptance criteria + +- [ ] Boolean icon mode uses configured icons. +- [ ] True/false icons can be overridden. +- [ ] Accessibility labels remain. +- [ ] Tests cover configured icons. +- [ ] QA passes. +BODY +)" +create_issue "Apply icon resolver to boolean cell icon mode" "type: feature,area: twig,area: ui,priority: medium,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Apply the icon strategy to sortable headers. + +## Scope + +- Replace hardcoded textual indicators with configured icon classes or keep text fallback. +- Support: + - neutral sort icon; + - ascending sort icon; + - descending sort icon. +- Preserve `aria-sort`. +- Preserve accessible labels. +- Add tests. +- Update docs. + +## Out of scope + +- Multi-column sorting. +- SVG icon rendering. + +## Acceptance criteria + +- [ ] Sort neutral/asc/desc icons are configurable. +- [ ] Fallback remains available. +- [ ] Active sort state remains correct. +- [ ] Tests cover default and override behavior. +- [ ] QA passes. +BODY +)" +create_issue "Apply icon resolver to sortable headers" "type: feature,area: twig,area: ui,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Apply icon strategy to filters and export controls. + +## Scope + +- Header filter button icon. +- Active filter icon if applicable. +- Export dropdown button icon. +- CSV export icon. +- XLSX export icon. +- Keep text labels visible. +- Add renderer tests. +- Update docs. + +## Out of scope + +- Icon-only dropdown items. +- SVG provider. + +## Acceptance criteria + +- [ ] Header filter icon is configurable. +- [ ] Export icons are configurable. +- [ ] CSV/XLSX icons are configurable. +- [ ] Text labels remain visible. +- [ ] Tests cover rendering. +- [ ] QA passes. +BODY +)" +create_issue "Apply icon resolver to filters and exports" "type: feature,area: twig,area: export,area: ui,priority: medium,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Document icon configuration and visual consistency. + +## Scope + +- Add or update icon documentation. +- Explain default icon keys. +- Explain CSS-class based strategy. +- Explain Bootstrap Icons example. +- Explain FontAwesome example. +- Explain per-action explicit icons. +- Explain accessibility rules. +- Link from UI/UX docs and README/index if needed. + +## Out of scope + +- Documenting unimplemented SVG provider. +- Recommending one mandatory icon library. + +## Acceptance criteria + +- [ ] Icon documentation is clear. +- [ ] Default keys are documented. +- [ ] Override examples exist. +- [ ] Accessibility limitations are explicit. +- [ ] QA passes. +BODY +)" +create_issue "Document icon system and visual consistency" "type: docs,area: ui,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Smoke test the icon system in the fresh Symfony application. + +## Scope + +- Configure Bootstrap Icons or FontAwesome in the smoke app. +- Test row/global/bulk action icons. +- Test boolean icon mode. +- Test sort icons. +- Test filter icons. +- Test CSV/XLSX export icons. +- Record findings. + +## Acceptance criteria + +- [ ] Smoke test validates configured icon set. +- [ ] No missing icon classes in expected UI. +- [ ] Text labels and accessibility remain acceptable. +- [ ] Findings are recorded. +BODY +)" +create_issue "Smoke test icon system" "type: tests,area: ui,priority: medium,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Update roadmap after icon system milestone. + +## Scope + +- Mark 0.25 as completed. +- Clarify delivered icon system capabilities. +- Keep limitations explicit. +- Set next milestone to advanced filter expressions unless priorities change. + +## Acceptance criteria + +- [ ] Roadmap updated. +- [ ] QA passes. +BODY +)" +create_issue "Update roadmap after icon system" "type: docs,priority: medium,ai-ready" "$body" +rm -f "$body" + +echo "Icon system milestone issues created successfully." diff --git a/tools/github/sync-labels.sh b/tools/github/sync-labels.sh index 5c6f5a8..fff0bea 100755 --- a/tools/github/sync-labels.sh +++ b/tools/github/sync-labels.sh @@ -48,6 +48,7 @@ sync_label "type: refactor" "c2e0c6" "Refactoring without behavior change" sync_label "type: legacy-analysis" "bfd4f2" "Analysis of legacy NC Manager implementation" # Areas +sync_label "area: ui" "006b75" "UI" sync_label "area: doctrine" "006b75" "Doctrine ORM provider, metadata or query logic" sync_label "area: twig" "006b75" "Twig rendering and templates" sync_label "area: stimulus" "006b75" "Stimulus controller and frontend behavior" From ec001258eb0512071b99d7caabfb7c4ba10a4885 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 10:45:48 +0200 Subject: [PATCH 12/39] feat: Design icon strategy and configuration model (#416) --- docs/architecture.md | 1 + ...8-icon-strategy-and-configuration-model.md | 112 ++++++++++++++++++ docs/decisions/index.md | 1 + docs/roadmap.md | 4 + 4 files changed, 118 insertions(+) create mode 100644 docs/decisions/0008-icon-strategy-and-configuration-model.md 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/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/index.md b/docs/decisions/index.md index d340365..2f11f64 100644 --- a/docs/decisions/index.md +++ b/docs/decisions/index.md @@ -9,3 +9,4 @@ 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) diff --git a/docs/roadmap.md b/docs/roadmap.md index 147ac59..c3da4f6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -942,6 +942,10 @@ Goal: Provide a consistent, configurable icon strategy across actions, booleans, sorting, filters and exports. ``` +Delivered: + +- icon strategy and configuration model decision. + Planned: - define a lightweight icon strategy; From 7f372bf2ed33223968c1529d20ef88f30cf49b62 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 12:52:32 +0200 Subject: [PATCH 13/39] feat: Implement icon configuration and resolver (#417) --- config/services.php | 9 ++++ docs/configuration.md | 39 +++++++++++++++ src/Contract/IconResolverInterface.php | 10 ++++ src/Icon/IconResolver.php | 38 +++++++++++++++ src/ZhorteinDatatableBundle.php | 5 ++ .../Functional/IconResolverFunctionalTest.php | 44 +++++++++++++++++ tests/Functional/Kernel/TestKernel.php | 12 +++++ .../BundleConfigurationTest.php | 16 +++++++ tests/Unit/Icon/IconResolverTest.php | 48 +++++++++++++++++++ 9 files changed, 221 insertions(+) create mode 100644 src/Contract/IconResolverInterface.php create mode 100644 src/Icon/IconResolver.php create mode 100644 tests/Functional/IconResolverFunctionalTest.php create mode 100644 tests/Unit/Icon/IconResolverTest.php diff --git a/config/services.php b/config/services.php index 4152f6e..51d0bfd 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; @@ -65,6 +67,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); diff --git a/docs/configuration.md b/docs/configuration.md index dba92d0..3ae9ecc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,10 @@ 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" export: csv: delimiter: ';' @@ -200,6 +204,41 @@ A runtime option can still override it: }) }} ``` +## `icons` + +Type: `array` + +Default: + +```yaml +icons: + view: "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: "bi bi-arrow-down-up" + sort_asc: "bi bi-arrow-up" + sort_desc: "bi bi-arrow-down" + filter: "bi bi-funnel" + export_csv: "bi bi-filetype-csv" + export_excel: "bi bi-filetype-xlsx" +``` + +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. + +**Note**: The host application is responsible for loading the icon library (e.g., Bootstrap Icons) if it wants these classes to render visually. + +You can override specific icons in your configuration: + +```yaml +zhortein_datatable: + icons: + view: "fas fa-eye" + edit: "fas fa-edit" +``` + ## Default export values ```yaml zhortein_datatable: 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 @@ + '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' => 'bi bi-arrow-down-up', + 'sort_asc' => 'bi bi-arrow-up', + 'sort_desc' => 'bi bi-arrow-down', + 'filter' => 'bi bi-funnel', + 'export_csv' => 'bi bi-filetype-csv', + 'export_excel' => 'bi bi-filetype-xlsx', + ]; + + /** + * @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/ZhorteinDatatableBundle.php b/src/ZhorteinDatatableBundle.php index b700906..eaa74a9 100644 --- a/src/ZhorteinDatatableBundle.php +++ b/src/ZhorteinDatatableBundle.php @@ -65,6 +65,7 @@ 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.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 +135,10 @@ private function configureRootNode(DefinitionConfigurator $definition): void ->booleanNode('search_enabled') ->defaultFalse() ->end() + ->arrayNode('icons') + ->useAttributeAsKey('name') + ->scalarPrototype()->end() + ->end() ->arrayNode('bootstrap') ->addDefaultsIfNotSet() ->children() 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/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/Icon/IconResolverTest.php b/tests/Unit/Icon/IconResolverTest.php new file mode 100644 index 0000000..2264b45 --- /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')); + 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')); + } +} From 749d69aee061ddb5be63933e2b7a411899d25034 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 13:04:26 +0200 Subject: [PATCH 14/39] feat: Apply icon resolver to actions and bulk actions (#418) --- docs/actions.md | 2 +- docs/configuration.md | 6 + docs/ui-ux.md | 8 +- src/Icon/IconResolver.php | 5 + src/Renderer/DatatableRenderer.php | 35 ++++- .../DatatableRendererIconResolutionTest.php | 144 ++++++++++++++++++ 6 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Renderer/DatatableRendererIconResolutionTest.php diff --git a/docs/actions.md b/docs/actions.md index f3a982d..d86d8fd 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -114,7 +114,7 @@ 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 [UI/UX](ui-ux.md) for details. - **Position**: Use `ActionIconPosition` enum to place icons `Before` or `After` the label. - **Attributes**: Pass arbitrary HTML attributes via the `attributes` array. diff --git a/docs/configuration.md b/docs/configuration.md index 3ae9ecc..83845c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,6 +21,7 @@ zhortein_datatable: view: "bi bi-eye" edit: "bi bi-pencil" delete: "bi bi-trash" + bulk_actions: "bi bi-collection" export: csv: delimiter: ';' @@ -224,6 +225,11 @@ icons: filter: "bi bi-funnel" export_csv: "bi bi-filetype-csv" 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" ``` 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. diff --git a/docs/ui-ux.md b/docs/ui-ux.md index 51a2497..2cecb2d 100644 --- a/docs/ui-ux.md +++ b/docs/ui-ux.md @@ -46,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', // ... ); diff --git a/src/Icon/IconResolver.php b/src/Icon/IconResolver.php index feee698..9038d19 100644 --- a/src/Icon/IconResolver.php +++ b/src/Icon/IconResolver.php @@ -21,6 +21,11 @@ 'filter' => 'bi bi-funnel', 'export_csv' => 'bi bi-filetype-csv', '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', ]; /** diff --git a/src/Renderer/DatatableRenderer.php b/src/Renderer/DatatableRenderer.php index 9b9a05b..aeb2b46 100644 --- a/src/Renderer/DatatableRenderer.php +++ b/src/Renderer/DatatableRenderer.php @@ -10,6 +10,7 @@ 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; @@ -28,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, @@ -409,7 +411,7 @@ private function normalizeAction(ActionDefinition|BulkActionDefinition $action, return [ 'name' => $action->getName(), 'label' => $action->getLabel(), - 'icon' => $action->getIcon(), + 'icon' => $this->resolveActionIcon($action), 'iconPosition' => $action->getIconPosition()->value, 'url' => $url, 'httpMethod' => $httpMethod, @@ -421,6 +423,37 @@ private function normalizeAction(ActionDefinition|BulkActionDefinition $action, ]; } + 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 */ diff --git a/tests/Unit/Renderer/DatatableRendererIconResolutionTest.php b/tests/Unit/Renderer/DatatableRendererIconResolutionTest.php new file mode 100644 index 0000000..98d11b9 --- /dev/null +++ b/tests/Unit/Renderer/DatatableRendererIconResolutionTest.php @@ -0,0 +1,144 @@ +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); + } + + private function createRenderer(): DatatableRenderer + { + return new DatatableRenderer( + twig: $this->createTwigEnvironment(), + 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; + } +} From b673a7f6f10d9d875061b4f0ef3620831d7e5995 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 13:12:24 +0200 Subject: [PATCH 15/39] feat: Implement icon resolver on boolean cells (#419) --- docs/configuration.md | 4 +- src/Icon/IconResolver.php | 2 + src/Renderer/DatatableRenderer.php | 7 ++- templates/bootstrap/_cell.html.twig | 4 +- templates/bootstrap/_row.html.twig | 4 +- templates/bootstrap/cell/boolean.html.twig | 12 ++++- ...atatableRendererBooleanDisplayModeTest.php | 47 +++++++++++++++++-- 7 files changed, 70 insertions(+), 10 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 83845c1..ed0ae59 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -230,6 +230,8 @@ icons: 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" ``` 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. @@ -286,7 +288,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/src/Icon/IconResolver.php b/src/Icon/IconResolver.php index 9038d19..6f65838 100644 --- a/src/Icon/IconResolver.php +++ b/src/Icon/IconResolver.php @@ -26,6 +26,8 @@ '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', ]; /** diff --git a/src/Renderer/DatatableRenderer.php b/src/Renderer/DatatableRenderer.php index aeb2b46..127828d 100644 --- a/src/Renderer/DatatableRenderer.php +++ b/src/Renderer/DatatableRenderer.php @@ -298,6 +298,9 @@ private function normalizeRows(DatatableDefinition $definition, DatatableResult { $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) { @@ -309,7 +312,9 @@ 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, ]; } 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/_row.html.twig b/templates/bootstrap/_row.html.twig index d3e5252..a0bff13 100644 --- a/templates/bootstrap/_row.html.twig +++ b/templates/bootstrap/_row.html.twig @@ -19,7 +19,9 @@ 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/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/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: [ From 47d34f8752a768f6208032f0fc8e8491f424b5e5 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 13:28:17 +0200 Subject: [PATCH 16/39] feat: Apply icon resolver to sortable headers (#420) --- docs/configuration.md | 2 +- src/Icon/IconResolver.php | 2 +- src/Renderer/DatatableRenderer.php | 20 +++- templates/bootstrap/_header.html.twig | 6 +- tests/Unit/Icon/IconResolverTest.php | 2 +- .../DatatableRendererHeaderFragmentTest.php | 20 ++++ .../DatatableRendererSortIconsTest.php | 93 +++++++++++++++++++ 7 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/Renderer/DatatableRendererSortIconsTest.php diff --git a/docs/configuration.md b/docs/configuration.md index ed0ae59..39e3ae0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -219,7 +219,7 @@ icons: add: "bi bi-plus-lg" check: "bi bi-check-lg" cancel: "bi bi-x-lg" - sort: "bi bi-arrow-down-up" + sort_neutral: "bi bi-arrow-down-up" sort_asc: "bi bi-arrow-up" sort_desc: "bi bi-arrow-down" filter: "bi bi-funnel" diff --git a/src/Icon/IconResolver.php b/src/Icon/IconResolver.php index 6f65838..0463eb8 100644 --- a/src/Icon/IconResolver.php +++ b/src/Icon/IconResolver.php @@ -15,7 +15,7 @@ 'add' => 'bi bi-plus-lg', 'check' => 'bi bi-check-lg', 'cancel' => 'bi bi-x-lg', - 'sort' => 'bi bi-arrow-down-up', + 'sort_neutral' => 'bi bi-arrow-down-up', 'sort_asc' => 'bi bi-arrow-up', 'sort_desc' => 'bi bi-arrow-down', 'filter' => 'bi bi-funnel', diff --git a/src/Renderer/DatatableRenderer.php b/src/Renderer/DatatableRenderer.php index 127828d..e29a34d 100644 --- a/src/Renderer/DatatableRenderer.php +++ b/src/Renderer/DatatableRenderer.php @@ -54,7 +54,7 @@ public function render(DatatableDefinition $definition, array $options = []): st $bulkActions = $this->normalizeBulkActions($definition); - return $this->twig->render(sprintf('@ZhorteinDatatable/%s/datatable.html.twig', $this->theme), [ + return $this->twig->render(sprintf('@ZhorteinDatatable/%s/datatable.html.twig', $this->theme), array_merge([ 'definition' => $definition, 'visibleColumns' => $this->getVisibleColumns($definition, $options), 'rowActions' => $definition->getRowActions(), @@ -66,7 +66,7 @@ public function render(DatatableDefinition $definition, array $options = []): st 'options' => $options, 'filters' => $filters, 'rowActionDisplayMode' => $this->resolveRowActionDisplayMode($definition, $options)->value, - ]); + ], $this->resolveSortIcons())); } /** @@ -76,7 +76,7 @@ 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(), @@ -84,7 +84,7 @@ public function renderHeader(DatatableDefinition $definition, array $options = [ 'htmlId' => $this->createHtmlId($definition), 'options' => $options, 'filters' => $options['filters'] ?? [], - ]); + ], $this->resolveSortIcons())); } /** @@ -590,4 +590,16 @@ 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} + */ + private function resolveSortIcons(): array + { + return [ + 'sort_neutral' => $this->iconResolver?->resolve('sort_neutral'), + 'sort_asc' => $this->iconResolver?->resolve('sort_asc'), + 'sort_desc' => $this->iconResolver?->resolve('sort_desc'), + ]; + } } diff --git a/templates/bootstrap/_header.html.twig b/templates/bootstrap/_header.html.twig index 5e1a99d..1384b0f 100644 --- a/templates/bootstrap/_header.html.twig +++ b/templates/bootstrap/_header.html.twig @@ -59,11 +59,11 @@ diff --git a/tests/Unit/Icon/IconResolverTest.php b/tests/Unit/Icon/IconResolverTest.php index 2264b45..c427178 100644 --- a/tests/Unit/Icon/IconResolverTest.php +++ b/tests/Unit/Icon/IconResolverTest.php @@ -19,7 +19,7 @@ public function test_resolve_default_icons(): void 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')); + 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')); 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/DatatableRendererSortIconsTest.php b/tests/Unit/Renderer/DatatableRendererSortIconsTest.php new file mode 100644 index 0000000..fdfef21 --- /dev/null +++ b/tests/Unit/Renderer/DatatableRendererSortIconsTest.php @@ -0,0 +1,93 @@ +createTwigEnvironment()); + + $html = $renderer->render($this->createDefinition(), [ + 'sortField' => 'e.email', + 'sortDirection' => 'asc', + ]); + + self::assertStringContainsString('↑', $html); + self::assertStringContainsString('↕', $html); // For the non-sorted column + } + + public function test_it_renders_default_icons_when_default_resolver_is_provided(): void + { + $renderer = new DatatableRenderer( + $this->createTwigEnvironment(), + new IconResolver(), + ); + + $html = $renderer->render($this->createDefinition(), [ + 'sortField' => 'e.email', + 'sortDirection' => 'asc', + ]); + + self::assertStringContainsString('', $html); + self::assertStringContainsString('', $html); + } + + public function test_it_renders_overridden_icons(): void + { + $renderer = new DatatableRenderer( + $this->createTwigEnvironment(), + new IconResolver([ + 'sort_neutral' => 'fa fa-sort', + 'sort_asc' => 'fa fa-sort-up', + 'sort_desc' => 'fa fa-sort-down', + ]), + ); + + $html = $renderer->render($this->createDefinition(), [ + 'sortField' => 'e.email', + 'sortDirection' => 'desc', + ]); + + self::assertStringContainsString('', $html); + self::assertStringContainsString('', $html); + } + + private function createDefinition(): DatatableDefinition + { + $definition = new DatatableDefinition('users'); + + $definition + ->addColumn('e.email', label: 'Email') + ->addColumn('e.username', label: 'Username') + ; + + 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; + } +} From 45edf51d2ce7561a189824a576e2116bcfe9c5fb Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 13:39:05 +0200 Subject: [PATCH 17/39] feat: Apply icon resolver to filters and exports (#421) --- docs/configuration.md | 3 + docs/theming.md | 2 +- docs/ui-ux.md | 2 +- src/Icon/IconResolver.php | 3 + src/Renderer/DatatableRenderer.php | 13 +++- templates/bootstrap/_column_filter.html.twig | 8 +- templates/bootstrap/_export.html.twig | 4 + templates/bootstrap/_header.html.twig | 2 +- templates/bootstrap/_toolbar.html.twig | 6 +- templates/bootstrap/datatable.html.twig | 4 +- .../DatatableRendererIconResolutionTest.php | 75 ++++++++++++++++++- 11 files changed, 107 insertions(+), 15 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 39e3ae0..f4a86a3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -223,7 +223,10 @@ icons: 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" diff --git a/docs/theming.md b/docs/theming.md index c48bf1a..42da945 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 diff --git a/docs/ui-ux.md b/docs/ui-ux.md index 2cecb2d..89ba52a 100644 --- a/docs/ui-ux.md +++ b/docs/ui-ux.md @@ -11,12 +11,12 @@ Currently implemented: - **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. diff --git a/src/Icon/IconResolver.php b/src/Icon/IconResolver.php index 0463eb8..896a041 100644 --- a/src/Icon/IconResolver.php +++ b/src/Icon/IconResolver.php @@ -19,7 +19,10 @@ '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', diff --git a/src/Renderer/DatatableRenderer.php b/src/Renderer/DatatableRenderer.php index e29a34d..3f906fc 100644 --- a/src/Renderer/DatatableRenderer.php +++ b/src/Renderer/DatatableRenderer.php @@ -66,7 +66,7 @@ public function render(DatatableDefinition $definition, array $options = []): st 'options' => $options, 'filters' => $filters, 'rowActionDisplayMode' => $this->resolveRowActionDisplayMode($definition, $options)->value, - ], $this->resolveSortIcons())); + ], $this->resolveCommonIcons())); } /** @@ -84,7 +84,7 @@ public function renderHeader(DatatableDefinition $definition, array $options = [ 'htmlId' => $this->createHtmlId($definition), 'options' => $options, 'filters' => $options['filters'] ?? [], - ], $this->resolveSortIcons())); + ], $this->resolveCommonIcons())); } /** @@ -592,14 +592,19 @@ private function resolvePaginationSize(array $options): PaginationSize } /** - * @return array{sort_neutral: string|null, sort_asc: string|null, sort_desc: string|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 resolveSortIcons(): array + 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'), ]; } } 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/_toolbar.html.twig b/templates/bootstrap/_toolbar.html.twig index 755c5bb..9331bad 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,14 @@ 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 not is_split_layout and page_size_selector_enabled %} diff --git a/templates/bootstrap/datatable.html.twig b/templates/bootstrap/datatable.html.twig index c840bd5..3824b55 100644 --- a/templates/bootstrap/datatable.html.twig +++ b/templates/bootstrap/datatable.html.twig @@ -79,11 +79,11 @@ options: options, controlsLayout: controls_layout, filters: filters|default({}) - } only %} + } %} {% include '@ZhorteinDatatable/bootstrap/_bulk_actions.html.twig' with { bulkActions: bulkActions|default([]) - } only %} + } %}
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: new IconResolver(), + iconResolver: $iconResolver ?? new IconResolver(), urlGenerator: $this->createUrlGeneratorStub(), routeParameterResolver: new RowActionRouteParameterResolver(), ); From afc358d1c060093a019150b6d9875747402edcf0 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 13:48:38 +0200 Subject: [PATCH 18/39] docs: Document icon system and visual consistency (#422) --- README.md | 3 +- docs/actions.md | 3 +- docs/configuration.md | 36 ++----------- docs/icons.md | 121 ++++++++++++++++++++++++++++++++++++++++++ docs/index.md | 1 + docs/theming.md | 1 + docs/ui-ux.md | 1 + 7 files changed, 132 insertions(+), 34 deletions(-) create mode 100644 docs/icons.md diff --git a/README.md b/README.md index d36a79a..c25870f 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,9 @@ final class UserDatatable implements DatatableInterface - [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/docs/actions.md b/docs/actions.md index d86d8fd..bec2696 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -114,12 +114,13 @@ By default, this uses `window.confirm()`. If Bootstrap JavaScript and a modal ta ## Customization -- **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 [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) diff --git a/docs/configuration.md b/docs/configuration.md index f4a86a3..65b6ed8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -209,45 +209,17 @@ A runtime option can still override it: Type: `array` -Default: - -```yaml -icons: - view: "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" -``` - 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. -**Note**: The host application is responsible for loading the icon library (e.g., Bootstrap Icons) if it wants these classes to render visually. +See [Icon System documentation](icons.md) for the full list of available keys and detailed strategy. -You can override specific icons in your configuration: +Example: ```yaml zhortein_datatable: icons: - view: "fas fa-eye" - edit: "fas fa-edit" + action_view: "fas fa-eye" + action_edit: "fas fa-edit" ``` ## Default export values diff --git a/docs/icons.md b/docs/icons.md new file mode 100644 index 0000000..d5b6291 --- /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: + +```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 b8f67e2..04c7682 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,7 @@ This bundle is a Symfony 8+ datatable bundle for Bootstrap-first business tables - [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/theming.md b/docs/theming.md index 42da945..7a72fd0 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -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 89ba52a..65a7cc2 100644 --- a/docs/ui-ux.md +++ b/docs/ui-ux.md @@ -100,6 +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) From d44b722667d1e2d01c042dfaa477becf64d7e4c4 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 13:58:48 +0200 Subject: [PATCH 19/39] docs: Update roadmap after implementing Milestone 0.25 (#423) --- docs/roadmap.md | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index c3da4f6..31fada1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -934,7 +934,7 @@ Current limitations: --- -## 0.25 - Icon system and visual consistency 🚧 +## 0.25 - Icon system and visual consistency ✅ Goal: @@ -944,26 +944,23 @@ Provide a consistent, configurable icon strategy across actions, booleans, sorti Delivered: -- icon strategy and configuration model decision. +- **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. -Planned: +Current limitations: -- define a lightweight icon strategy; -- keep CSS-class based icons supported; -- document Bootstrap Icons and FontAwesome usage; -- provide default icon names/classes; -- support icons for: - - row actions; - - global actions; - - bulk actions; - - booleans; - - sort indicators; - - filters; - - exports; -- keep icon libraries optional; -- avoid hard dependency on a specific icon set. +- no mandatory icon library; +- no SVG provider; +- no UX Icons hard integration; +- no icon-only actions unless implemented. -Main expected outcome: +Main outcome: ```text Generated datatables have a coherent visual language while allowing host applications to choose their icon system. @@ -971,7 +968,7 @@ Generated datatables have a coherent visual language while allowing host applica --- -## 0.26 - Advanced filter expressions 🕒 +## 0.26 - Advanced filter expressions 🚧 Goal: From eb2afae863a1525aaf5d763a9b704cf717c2b30b Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 17 May 2026 15:00:29 +0200 Subject: [PATCH 20/39] chore: create Milestone 0.26 issues --- ...eate-advanced-filter-expressions-issues.sh | 589 ++++++++++++++++++ tools/github/sync-labels.sh | 6 +- 2 files changed, 594 insertions(+), 1 deletion(-) create mode 100755 tools/github/create-advanced-filter-expressions-issues.sh diff --git a/tools/github/create-advanced-filter-expressions-issues.sh b/tools/github/create-advanced-filter-expressions-issues.sh new file mode 100755 index 0000000..08fdd90 --- /dev/null +++ b/tools/github/create-advanced-filter-expressions-issues.sh @@ -0,0 +1,589 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI is required. Install it first: https://cli.github.com/" + exit 1 +fi + +gh auth status >/dev/null + +MILESTONE_TITLE="0.26 - Advanced filter expressions" + +ensure_milestone() { + if gh api repos/:owner/:repo/milestones --jq ".[] | select(.title == \"${MILESTONE_TITLE}\") | .title" | grep -q "${MILESTONE_TITLE}"; then + echo "Milestone already exists: ${MILESTONE_TITLE}" + else + echo "Creating milestone: ${MILESTONE_TITLE}" + gh api repos/:owner/:repo/milestones \ + -f title="${MILESTONE_TITLE}" \ + -f description="Add safe advanced filter expressions / search builder: backend model, Bootstrap UI, Stimulus state, Array/Doctrine provider support, export compatibility and documentation." + fi +} + +issue_exists() { + local title="$1" + + gh issue list \ + --state all \ + --search "$title in:title" \ + --json title \ + --jq ".[].title" \ + | grep -Fxq "$title" +} + +create_issue() { + local title="$1" + local labels="$2" + local body="$3" + + if issue_exists "$title"; then + echo "Issue already exists: $title" + return + fi + + local tmpfile + tmpfile="$(mktemp)" + printf "%s\n" "$body" > "$tmpfile" + + local label_args=() + IFS=',' read -ra label_list <<< "$labels" + + for raw_label in "${label_list[@]}"; do + local label + label="$(printf "%s" "$raw_label" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + + if [ -n "$label" ]; then + label_args+=(--label "$label") + fi + done + + echo "Creating issue: $title" + + gh issue create \ + --title "$title" \ + --body-file "$tmpfile" \ + --milestone "$MILESTONE_TITLE" \ + "${label_args[@]}" + + rm -f "$tmpfile" +} + +ensure_milestone + +create_issue "Design advanced filter expression model" "type: architecture,area: filters,priority: high,ai-ready" \ +"## Objective + +Design the backend-controlled advanced filter expression model before implementation. + +## Context + +The previous DataTables.net implementation had a SearchBuilder concept: +- fields could opt in/out of the search builder; +- JS column definitions exposed a search-builder type; +- backend code parsed a searchBuilder payload and translated it to Doctrine conditions. + +The new bundle must provide the same business capability without DataTables.net and without exposing Doctrine QueryBuilder, DQL, SQL or arbitrary expressions to the frontend. + +## Scope + +- Define public terminology: + - advanced filter expression; + - condition; + - group; + - logic operator; + - comparison operator. +- Define the first payload shape. +- Define supported field types: + - string/text; + - number; + - boolean; + - date/datetime; + - choice. +- Define first-version operators: + - equals; + - not equals; + - contains; + - not contains; + - starts with; + - ends with; + - greater than; + - greater than or equals; + - less than; + - less than or equals; + - between; + - is null; + - is not null; + - in; + - not in. +- Define logic: + - AND; + - OR. +- Define nesting policy: + - one root group; + - nested groups only if safe; + - max depth. +- Define security boundaries: + - no arbitrary DQL; + - no arbitrary SQL; + - no frontend-provided field paths outside declared filterable fields; + - no frontend-provided join expressions; + - parameters must always be bound. +- Define provider mapping expectations: + - Array provider; + - Doctrine provider. +- Define out-of-scope items: + - saved filters; + - persisted user presets; + - async filtering; + - custom widgets; + - collection-valued association filtering; + - arbitrary expression language. + +## Acceptance criteria + +- [ ] Decision document exists under docs/decisions. +- [ ] Payload shape is documented. +- [ ] Supported operators are documented. +- [ ] Security boundaries are explicit. +- [ ] Public wording avoids exposing Doctrine internals. +- [ ] No runtime code is implemented unless strictly necessary. +- [ ] QA passes." + +create_issue "Implement advanced filter expression value objects" "type: feature,area: filters,priority: high,ai-ready" \ +"## Objective + +Implement the backend model for advanced filter expressions. + +## Scope + +- Add enums/value objects for: + - logic operator; + - comparison operator; + - condition; + - group; + - expression. +- Use a namespace consistent with the bundle, for example: + - Zhortein\\DatatableBundle\\Filter\\Expression +- Prefer immutable PHP objects where practical. +- Supported operators must match the decision document. +- Add validation for: + - empty field; + - empty operator; + - invalid operator; + - malformed group; + - unsupported value shape. +- Add max-depth handling if defined in the decision. +- Add unit tests. + +## Out of scope + +- HTTP request parsing. +- Twig/UI rendering. +- Provider application. +- Export behavior. + +## Acceptance criteria + +- [ ] Expression value objects exist. +- [ ] Logic operator enum exists. +- [ ] Comparison operator enum exists. +- [ ] Invalid expressions fail clearly. +- [ ] Unit tests cover valid and invalid expressions. +- [ ] Existing filters still work. +- [ ] Frontend tests pass. +- [ ] QA passes." + +create_issue "Declare advanced-filterable fields" "type: feature,area: definition,area: filters,priority: high,ai-ready" \ +"## Objective + +Allow datatable definitions to declare which fields are available in the advanced filter builder. + +## Context + +The previous implementation had an inSearchBuilder flag on fields. The new bundle needs an equivalent concept in its own definition model, not tied to DataTables.net. + +## Scope + +- Add advanced-filter metadata to the current bundle definition model. +- Decide whether this belongs on: + - column definitions; + - user filter definitions; + - a dedicated advanced filter field definition. +- Prefer backend-explicit configuration. +- Include metadata: + - field name; + - label; + - type; + - allowed operators; + - optional choices for choice fields. +- Provide sensible defaults only if consistent with current design. +- Add methods on DatatableDefinition if needed: + - addAdvancedFilterField(...); + - getAdvancedFilterFields(). +- Add unit tests. + +## Out of scope + +- UI rendering. +- Request parsing. +- Provider application. +- Frontend-only fields. + +## Acceptance criteria + +- [ ] Advanced-filterable fields can be declared. +- [ ] Allowed operators can be controlled. +- [ ] Field labels/types are available for rendering. +- [ ] Unit tests cover declarations and defaults. +- [ ] Existing filters and columns still work. +- [ ] QA passes." + +create_issue "Normalize advanced filter expression request payload" "type: feature,area: request,area: filters,priority: high,ai-ready" \ +"## Objective + +Parse and normalize advanced filter expression payloads into the backend expression model. + +## Scope + +- Choose the request key, for example: + - advancedFilters; + - filterExpression. +- Parse payload from Ajax requests. +- Normalize it into expression value objects. +- Validate: + - field exists in allowed advanced filter fields; + - operator is allowed for that field; + - value shape matches operator/type; + - group logic is valid; + - nesting depth is valid. +- Fail safely: + - malformed advanced filters must not create unsafe queries; + - define whether invalid payloads are ignored or returned as request errors. +- Add tests around DatatableRequest / request factory according to the current architecture. + +## Important constraints + +- Existing toolbar/header filters must keep working. +- Simple search must keep working. +- Sorting/pagination/export state must keep working. +- Do not apply provider logic yet. + +## Acceptance criteria + +- [ ] Valid payload is normalized. +- [ ] Invalid payload is rejected or ignored safely according to documented behavior. +- [ ] Request object exposes advanced filter expression. +- [ ] Existing request behavior is preserved. +- [ ] Tests pass. +- [ ] QA passes." + +create_issue "Render advanced filter builder UI" "type: feature,area: twig,area: bootstrap,area: filters,priority: high,ai-ready" \ +"## Objective + +Render an optional Bootstrap-first advanced filter builder UI. + +## Scope + +- Add an option to enable advanced filters/search builder, for example: + - advancedFilters: true; + - or searchBuilder: true. +- Render a Bootstrap panel/dropdown/offcanvas-friendly block. +- Render: + - field selector; + - operator selector; + - value input; + - add condition button; + - remove condition button; + - clear advanced filters button; + - logic selector AND/OR. +- Use backend-declared advanced filter fields only. +- Render operators based on field type. +- Keep simple toolbar/header filters unchanged. +- Use translations. +- Add renderer tests. + +## Out of scope + +- Third-party JS widgets. +- jQuery. +- Select2. +- Datepicker dependency. +- Nested group drag/drop. +- Full SearchBuilder parity. + +## Acceptance criteria + +- [ ] Advanced filter UI is opt-in. +- [ ] Available fields are backend-defined. +- [ ] Operators are type-aware. +- [ ] Simple filters remain unchanged. +- [ ] Renderer tests cover output. +- [ ] QA passes." + +create_issue "Implement Stimulus advanced filter builder state" "type: feature,area: stimulus,area: filters,priority: high,ai-ready" \ +"## Objective + +Manage advanced filter builder state in the existing vanilla Stimulus controller. + +## Scope + +- Add/remove conditions. +- Change field. +- Update operator choices when field changes. +- Change operator. +- Manage value input. +- Serialize expression payload into Ajax requests. +- Refresh table when applying advanced filters. +- Clear advanced filters. +- Keep simple filters/header filters working. +- Add frontend tests. + +## Important constraints + +- JavaScript must remain vanilla. +- Do not add jQuery. +- Do not add third-party widgets. +- Do not break pagination/sorting/export URL generation. +- Do not persist filters yet. + +## Acceptance criteria + +- [ ] Conditions can be added/removed. +- [ ] Expression payload is serialized. +- [ ] Ajax refresh includes advanced filter payload. +- [ ] Clear advanced filters works. +- [ ] Existing filters still serialize. +- [ ] Frontend tests cover core interactions. +- [ ] Frontend tests pass. +- [ ] QA passes." + +create_issue "Apply advanced filter expressions in Array provider" "type: feature,area: provider,area: filters,priority: high,ai-ready" \ +"## Objective + +Evaluate advanced filter expressions against in-memory rows in the Array provider. + +## Scope + +- Add an expression evaluator for array rows. +- Support: + - AND; + - OR; + - equals; + - not equals; + - contains; + - not contains; + - starts with; + - ends with; + - greater/less comparisons; + - between; + - is null; + - is not null; + - in; + - not in. +- Implement type-aware behavior for: + - strings; + - numbers; + - booleans; + - dates if currently supported in the Array provider. +- Keep existing simple filters/search/sort/pagination working. +- Add unit/functional tests. + +## Out of scope + +- Doctrine provider support. +- Custom expression callbacks unless explicitly designed. +- Saved presets. + +## Acceptance criteria + +- [ ] Array provider applies expression filters. +- [ ] AND works. +- [ ] OR works. +- [ ] Type-aware operators work. +- [ ] Existing filters still work. +- [ ] Tests cover combinations. +- [ ] QA passes." + +create_issue "Apply advanced filter expressions in Doctrine provider" "type: feature,area: doctrine,area: filters,priority: high,ai-ready" \ +"## Objective + +Convert validated advanced filter expressions into safe Doctrine QueryBuilder conditions. + +## Scope + +- Add a Doctrine expression applier service. +- Reuse existing field reference resolver / metadata resolver where possible. +- Support main entity fields. +- Support joined fields when already declared/supported. +- Support: + - AND; + - OR; + - equals; + - not equals; + - contains; + - not contains; + - starts with; + - ends with; + - greater/less comparisons; + - between; + - is null; + - is not null; + - in; + - not in. +- Always bind parameters. +- Generate unique parameter names. +- Apply advanced filters to both data query and count query. +- Keep permanent filters, simple filters, search, sorting and pagination working. +- Add functional Doctrine tests. + +## Important constraints + +- No arbitrary DQL. +- No arbitrary SQL. +- No frontend-provided field paths outside allowed fields. +- No frontend-provided join expressions. +- No collection-valued association support unless already safe and covered. +- Avoid PostgreSQL-only functions unless already abstracted or explicitly documented. +- Prefer portable Doctrine expressions where possible. + +## Acceptance criteria + +- [ ] Doctrine provider applies advanced expressions. +- [ ] Main fields work. +- [ ] Joined fields work where supported. +- [ ] Parameters are safely bound. +- [ ] Count query matches data filtering. +- [ ] Invalid expressions do not produce unsafe queries. +- [ ] Functional tests cover AND/OR and multiple types. +- [ ] QA passes." + +create_issue "Ensure advanced filters apply to CSV and XLSX exports" "type: feature,area: export,area: filters,priority: medium,ai-ready" \ +"## Objective + +Ensure exports respect advanced filter expressions. + +## Scope + +- Verify current CSV export respects advanced filters. +- Verify current XLSX export respects advanced filters. +- Ensure current-page and full export modes behave as intended. +- Add tests if export tests already exist. +- Document limitations. + +## Important constraints + +- Do not change export format support. +- Do not make optional XLSX dependencies mandatory beyond current behavior. +- Do not break filename/format logic. +- Do not break visible column behavior. + +## Acceptance criteria + +- [ ] CSV export includes advanced filters. +- [ ] XLSX export includes advanced filters. +- [ ] Current/full export modes are covered. +- [ ] Tests pass. +- [ ] QA passes." + +create_issue "Document advanced filter expressions" "type: docs,area: filters,priority: high,ai-ready" \ +"## Objective + +Document the advanced filter/search-builder system. + +## Scope + +- Add user-facing documentation. +- Add developer/reference documentation. +- Explain: + - enabling advanced filters; + - declaring advanced-filterable fields; + - supported operators; + - supported types; + - AND/OR logic; + - Array provider behavior; + - Doctrine provider behavior; + - export behavior. +- Explain security boundaries: + - backend-defined fields only; + - no arbitrary DQL; + - no arbitrary SQL; + - no arbitrary join expressions; + - parameters are bound. +- Explain limitations: + - no saved presets yet; + - no persisted user filters yet; + - no third-party widgets yet; + - no collection-valued association support unless implemented. +- Link from README/docs index/UI docs. + +## Acceptance criteria + +- [ ] Docs are findable. +- [ ] Examples match current API. +- [ ] Security boundaries are explicit. +- [ ] Limitations are clear. +- [ ] QA passes." + +create_issue "Smoke test advanced filter expressions" "type: tests,area: filters,priority: high,ai-ready" \ +"## Objective + +Validate advanced filter expressions in the fresh Symfony smoke application. + +## Scope + +- Enable advanced filters on at least one Array provider datatable. +- Enable advanced filters on at least one Doctrine provider datatable if the smoke app supports it. +- Test: + - one string condition; + - one boolean condition; + - one numeric condition; + - AND conditions; + - OR conditions; + - clear filters; + - pagination after filtering; + - sorting after filtering; + - CSV export; + - XLSX export. +- Record findings. +- Create follow-up issues for blockers. + +## Acceptance criteria + +- [ ] Smoke test report exists. +- [ ] Advanced filters work in browser. +- [ ] Exports respect advanced filters. +- [ ] Blockers are fixed or tracked. +- [ ] QA passes." + +create_issue "Update roadmap after advanced filter expressions" "type: docs,priority: medium,ai-ready" \ +"## Objective + +Update roadmap after milestone 0.26. + +## Scope + +- Mark 0.26 - Advanced filter expressions as completed. +- Summarize delivered capabilities: + - backend expression model; + - field declarations; + - request normalization; + - Bootstrap UI; + - Stimulus serialization; + - Array provider support; + - Doctrine provider support; + - export compatibility. +- List current limitations. +- Set next milestone depending on priorities: + - 0.27 - Frontend E2E and accessibility evaluation; + - or 0.28 - Hierarchical tables / expandable child datatables. +- Keep later ideas coherent. + +## Acceptance criteria + +- [ ] Roadmap updated. +- [ ] Next milestone direction is clear. +- [ ] QA passes." + +echo "Advanced filter expressions milestone issues created successfully." diff --git a/tools/github/sync-labels.sh b/tools/github/sync-labels.sh index fff0bea..629ccde 100755 --- a/tools/github/sync-labels.sh +++ b/tools/github/sync-labels.sh @@ -58,7 +58,11 @@ sync_label "area: configuration" "006b75" "Bundle configuration" sync_label "area: export" "006b75" "CSV/XLSX or other export features" sync_label "area: security" "006b75" "Security, CSRF, permissions or safe defaults" sync_label "area: i18n" "006b75" "Translations and localization" -sync_label "area: actions" "006b75" "Translations and localization" +sync_label "area: actions" "006b75" "Actions" +sync_label "area: provider" "006b75" "PRoviders" +sync_label "area: filters" "006b75" "Filters" +sync_label "area: definition" "006b75" "Definitions" +sync_label "area: request" "006b75" "Request" # Priorities sync_label "priority: high" "b60205" "High priority" From 7d01985c51b47f6072929fc775b3c48d40ada341 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 15:07:38 +0200 Subject: [PATCH 21/39] feat: Design advanced filter expression model (#436) --- .../0009-advanced-filter-expressions-model.md | 123 ++++++++++++++++++ docs/decisions/index.md | 1 + 2 files changed, 124 insertions(+) create mode 100644 docs/decisions/0009-advanced-filter-expressions-model.md 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 2f11f64..7c1ce00 100644 --- a/docs/decisions/index.md +++ b/docs/decisions/index.md @@ -10,3 +10,4 @@ This directory contains records of significant architectural decisions made duri - [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) From 71ed32f1fbea02370616fcd41ed2cd4abbcd1a67 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 15:15:24 +0200 Subject: [PATCH 22/39] feat: Implement advanced filter expression value objects (#437) --- src/Exception/InvalidExpressionException.php | 9 +++ .../Expression/AdvancedFilterExpression.php | 13 +++++ src/Filter/Expression/ComparisonOperator.php | 24 ++++++++ src/Filter/Expression/Condition.php | 53 +++++++++++++++++ src/Filter/Expression/ExpressionInterface.php | 10 ++++ src/Filter/Expression/Group.php | 45 ++++++++++++++ src/Filter/Expression/LogicOperator.php | 11 ++++ .../AdvancedFilterExpressionTest.php | 24 ++++++++ .../Unit/Filter/Expression/ConditionTest.php | 56 ++++++++++++++++++ tests/Unit/Filter/Expression/GroupTest.php | 58 +++++++++++++++++++ 10 files changed, 303 insertions(+) create mode 100644 src/Exception/InvalidExpressionException.php create mode 100644 src/Filter/Expression/AdvancedFilterExpression.php create mode 100644 src/Filter/Expression/ComparisonOperator.php create mode 100644 src/Filter/Expression/Condition.php create mode 100644 src/Filter/Expression/ExpressionInterface.php create mode 100644 src/Filter/Expression/Group.php create mode 100644 src/Filter/Expression/LogicOperator.php create mode 100644 tests/Unit/Filter/Expression/AdvancedFilterExpressionTest.php create mode 100644 tests/Unit/Filter/Expression/ConditionTest.php create mode 100644 tests/Unit/Filter/Expression/GroupTest.php 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 @@ +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 @@ +root); + } +} 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()); + } +} From bd63b2e1a526b67c123f953c9f607dbba4e59c8c Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 15:24:53 +0200 Subject: [PATCH 23/39] feat: Declare advanced filterable fields (#438) --- package-lock.json | 1528 +++++------------ package.json | 2 +- .../AdvancedFilterFieldDefinition.php | 68 + src/Definition/DatatableDefinition.php | 37 + ...DatatableDefinitionAdvancedFiltersTest.php | 68 + 5 files changed, 585 insertions(+), 1118 deletions(-) create mode 100644 src/Definition/AdvancedFilterFieldDefinition.php create mode 100644 tests/Unit/Definition/DatatableDefinitionAdvancedFiltersTest.php diff --git a/package-lock.json b/package-lock.json index 4e142c8..f1c82a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,14 +7,13 @@ "devDependencies": { "@hotwired/stimulus": "^3.2.2", "jsdom": "^25.0.1", - "vitest": "^2.1.9" + "vitest": "^4.1.6" } }, "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,752 +122,119 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "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 - }, - "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 - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", - "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", - "cpu": [ - "riscv64" - ], + "node_modules/@hotwired/stimulus": { + "version": "3.2.2", "dev": true, - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", - "cpu": [ - "riscv64" - ], + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", "dev": true, - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", - "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", - "cpu": [ - "s390x" - ], + "node_modules/@oxc-project/types": { + "version": "0.130.0", "dev": true, - "optional": true, - "os": [ - "linux" - ] + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", - "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", - "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", - "cpu": [ - "x64" ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", - "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", - "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", - "cpu": [ - "arm64" + "linux" ], - "dev": true, - "optional": true, - "os": [ - "openharmony" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", - "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", - "cpu": [ - "arm64" - ], + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", "dev": true, - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", - "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", - "cpu": [ - "ia32" - ], + "node_modules/@standard-schema/spec": { + "version": "1.1.0", "dev": true, - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", - "cpu": [ - "x64" - ], + "node_modules/@types/chai": { + "version": "5.2.3", "dev": true, - "optional": true, - "os": [ - "win32" - ] + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", - "cpu": [ - "x64" - ], + "node_modules/@types/deep-eql": { + "version": "4.0.2", "dev": true, - "optional": true, - "os": [ - "win32" - ] + "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 + "dev": true, + "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "version": "4.1.6", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "version": "4.1.6", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", + "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -886,65 +246,58 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "version": "4.1.6", "dev": true, + "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "version": "4.1.6", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.1.6", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.1.6", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.1.6", "dev": true, - "dependencies": { - "tinyspy": "^3.0.2" - }, + "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.1.6", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.1.6", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -952,42 +305,29 @@ }, "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": { "node": ">=12" } }, "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 - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, - "engines": { - "node": ">=8" - } + "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" @@ -997,35 +337,17 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", "dev": true, - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, + "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "engines": { - "node": ">= 16" - } - }, "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" }, @@ -1033,11 +355,15 @@ "node": ">= 0.8" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "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" @@ -1048,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" @@ -1067,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" }, @@ -1084,33 +407,29 @@ }, "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 - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, - "engines": { - "node": ">=6" - } + "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" } }, + "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", @@ -1122,9 +441,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" }, @@ -1134,33 +452,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": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true + "version": "2.1.0", + "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" }, @@ -1170,9 +484,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", @@ -1183,67 +496,42 @@ "node": ">= 0.4" } }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, "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": { "@types/estree": "^1.0.0" } }, "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/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", @@ -1255,34 +543,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, - "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", @@ -1304,9 +576,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" @@ -1317,9 +588,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" }, @@ -1329,9 +599,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" }, @@ -1341,9 +610,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" }, @@ -1356,9 +624,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" }, @@ -1368,9 +635,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" }, @@ -1380,9 +646,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" @@ -1393,9 +658,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" @@ -1406,9 +670,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" }, @@ -1418,15 +681,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", @@ -1462,50 +723,105 @@ } } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true + "node_modules/lightningcss": { + "version": "1.32.0", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "cpu": [ + "x64" + ], + "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-musl": { + "version": "1.32.0", + "cpu": [ + "x64" + ], + "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/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": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "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" }, @@ -1515,14 +831,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": [ { @@ -1530,6 +843,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -1539,15 +853,22 @@ }, "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/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" }, @@ -1556,30 +877,28 @@ } }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "version": "2.0.3", "dev": true, - "engines": { - "node": ">= 14.16" - } + "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 + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, "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": [ { @@ -1595,6 +914,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -1606,80 +926,58 @@ }, "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/rollup": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", - "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "node_modules/rolldown": { + "version": "1.0.1", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.3", - "@rollup/rollup-android-arm64": "4.60.3", - "@rollup/rollup-darwin-arm64": "4.60.3", - "@rollup/rollup-darwin-x64": "4.60.3", - "@rollup/rollup-freebsd-arm64": "4.60.3", - "@rollup/rollup-freebsd-x64": "4.60.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", - "@rollup/rollup-linux-arm-musleabihf": "4.60.3", - "@rollup/rollup-linux-arm64-gnu": "4.60.3", - "@rollup/rollup-linux-arm64-musl": "4.60.3", - "@rollup/rollup-linux-loong64-gnu": "4.60.3", - "@rollup/rollup-linux-loong64-musl": "4.60.3", - "@rollup/rollup-linux-ppc64-gnu": "4.60.3", - "@rollup/rollup-linux-ppc64-musl": "4.60.3", - "@rollup/rollup-linux-riscv64-gnu": "4.60.3", - "@rollup/rollup-linux-riscv64-musl": "4.60.3", - "@rollup/rollup-linux-s390x-gnu": "4.60.3", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@rollup/rollup-openbsd-x64": "4.60.3", - "@rollup/rollup-openharmony-arm64": "4.60.3", - "@rollup/rollup-win32-arm64-msvc": "4.60.3", - "@rollup/rollup-win32-ia32-msvc": "4.60.3", - "@rollup/rollup-win32-x64-gnu": "4.60.3", - "@rollup/rollup-win32-x64-msvc": "4.60.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } }, "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" }, @@ -1689,81 +987,72 @@ }, "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": { "node": ">=0.10.0" } }, "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": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true + "version": "4.1.0", + "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": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "version": "1.1.2", "dev": true, + "license": "MIT", "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=18" } }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "node_modules/tinyglobby": { + "version": "0.2.16", "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=14.0.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "node_modules/tinyrainbow": { + "version": "3.1.0", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } }, "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" }, @@ -1773,15 +1062,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" }, @@ -1791,9 +1078,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" }, @@ -1802,20 +1088,21 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "8.0.13", "dev": true, + "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -1824,23 +1111,33 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, - "less": { + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -1857,83 +1154,87 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "version": "4.1.6", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, + "@opentelemetry/api": { + "optional": true + }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -1944,14 +1245,16 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, "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" }, @@ -1961,19 +1264,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" }, @@ -1983,18 +1283,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" @@ -2005,9 +1303,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" @@ -2021,9 +1318,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" }, @@ -2042,18 +1338,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/package.json b/package.json index 1f5c185..e5e30b2 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,6 @@ "devDependencies": { "@hotwired/stimulus": "^3.2.2", "jsdom": "^25.0.1", - "vitest": "^2.1.9" + "vitest": "^4.1.6" } } diff --git a/src/Definition/AdvancedFilterFieldDefinition.php b/src/Definition/AdvancedFilterFieldDefinition.php new file mode 100644 index 0000000..1760beb --- /dev/null +++ b/src/Definition/AdvancedFilterFieldDefinition.php @@ -0,0 +1,68 @@ + $allowedOperators + * @param array $choices + */ + public function __construct( + private string $name, + private string $field, + private ?string $label = null, + private FilterType $type = FilterType::Text, + private array $allowedOperators = [], + private array $choices = [], + ) { + 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.'); + } + } + + 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; + } + + /** + * @return list + */ + public function getAllowedOperators(): array + { + return $this->allowedOperators; + } + + /** + * @return array + */ + public function getChoices(): array + { + return $this->choices; + } +} diff --git a/src/Definition/DatatableDefinition.php b/src/Definition/DatatableDefinition.php index 3e291cf..46da34f 100644 --- a/src/Definition/DatatableDefinition.php +++ b/src/Definition/DatatableDefinition.php @@ -69,6 +69,11 @@ final class DatatableDefinition */ private array $aggregateColumns = []; + /** + * @var array + */ + private array $advancedFilterFields = []; + public function __construct( private readonly string $name, ) { @@ -445,4 +450,36 @@ public function getAggregateColumns(): array { return $this->aggregateColumns; } + + /** + * @param list $allowedOperators + * @param array $choices + */ + public function addAdvancedFilterField( + string $name, + string $field, + ?string $label = null, + FilterType $type = FilterType::Text, + array $allowedOperators = [], + array $choices = [], + ): self { + $this->advancedFilterFields[$name] = new AdvancedFilterFieldDefinition( + name: $name, + field: $field, + label: $label, + type: $type, + allowedOperators: $allowedOperators, + choices: $choices, + ); + + return $this; + } + + /** + * @return array + */ + public function getAdvancedFilterFields(): array + { + return $this->advancedFilterFields; + } } diff --git a/tests/Unit/Definition/DatatableDefinitionAdvancedFiltersTest.php b/tests/Unit/Definition/DatatableDefinitionAdvancedFiltersTest.php new file mode 100644 index 0000000..7e39be5 --- /dev/null +++ b/tests/Unit/Definition/DatatableDefinitionAdvancedFiltersTest.php @@ -0,0 +1,68 @@ +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([FilterOperator::Equals, FilterOperator::Like], $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()); + } +} From 85d090cfb0db49b23b3240c00035c611561025a5 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 15:38:29 +0200 Subject: [PATCH 24/39] feat: Normalize advanced filter expression request payload (#439) --- config/services.php | 3 + src/Controller/DatatableController.php | 4 +- .../AdvancedFilterExpressionFactory.php | 129 ++++++++++++++ src/Factory/DatatableRequestFactory.php | 10 +- src/Request/DatatableRequest.php | 15 ++ .../Controller/DatatableControllerTest.php | 3 +- .../AdvancedFilterExpressionFactoryTest.php | 159 ++++++++++++++++++ ...ableRequestFactoryColumnVisibilityTest.php | 24 +-- ...leRequestFactoryConfiguredDefaultsTest.php | 9 +- .../Factory/DatatableRequestFactoryTest.php | 78 +++++++-- 10 files changed, 399 insertions(+), 35 deletions(-) create mode 100644 src/Factory/AdvancedFilterExpressionFactory.php create mode 100644 tests/Unit/Factory/AdvancedFilterExpressionFactoryTest.php diff --git a/config/services.php b/config/services.php index 51d0bfd..9130155 100644 --- a/config/services.php +++ b/config/services.php @@ -27,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; @@ -51,6 +52,8 @@ $services->alias(ActionVisibilityCheckerInterface::class, AllowAllActionVisibilityChecker::class); + $services->set(AdvancedFilterExpressionFactory::class); + $services->set(DatatableDefinitionFactory::class); $services diff --git a/src/Controller/DatatableController.php b/src/Controller/DatatableController.php index 3297d47..b16ef01 100644 --- a/src/Controller/DatatableController.php +++ b/src/Controller/DatatableController.php @@ -31,7 +31,7 @@ public function __construct( public function fragments(Request $request, string $name): JsonResponse { $definition = $this->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/Factory/AdvancedFilterExpressionFactory.php b/src/Factory/AdvancedFilterExpressionFactory.php new file mode 100644 index 0000000..fac64cf --- /dev/null +++ b/src/Factory/AdvancedFilterExpressionFactory.php @@ -0,0 +1,129 @@ + $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; + + $children = []; + $childrenPayload = $payload['children'] ?? []; + + if (!is_array($childrenPayload) || [] === $childrenPayload) { + throw new InvalidExpressionException('Group must have at least one child.'); + } + + foreach ($childrenPayload as $childPayload) { + if (!is_array($childPayload)) { + continue; + } + + /** @var array $childPayload */ + if (isset($childPayload['children'])) { + $children[] = $this->parseGroup($childPayload, $definition); + } 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; + } + + if (null !== $definition) { + $advancedFilterFields = $definition->getAdvancedFilterFields(); + if (!isset($advancedFilterFields[$field])) { + return null; + } + + $fieldDefinition = $advancedFilterFields[$field]; + $allowedOperators = $fieldDefinition->getAllowedOperators(); + + if ([] !== $allowedOperators) { + $mappedFilterOperator = $this->mapToFilterOperator($operator); + if (!in_array($mappedFilterOperator, $allowedOperators, true)) { + return null; + } + } + } + + try { + return new Condition($field, $operator, $value); + } catch (InvalidExpressionException) { + return null; + } + } + + private function mapToFilterOperator(ComparisonOperator $operator): FilterOperator + { + return match ($operator) { + ComparisonOperator::Equals => FilterOperator::Equals, + ComparisonOperator::NotEquals => FilterOperator::NotEquals, + ComparisonOperator::Contains, + ComparisonOperator::StartsWith, + ComparisonOperator::EndsWith => FilterOperator::Like, + ComparisonOperator::NotContains => FilterOperator::NotLike, + ComparisonOperator::GreaterThan => FilterOperator::GreaterThan, + ComparisonOperator::GreaterThanOrEquals => FilterOperator::GreaterThanOrEquals, + ComparisonOperator::LessThan => FilterOperator::LessThan, + ComparisonOperator::LessThanOrEquals => FilterOperator::LessThanOrEquals, + ComparisonOperator::Between => FilterOperator::Between, + ComparisonOperator::IsNull => FilterOperator::IsNull, + ComparisonOperator::IsNotNull => FilterOperator::IsNotNull, + ComparisonOperator::In => FilterOperator::In, + ComparisonOperator::NotIn => FilterOperator::NotIn, + }; + } +} 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/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/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/Factory/AdvancedFilterExpressionFactoryTest.php b/tests/Unit/Factory/AdvancedFilterExpressionFactoryTest.php new file mode 100644 index 0000000..7edb684 --- /dev/null +++ b/tests/Unit/Factory/AdvancedFilterExpressionFactoryTest.php @@ -0,0 +1,159 @@ +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 \Zhortein\DatatableBundle\Filter\Expression\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(\Zhortein\DatatableBundle\Filter\Expression\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 \Zhortein\DatatableBundle\Filter\Expression\Condition $condition */ + $condition = $expression->root->children[0]; + self::assertSame('name', $condition->field); + } + + 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()); + } } From 88bd52962250a2e8a20725c5e61190f57f76cbda Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 15:47:27 +0200 Subject: [PATCH 25/39] feat: Render advanced filter builder UI (#440) --- assets/controllers/datatable_controller.js | 156 ++++++++++++++++++ config/services.php | 1 + src/Icon/IconResolver.php | 1 + src/Renderer/DatatableRenderer.php | 3 + src/ZhorteinDatatableBundle.php | 4 + templates/bootstrap/_search_builder.html.twig | 100 +++++++++++ templates/bootstrap/_toolbar.html.twig | 17 ++ templates/bootstrap/datatable.html.twig | 6 + .../DatatableRendererSearchBuilderTest.php | 131 +++++++++++++++ translations/zhortein_datatable.en.yaml | 24 +++ translations/zhortein_datatable.fr.yaml | 24 +++ 11 files changed, 467 insertions(+) create mode 100644 templates/bootstrap/_search_builder.html.twig create mode 100644 tests/Unit/Renderer/DatatableRendererSearchBuilderTest.php diff --git a/assets/controllers/datatable_controller.js b/assets/controllers/datatable_controller.js index b477206..4aae137 100644 --- a/assets/controllers/datatable_controller.js +++ b/assets/controllers/datatable_controller.js @@ -27,6 +27,9 @@ export default class extends Controller { 'selectedCount', 'bulkToolbar', 'bulkAction', + 'searchBuilder', + 'searchBuilderConditions', + 'searchBuilderConditionTemplate', ]; static values = { @@ -40,6 +43,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 }, @@ -452,6 +456,7 @@ export default class extends Controller { } this.appendFilterParameters(url.searchParams); + this.appendAdvancedFilterParameters(url.searchParams); this.appendColumnVisibilityParameters(url.searchParams); return url.toString(); @@ -541,6 +546,157 @@ export default class extends Controller { .filter((control) => control instanceof HTMLInputElement && control.type === 'checkbox'); } + addSearchBuilderCondition(event) { + if (event) event.preventDefault(); + + if (!this.hasSearchBuilderConditionsTarget || !this.hasSearchBuilderConditionTemplateTarget) { + return; + } + + const template = this.searchBuilderConditionTemplateTarget.content.cloneNode(true); + this.searchBuilderConditionsTarget.appendChild(template); + } + + removeSearchBuilderCondition(event) { + if (event) event.preventDefault(); + + const condition = event.target.closest('.zhortein-datatable__search-builder-condition'); + if (condition) { + condition.remove(); + this.refresh(); + } + } + + clearSearchBuilder(event) { + if (event) event.preventDefault(); + + if (this.hasSearchBuilderConditionsTarget) { + this.searchBuilderConditionsTarget.innerHTML = ''; + this.refresh(); + } + } + + updateSearchBuilderLogic() { + this.refresh(); + } + + 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; + + if (!type) { + operatorSelect.disabled = true; + operatorSelect.innerHTML = ``; + valueContainer.innerHTML = ''; + return; + } + + operatorSelect.disabled = false; + const operators = JSON.parse(this.searchBuilderTarget.dataset.zhorteinDatatableBundleDatatableOperatorsValue)[type] || []; + const operatorLabels = JSON.parse(this.searchBuilderTarget.dataset.zhorteinDatatableBundleDatatableOperatorLabelsValue); + + 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; + + 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 conditions = Array.from(this.searchBuilderConditionsTarget.querySelectorAll('.zhortein-datatable__search-builder-condition')); + if (conditions.length === 0) { + return; + } + + const logic = this.searchBuilderTarget.querySelector('select[data-action*="updateSearchBuilderLogic"]').value; + + searchParams.set('advancedFilters[logic]', logic); + + conditions.forEach((condition, index) => { + const fieldSelect = condition.querySelector('select[data-action*="onSearchBuilderFieldChange"]'); + const field = fieldSelect.value; + const operator = condition.querySelector('select[data-action*="onSearchBuilderOperatorChange"]').value; + + if (!field || !operator) { + return; + } + + searchParams.set(`advancedFilters[children][${index}][field]`, field); + searchParams.set(`advancedFilters[children][${index}][operator]`, operator); + + const valueContainer = condition.querySelector('.zhortein-datatable__search-builder-value-container'); + const inputs = valueContainer.querySelectorAll('input, select'); + + if (inputs.length === 1) { + const input = inputs[0]; + if (input instanceof HTMLSelectElement && input.multiple) { + Array.from(input.selectedOptions).forEach((opt, optIndex) => { + searchParams.append(`advancedFilters[children][${index}][value][${optIndex}]`, opt.value); + }); + } else { + searchParams.set(`advancedFilters[children][${index}][value]`, input.value); + } + } else if (inputs.length === 2) { + // Between + searchParams.set(`advancedFilters[children][${index}][value][from]`, inputs[0].value); + searchParams.set(`advancedFilters[children][${index}][value][to]`, inputs[1].value); + } + }); + } + resolveConfirmationMessage(target) { if (!(target instanceof HTMLElement)) { return null; diff --git a/config/services.php b/config/services.php index 9130155..ca0d364 100644 --- a/config/services.php +++ b/config/services.php @@ -137,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/src/Icon/IconResolver.php b/src/Icon/IconResolver.php index 896a041..3523b4c 100644 --- a/src/Icon/IconResolver.php +++ b/src/Icon/IconResolver.php @@ -31,6 +31,7 @@ 'bulk_actions' => 'bi bi-collection', 'boolean_true' => 'bi bi-check-lg', 'boolean_false' => 'bi bi-x-lg', + 'search_builder' => 'bi bi-sliders', ]; /** diff --git a/src/Renderer/DatatableRenderer.php b/src/Renderer/DatatableRenderer.php index 3f906fc..206d580 100644 --- a/src/Renderer/DatatableRenderer.php +++ b/src/Renderer/DatatableRenderer.php @@ -37,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 = [], ) { } @@ -160,6 +161,7 @@ private function resolveOptions(array $options): array $this->defaultTableOptions, [ 'search' => $this->searchEnabled, + 'searchBuilder' => $this->searchBuilderEnabled, 'pageSize' => $this->defaultPageSize, ], $options, @@ -605,6 +607,7 @@ private function resolveCommonIcons(): array '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/ZhorteinDatatableBundle.php b/src/ZhorteinDatatableBundle.php index eaa74a9..129a30a 100644 --- a/src/ZhorteinDatatableBundle.php +++ b/src/ZhorteinDatatableBundle.php @@ -65,6 +65,7 @@ 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']) @@ -135,6 +136,9 @@ private function configureRootNode(DefinitionConfigurator $definition): void ->booleanNode('search_enabled') ->defaultFalse() ->end() + ->booleanNode('search_builder_enabled') + ->defaultFalse() + ->end() ->arrayNode('icons') ->useAttributeAsKey('name') ->scalarPrototype()->end() diff --git a/templates/bootstrap/_search_builder.html.twig b/templates/bootstrap/_search_builder.html.twig new file mode 100644 index 0000000..f307567 --- /dev/null +++ b/templates/bootstrap/_search_builder.html.twig @@ -0,0 +1,100 @@ +
+
+
+
{{ 'zhortein_datatable.search_builder.title'|trans({}, 'zhortein_datatable') }}
+
+ + +
+
+ +
+ {# Conditions will be added here via JS #} +
+ + +
+ + {# Template for a single condition #} + +
diff --git a/templates/bootstrap/_toolbar.html.twig b/templates/bootstrap/_toolbar.html.twig index 9331bad..715e8f4 100644 --- a/templates/bootstrap/_toolbar.html.twig +++ b/templates/bootstrap/_toolbar.html.twig @@ -76,6 +76,23 @@ } %} {% endif %} + {% if options.searchBuilder is defined and options.searchBuilder %} + + {% endif %} + {% if not is_split_layout and page_size_selector_enabled %}
+ + {# Template for a nested subgroup #} + diff --git a/tests/Frontend/datatable_controller_search_builder.test.js b/tests/Frontend/datatable_controller_search_builder.test.js index 69e6e16..697695a 100644 --- a/tests/Frontend/datatable_controller_search_builder.test.js +++ b/tests/Frontend/datatable_controller_search_builder.test.js @@ -20,13 +20,18 @@ function createDatatableHtml() { data-${CONTROLLER_IDENTIFIER}-operator-labels-value='{"eq":"Equals","neq":"Not Equals","contains":"Contains","in":"In","gt":"Greater than","between":"Between"}' data-${CONTROLLER_IDENTIFIER}-i18n-value='{"select_operator":"Select operator","boolean_yes":"Yes","boolean_no":"No","between_from":"From","between_to":"To"}' > - - -
- +
+
+ + +
+
+ + +
+ + @@ -95,6 +115,22 @@ function getLastRequestedUrl(fetchMock) { 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; @@ -115,15 +151,15 @@ describe('datatable_controller search builder interactions', () => { application = startApplication(); const { element, controller } = await getController(application); - const addButton = element.querySelector('button[data-action$="#addSearchBuilderCondition"]'); - const conditionsContainer = element.querySelector('[data-zhortein--datatable-bundle--datatable-target="searchBuilderConditions"]'); + 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); - addButton.click(); + clickWithCurrentTarget(controller, 'addSearchBuilderCondition', addButton); expect(conditionsContainer.children.length).toBe(1); - addButton.click(); + clickWithCurrentTarget(controller, 'addSearchBuilderCondition', addButton); expect(conditionsContainer.children.length).toBe(2); const removeButton = conditionsContainer.children[0].querySelector('button[data-action$="#removeSearchBuilderCondition"]'); @@ -136,7 +172,7 @@ describe('datatable_controller search builder interactions', () => { application = startApplication(); const { element, controller } = await getController(application); - element.querySelector('button[data-action$="#addSearchBuilderCondition"]').click(); + clickWithCurrentTarget(controller, 'addSearchBuilderCondition', getRootAddConditionButton(element)); const condition = element.querySelector('.zhortein-datatable__search-builder-condition'); const fieldSelect = condition.querySelector('select[data-action$="#onSearchBuilderFieldChange"]'); @@ -158,7 +194,7 @@ describe('datatable_controller search builder interactions', () => { application = startApplication(); const { element, controller } = await getController(application); - element.querySelector('button[data-action$="#addSearchBuilderCondition"]').click(); + clickWithCurrentTarget(controller, 'addSearchBuilderCondition', getRootAddConditionButton(element)); const condition = element.querySelector('.zhortein-datatable__search-builder-condition'); const fieldSelect = condition.querySelector('select[data-action$="#onSearchBuilderFieldChange"]'); @@ -183,7 +219,7 @@ describe('datatable_controller search builder interactions', () => { const operatorSelect = condition.querySelector('select[data-action$="#onSearchBuilderOperatorChange"]'); operatorSelect.value = 'between'; controller.onSearchBuilderOperatorChange(); - controller.updateSearchBuilderValueInput(condition, 'number', null); // Manual trigger because of how test is set up + controller.updateSearchBuilderValueInput(condition, 'number', null); expect(valueContainer.querySelectorAll('input').length).toBe(2); expect(valueContainer.querySelectorAll('input')[0].placeholder).toBe('From'); @@ -197,7 +233,7 @@ describe('datatable_controller search builder interactions', () => { application = startApplication(); const { element, controller } = await getController(application); - element.querySelector('button[data-action$="#addSearchBuilderCondition"]').click(); + clickWithCurrentTarget(controller, 'addSearchBuilderCondition', getRootAddConditionButton(element)); const condition = element.querySelector('.zhortein-datatable__search-builder-condition'); const fieldSelect = condition.querySelector('select[data-action$="#onSearchBuilderFieldChange"]'); @@ -210,16 +246,16 @@ describe('datatable_controller search builder interactions', () => { 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[children][0][field]')).toBe('email'); - expect(url.searchParams.get('advancedFilters[children][0][operator]')).toBe('contains'); - expect(url.searchParams.get('advancedFilters[children][0][value]')).toBe('example'); + 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 () => { @@ -227,7 +263,7 @@ describe('datatable_controller search builder interactions', () => { application = startApplication(); const { element, controller } = await getController(application); - element.querySelector('button[data-action$="#addSearchBuilderCondition"]').click(); + clickWithCurrentTarget(controller, 'addSearchBuilderCondition', getRootAddConditionButton(element)); const condition = element.querySelector('.zhortein-datatable__search-builder-condition'); const fieldSelect = condition.querySelector('select[data-action$="#onSearchBuilderFieldChange"]'); @@ -258,7 +294,7 @@ describe('datatable_controller search builder interactions', () => { application = startApplication(); const { element, controller } = await getController(application); - element.querySelector('button[data-action$="#addSearchBuilderCondition"]').click(); + 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'; @@ -280,10 +316,10 @@ describe('datatable_controller search builder interactions', () => { 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[children][0][field]')).toBe('email'); - expect(url.searchParams.get('advancedFilters[children][0][operator]')).toBe('contains'); - expect(url.searchParams.get('advancedFilters[children][0][value]')).toBe('example'); + 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 () => { @@ -294,15 +330,145 @@ describe('datatable_controller search builder interactions', () => { application = startApplication(); const { element } = await getController(application); - element.querySelector('button[data-action$="#addSearchBuilderCondition"]').click(); - + 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('[data-zhortein--datatable-bundle--datatable-target="searchBuilderConditions"]').children.length).toBe(0); + 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/Unit/Definition/DatatableDefinitionAdvancedFiltersTest.php b/tests/Unit/Definition/DatatableDefinitionAdvancedFiltersTest.php index 7e39be5..6d1eafb 100644 --- a/tests/Unit/Definition/DatatableDefinitionAdvancedFiltersTest.php +++ b/tests/Unit/Definition/DatatableDefinitionAdvancedFiltersTest.php @@ -8,6 +8,7 @@ use Zhortein\DatatableBundle\Definition\DatatableDefinition; use Zhortein\DatatableBundle\Enum\FilterOperator; use Zhortein\DatatableBundle\Enum\FilterType; +use Zhortein\DatatableBundle\Filter\Expression\ComparisonOperator; final class DatatableDefinitionAdvancedFiltersTest extends TestCase { @@ -42,7 +43,15 @@ public function test_it_stores_advanced_filter_fields(): void self::assertSame('e.email', $fields['email']->getField()); self::assertSame('Email', $fields['email']->getLabel()); self::assertSame(FilterType::Text, $fields['email']->getType()); - self::assertSame([FilterOperator::Equals, FilterOperator::Like], $fields['email']->getAllowedOperators()); + 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()); @@ -65,4 +74,128 @@ public function test_it_has_sensible_defaults(): void 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/Factory/AdvancedFilterExpressionFactoryTest.php b/tests/Unit/Factory/AdvancedFilterExpressionFactoryTest.php index ceec50e..189b49f 100644 --- a/tests/Unit/Factory/AdvancedFilterExpressionFactoryTest.php +++ b/tests/Unit/Factory/AdvancedFilterExpressionFactoryTest.php @@ -235,6 +235,122 @@ enumClass: StatusEnumFixture::class, 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 = [ diff --git a/tests/Unit/Provider/ArrayDataProviderAdvancedFiltersTest.php b/tests/Unit/Provider/ArrayDataProviderAdvancedFiltersTest.php index 30951a8..3718701 100644 --- a/tests/Unit/Provider/ArrayDataProviderAdvancedFiltersTest.php +++ b/tests/Unit/Provider/ArrayDataProviderAdvancedFiltersTest.php @@ -41,6 +41,36 @@ public function test_it_applies_advanced_filter_expression(): void ], $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( From 3a84993e16745165c0033847e77d47881139a072 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 17 May 2026 17:52:28 +0200 Subject: [PATCH 33/39] fix: missing translation --- translations/zhortein_datatable.en.yaml | 1 + translations/zhortein_datatable.fr.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/translations/zhortein_datatable.en.yaml b/translations/zhortein_datatable.en.yaml index ae187e5..d5c32f4 100644 --- a/translations/zhortein_datatable.en.yaml +++ b/translations/zhortein_datatable.en.yaml @@ -10,6 +10,7 @@ zhortein_datatable: add_condition: 'Add condition' select_field: 'Select field' select_operator: 'Select operator' + add_subgroup: 'Add subgroup' operators: eq: 'Equals' neq: 'Not Equals' diff --git a/translations/zhortein_datatable.fr.yaml b/translations/zhortein_datatable.fr.yaml index a925dbe..700f016 100644 --- a/translations/zhortein_datatable.fr.yaml +++ b/translations/zhortein_datatable.fr.yaml @@ -10,6 +10,7 @@ zhortein_datatable: add_condition: 'Ajouter une condition' select_field: 'Sélectionner un champ' select_operator: 'Sélectionner un opérateur' + add_subgroup: 'Ajouter un sous-groupe' operators: eq: 'Égal à' neq: 'Différent de' From 1e160479c00e166b082ce15cc11ca37881a4e551 Mon Sep 17 00:00:00 2001 From: David Renard <82563246+Zhortein@users.noreply.github.com> Date: Sun, 17 May 2026 18:01:04 +0200 Subject: [PATCH 34/39] docs: Roadmap updated (#448) --- docs/roadmap.md | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 31fada1..51d2bfb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -968,7 +968,7 @@ Generated datatables have a coherent visual language while allowing host applica --- -## 0.26 - Advanced filter expressions 🚧 +## 0.26 - Advanced filter expressions ✅ Goal: @@ -976,34 +976,34 @@ Goal: Introduce a safe advanced filter expression model without exposing Doctrine QueryBuilder directly to the frontend. ``` -Planned: - -- design a filter expression model; -- support grouped conditions; -- support AND / OR combinations; -- support type-aware operators; -- support Doctrine-safe mapping; -- keep frontend input declarative and validated; -- document limitations; -- add tests for expression normalization and Doctrine application. +Delivered: -Suggested public terminology: +- **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. -```text -Advanced filter expressions -``` +Current limitations: -Avoid public wording such as “query builder” if it could imply exposing Doctrine internals. +- 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 expected outcome: +Main outcome: ```text -Users can build richer filters safely while the backend remains in control of query generation. +Users can build richer, nested filters safely using a Search Builder UI while the backend remains in control of query generation. ``` --- -## 0.27 - Frontend E2E and accessibility evaluation 🕒 +## 0.27 - Frontend E2E and accessibility evaluation 🚧 Goal: @@ -1095,7 +1095,6 @@ Expected stable scope: Potential future work: - multi-column sorting; -- SearchBuilder-like advanced expressions; - async exports; - streaming export provider contracts; - additional export formats; @@ -1104,8 +1103,6 @@ Potential future work: - Elasticsearch provider; - UX Icons integration; - richer enum badge/icon rendering; -- accessibility audit; -- browser E2E test suite; - Symfony Flex recipe if external demand justifies it; - Tailwind or custom theme support; - icon provider abstraction; From 2bd024644cccc8ca9b6ebb1b58b7a71bd4c0addc Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sun, 17 May 2026 18:36:55 +0200 Subject: [PATCH 35/39] chore: add first beta preparation issue creation script --- .../create-first-beta-preparation-issues.sh | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100755 tools/github/create-first-beta-preparation-issues.sh diff --git a/tools/github/create-first-beta-preparation-issues.sh b/tools/github/create-first-beta-preparation-issues.sh new file mode 100755 index 0000000..a43c9d6 --- /dev/null +++ b/tools/github/create-first-beta-preparation-issues.sh @@ -0,0 +1,381 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI is required. Install it first: https://cli.github.com/" + exit 1 +fi + +gh auth status >/dev/null + +MILESTONE_TITLE="0.27 - First beta preparation" + +ensure_milestone() { + if gh api repos/:owner/:repo/milestones --jq ".[] | select(.title == \"${MILESTONE_TITLE}\") | .title" | grep -q "${MILESTONE_TITLE}"; then + echo "Milestone already exists: ${MILESTONE_TITLE}" + else + echo "Creating milestone: ${MILESTONE_TITLE}" + gh api repos/:owner/:repo/milestones \ + -f title="${MILESTONE_TITLE}" \ + -f description="Prepare the first beta release after bulk actions, icon system and advanced filter expressions. Target release: v0.3.0-beta.1." + fi +} + +issue_exists() { + local title="$1" + + gh issue list \ + --state all \ + --search "$title in:title" \ + --json title \ + --jq ".[].title" \ + | grep -Fxq "$title" +} + +create_issue() { + local title="$1" + local labels="$2" + local body_file="$3" + + if issue_exists "$title"; then + echo "Issue already exists: $title" + return + fi + + local label_args=() + IFS=',' read -ra label_list <<< "$labels" + + for raw_label in "${label_list[@]}"; do + local label + label="$(printf "%s" "$raw_label" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + + if [ -n "$label" ]; then + label_args+=(--label "$label") + fi + done + + echo "Creating issue: $title" + + gh issue create \ + --title "$title" \ + --body-file "$body_file" \ + --milestone "$MILESTONE_TITLE" \ + "${label_args[@]}" +} + +make_body() { + local tmpfile + tmpfile="$(mktemp)" + cat > "$tmpfile" + echo "$tmpfile" +} + +ensure_milestone + +body="$(make_body <<'BODY' +## Objective + +Run a full smoke test before the first beta release. + +## Context + +The bundle now includes several production-oriented features: + +- Bootstrap/Twig datatable rendering; +- Stimulus Ajax refresh; +- Array provider; +- Doctrine provider; +- explicit joins; +- chained joins; +- custom joins; +- aggregate columns; +- simple filters; +- header filters; +- advanced filter expressions / search builder; +- row/global actions; +- bulk actions and row selection; +- modal confirmations; +- icon system; +- CSV and XLSX exports; +- frontend tests. + +The first beta should only be tagged after validating these features in the fresh Symfony smoke application. + +## Scope + +Validate at least: + +- installation from current `develop`; +- route import; +- Stimulus controller activation; +- Bootstrap CSS/JS integration; +- array provider datatable; +- Doctrine provider datatable; +- row actions; +- global actions; +- bulk actions; +- CSRF-aware non-GET actions; +- Bootstrap modal confirmation; +- inline/dropdown/list row action modes; +- split/default controls layout; +- toolbar filters; +- header filters; +- advanced filter expressions; +- sorting and sort indicators; +- column visibility; +- CSV export; +- XLSX export; +- icon configuration; +- documentation sanity. + +## Out of scope + +- Full browser automation. +- Performance benchmarking. +- Complete accessibility audit. + +## Acceptance criteria + +- [ ] Smoke app is updated against current `develop`. +- [ ] Array provider smoke path passes. +- [ ] Doctrine provider smoke path passes. +- [ ] Actions and bulk actions smoke path passes. +- [ ] Simple and advanced filters smoke path passes. +- [ ] CSV and XLSX exports work. +- [ ] Icon system renders as expected. +- [ ] Findings are recorded. +- [ ] Blockers are identified or ruled out. +BODY +)" +create_issue "Run first beta smoke test" "type: tests,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Resolve any blockers discovered during the first beta smoke test. + +## Scope + +- Review first beta smoke test findings. +- Fix release-blocking issues. +- Create follow-up issues for non-blocking findings. +- Ensure QA and frontend tests remain green. + +## Release-blocking examples + +- installation fails; +- routes fail; +- Stimulus controller fails; +- basic datatable rendering fails; +- Array provider fails; +- Doctrine provider fails; +- actions or bulk actions fail; +- CSRF behavior is broken; +- advanced filters do not apply; +- exports fail; +- modal confirmation prevents normal use; +- CI is not green. + +## Acceptance criteria + +- [ ] All first beta blockers are resolved. +- [ ] Non-blocking findings are tracked separately. +- [ ] `make frontendtest` passes. +- [ ] `make qa` passes. +- [ ] CI is green. +BODY +)" +create_issue "Resolve first beta blockers" "type: bug,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Review the public API before publishing the first beta. + +## Context + +A beta release sends the signal that the main feature set is now mostly in place and should begin stabilizing. + +Before tagging `v0.3.0-beta.1`, review the public API surface and identify risks. + +## Scope + +Review public or semi-public APIs including: + +- `#[AsDatatable]`; +- `DatatableInterface`; +- `DatatableDefinition`; +- column definitions; +- filter definitions; +- advanced filter expression APIs; +- action definitions; +- bulk action definitions; +- join/custom join definitions; +- aggregate column definitions; +- export writer interfaces; +- preference provider interfaces; +- action visibility/security interfaces; +- Twig function API; +- runtime options passed to `zhortein_datatable()`; +- Twig template override context; +- Stimulus data attributes that host apps may rely on. + +## Tasks + +- Update `docs/public-api-review.md`. +- Mark APIs as stable-ish, experimental or internal. +- List API risks before 1.0. +- Identify any immediate API rename/deprecation needed before beta. +- Avoid code changes unless a small naming fix is clearly necessary and safe. + +## Acceptance criteria + +- [ ] Public API review is updated. +- [ ] Experimental APIs are identified. +- [ ] Internal APIs are identified where possible. +- [ ] 1.0 API risks are documented. +- [ ] Any beta-blocking API issue is tracked. +- [ ] QA passes. +BODY +)" +create_issue "Review public API before first beta" "type: architecture,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Prepare `CHANGELOG.md` for the first beta release. + +## Target version + +```text +v0.3.0-beta.1 +``` + +## Scope + +- Review the `Unreleased` section. +- Ensure all changes since `v0.2.0-alpha.1` are listed. +- Include: + - bulk actions and row selection; + - icon system and visual consistency; + - advanced filter expressions / search builder; + - smoke-test fixes; + - docs updates; + - any dependency/CI changes. +- Ensure no unimplemented feature is listed as implemented. +- Prepare or verify release notes extraction. + +## Acceptance criteria + +- [ ] `CHANGELOG.md` is accurate. +- [ ] Target version is confirmed. +- [ ] Release notes can be extracted. +- [ ] QA passes. +BODY +)" +create_issue "Prepare changelog for first beta" "type: docs,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Record the go/no-go decision for the first beta tag. + +## Scope + +Review: + +- smoke test status; +- blocker status; +- public API review; +- changelog status; +- Composer/Packagist metadata; +- CI status; +- QA status; +- frontend test status; +- documentation status; +- known limitations; +- final tag name. + +## Proposed tag + +```text +v0.3.0-beta.1 +``` + +## Acceptance criteria + +- [ ] Go/no-go decision is recorded. +- [ ] Known limitations are explicit. +- [ ] Release tag is confirmed. +- [ ] CI is green. +- [ ] QA is green. +BODY +)" +create_issue "Review go-no-go for first beta tag" "type: docs,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Tag and publish the first beta release. + +## Target version + +```text +v0.3.0-beta.1 +``` + +## Scope + +- Ensure `develop` is ready. +- Merge or fast-forward `main` to `develop`. +- Create the tag. +- Push the tag. +- Verify GitHub release workflow. +- Verify GitHub release notes. +- Verify Packagist update. +- Optionally test installation from Packagist. + +## Acceptance criteria + +- [ ] `main` contains the release state. +- [ ] Tag `v0.3.0-beta.1` is pushed. +- [ ] GitHub release exists. +- [ ] Packagist sees the new version. +- [ ] Installation test succeeds if performed. +BODY +)" +create_issue "Tag and publish first beta" "type: release,priority: high,ai-ready" "$body" +rm -f "$body" + +body="$(make_body <<'BODY' +## Objective + +Update the roadmap after the first beta release. + +## Scope + +- Mark `0.27 - First beta preparation` as completed. +- Mention published tag `v0.3.0-beta.1`. +- Clarify what beta means for the project. +- Update current limitations. +- Set next roadmap direction. +- Keep later ideas coherent. + +## Acceptance criteria + +- [ ] Roadmap reflects first beta release. +- [ ] Next milestone direction is clear. +- [ ] Known limitations are accurate. +- [ ] QA passes. +BODY +)" +create_issue "Update roadmap after first beta" "type: docs,priority: medium,ai-ready" "$body" +rm -f "$body" + +echo "First beta preparation issues created successfully." From daab1e45144aeeec3166c9ea8ac2d9e92a974f15 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 18 May 2026 05:26:41 +0200 Subject: [PATCH 36/39] chore: update roadmap with Milestone for first beta release --- docs/roadmap.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 51d2bfb..9eff17c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1003,6 +1003,32 @@ Users can build richer, nested filters safely using a Search Builder UI while th --- +## 0.28 - 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. +``` + +Planned: +- 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 expected outcome: +```text +Target release: v0.3.0-beta.1. +``` + +--- + ## 0.27 - Frontend E2E and accessibility evaluation 🚧 Goal: From 412faa01c9377fb3598cc8acd247851ebb9f039a Mon Sep 17 00:00:00 2001 From: David RENARD Date: Mon, 18 May 2026 11:55:04 +0200 Subject: [PATCH 37/39] fix: missing translations --- translations/zhortein_datatable.en.yaml | 2 ++ translations/zhortein_datatable.fr.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/translations/zhortein_datatable.en.yaml b/translations/zhortein_datatable.en.yaml index d5c32f4..16eddac 100644 --- a/translations/zhortein_datatable.en.yaml +++ b/translations/zhortein_datatable.en.yaml @@ -8,9 +8,11 @@ zhortein_datatable: or: 'OR' clear: 'Clear search builder' add_condition: 'Add condition' + remove_condition: 'Remove condition' select_field: 'Select field' select_operator: 'Select operator' add_subgroup: 'Add subgroup' + remove_subgroup: 'Remove subgroup' operators: eq: 'Equals' neq: 'Not Equals' diff --git a/translations/zhortein_datatable.fr.yaml b/translations/zhortein_datatable.fr.yaml index 700f016..0152af5 100644 --- a/translations/zhortein_datatable.fr.yaml +++ b/translations/zhortein_datatable.fr.yaml @@ -8,9 +8,11 @@ zhortein_datatable: or: 'OU' clear: 'Effacer le constructeur' add_condition: 'Ajouter une condition' + remove_condition: 'Supprimer une condition' select_field: 'Sélectionner un champ' select_operator: 'Sélectionner un opérateur' add_subgroup: 'Ajouter un sous-groupe' + remove_subgroup: 'Supprimer un sous-groupe' operators: eq: 'Égal à' neq: 'Différent de' From ad700c7a3251b4bf227e2cb3a5c1670ed12c2d27 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sat, 6 Jun 2026 07:00:10 +0200 Subject: [PATCH 38/39] chore: updated composer packages --- .idea/inspectionProfiles/Project_Default.xml | 6 + composer.lock | 887 +++++++++++-------- 2 files changed, 519 insertions(+), 374 deletions(-) create mode 100644 .idea/inspectionProfiles/Project_Default.xml 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/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", From 0256c7a451c08416c2fcc3951134372b0f363808 Mon Sep 17 00:00:00 2001 From: David RENARD Date: Sat, 6 Jun 2026 07:17:05 +0200 Subject: [PATCH 39/39] chore: documentation update --- .idea/datatable-bundle.iml | 1 + .idea/php.xml | 1 + CHANGELOG.md | 13 ++++++++++++- docs/icons.md | 4 ++-- docs/roadmap.md | 10 +++++----- 5 files changed, 21 insertions(+), 8 deletions(-) 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/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/docs/icons.md b/docs/icons.md index d5b6291..038b054 100644 --- a/docs/icons.md +++ b/docs/icons.md @@ -78,7 +78,7 @@ If no `icon` is provided for an action, the bundle attempts to resolve it automa ### Using Bootstrap Icons (Default) -Ensure you include the Bootstrap Icons CSS in your layout: +Ensure you include the Bootstrap Icons CSS in your layout, if not included via AssetMapper: ```html @@ -111,7 +111,7 @@ zhortein_datatable: - **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. +- **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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 9eff17c..d222dfc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1003,7 +1003,7 @@ Users can build richer, nested filters safely using a Search Builder UI while th --- -## 0.28 - First beta preparation 🚧 +## 0.27 - First beta preparation ✅ Goal: @@ -1013,7 +1013,7 @@ Prepare the first beta release after bulk actions, icon system and advanced filt Target release: v0.3.0-beta.1. ``` -Planned: +Delivered: - Run first beta smoke test - Resolve first beta blockers - Review public API before first beta @@ -1022,14 +1022,14 @@ Planned: - Tag and publish first beta - Update roadmap after first beta -Main expected outcome: +Main outcome: ```text Target release: v0.3.0-beta.1. ``` --- -## 0.27 - Frontend E2E and accessibility evaluation 🚧 +## 0.28 - Frontend E2E and accessibility evaluation 🚧 Goal: @@ -1057,7 +1057,7 @@ The most interactive Bootstrap/Stimulus behaviors are validated beyond jsdom uni --- -## 0.28 - Hierarchical tables / expandable child datatables 🕒 +## 0.29 - Hierarchical tables / expandable child datatables 🕒 Goal: