diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1306f43..0f1c83d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -name: tableBundle +name: PHP Symfony CI on: push: @@ -6,21 +6,51 @@ on: pull_request: branches: [ main, develop ] +env: + DATABASE_URL: mysql://root:root@127.0.01:3306/table_bundle + DATABASE_DRIVER: pdo_mysql + jobs: - phpunit: + build: runs-on: ubuntu-latest + + strategy: + matrix: + php: [8.1, 8.2, 8.3] + symfony: [6.4.*, 7.0.*, 7.1.*] + exclude: + - php: 8.1 + symfony: 7.1.* + - php: 8.1 + symfony: 7.0.* + + services: + mysql: + image: mysql:latest + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping --silent" + --health-interval 10s + --health-timeout 5s + --health-retries 3 + steps: - - uses: shivammathur/setup-php@2cb9b829437ee246e9b3cac53555a39208ca6d28 + - uses: actions/checkout@v4 + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 with: - php-version: '8.1' - - uses: actions/checkout@v2 - - name: Install Dependencies - run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - - name: Execute tests (Unit and Feature tests) via PHPUnit + php-version: ${{ matrix.php }} + tools: flex + - name: Download dependencies env: - DATABASE_URL: sqlite:///%kernel.project_dir%/var/app.db + SYMFONY_REQUIRE: ${{ matrix.symfony }} + uses: ramsey/composer-install@v2 + - name: Run test suite on PHP ${{ matrix.php }} and Symfony ${{ matrix.symfony }} run: vendor/bin/simple-phpunit - - name: Check Code Styles + - name: Run ECS run: vendor/bin/ecs - - name: Check PHP Stan + - name: Run PHPStan run: vendor/bin/phpstan analyse src tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ff2a2..53b4aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## v1.2.0 + - Removed symfony ^5.4 support + - Added Table option `OPT_SUB_TABLE_COLLAPSED`. This will collapse the sub table by default, you can also pass a callable to determine if the sub table should be collapsed or not + - Added footer columns to the table. This can be used to display totals or other information + - Fixed a bug where the table count would be of when using group by in the default query builder + - Date filters now use the `datetime_controller.js` provided by the core-bundle + - UX improvements + ## v1.0.7 - More documentation and better styling of the documentation - Added a new optional parameter to the `FilterTypeInterface::getValueField()` method to allow for more complex value fields. diff --git a/composer.json b/composer.json index c2b9b52..15ea69f 100644 --- a/composer.json +++ b/composer.json @@ -16,30 +16,30 @@ ], "require": { "php": ">=8.1", - "symfony/framework-bundle": "^5.4|^6.4|^7.0", - "symfony/http-kernel": "^5.4|^6.4|^7.0", - "symfony/validator": "^5.4|^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", "araise/core-bundle": "^1.1", "araise/search-bundle": "^3.1", "phpoffice/phpspreadsheet": "^1.22|^2.0", "symfony/stimulus-bundle": "^2.16" }, "require-dev": { - "symfony/phpunit-bridge": "^5.4|^6.4|^7.0", - "symfony/config": "^5.4|^6.4|^7.0", - "symfony/dependency-injection": "^5.4|^6.4|^7.0", - "symfony/yaml": "^5.4|^6.4|^7.0", + "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0", "doctrine/doctrine-bundle": "^2.5.5", - "doctrine/orm": "^2.11|^3.1", + "doctrine/orm": "^2.11", "whatwedo/php-coding-standard": "^1.0", - "zenstruck/foundry": "^1.16", + "zenstruck/foundry": "^v2.0.7", "zenstruck/console-test": "^v1.1.0", - "symfony/translation": "^5.4|^6.4|^7.0", - "symfony/twig-bundle": "^5.4|^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/twig-bundle": "^6.4|^7.0", "gedmo/doctrine-extensions": "^3.15", "symfony/webpack-encore-bundle": "^1.14|^2.1", - "symfony/security-core": "^5.4|^6.4|^7.0", - "symfony/security-bundle": "^5.4|^6.4|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/security-bundle": "^6.4|^7.0", "phpstan/phpstan": "^1.5" }, "autoload": { diff --git a/docs/index.html b/docs/index.html index 332c1f6..531cf50 100644 --- a/docs/index.html +++ b/docs/index.html @@ -79,7 +79,19 @@ diff --git a/docs/table-configuration.md b/docs/table-configuration.md index b80157e..ed6d8c4 100644 --- a/docs/table-configuration.md +++ b/docs/table-configuration.md @@ -22,6 +22,32 @@ All Options are as constants in `Table` class. - - `content_show_header`: Boolean, default: `true` - - `content_show_entry_dropdown`: Boolean, default: `true` - - `content_show_pagination_if_page_total_less_than_limit`: Boolean, default: `true` +- `sub_table_collapsed`: Boolean or callable, default: `true` + + +### Collapse Sub-Tables +By default, sub-tables will be rendered collapsed. +This can be changed by setting the `sub_table_collapsed` option to either `false` or pass a callable that returns a boolean. + +```php +$table->setOption(Table::OPT_SUB_TABLE_COLLAPSED, false); +``` + +Note that if you pass a function, you will get the current row as an argument. +You can use this to only expand certain rows: + +```php +// Expand only users with an email +$table->setOption(Table::OPT_SUB_TABLE_COLLAPSED, function(array|object $row) { + if(!$row instanceof User) { + return true; + } + if($row->getEmail() !== null) { + return false; + } + return true; +}); +``` ## Column Options @@ -48,6 +74,33 @@ All Options are as constants in `Column` class. - `formatter`: [Formatter](formatter.md) - `sort_expression`: String, example: `'trainerGroup.name'` +## Footer Columns +In this example we would like to add a count of the content at the end of our table. +We can to this by adding a footer column like so: + +```php +$data= [ + [ + 'id' => 1, + ], + [ + 'id' => 2, + ] +]; + +$table + ->setFooterData(['count' => count($data)]) + ->addFooterColumn('totalLabel', null, [ + Column::OPT_CALLABLE => fn (array $content) => 'Total', + ]) + ->addFooterColumn('count', null, [ + Column::OPT_CALLABLE => fn(array $content) => $content['count'], + ]) +; +``` + +Note that this is only a simple example. The data could easily be replaced with an array of entities for example. + ## Action Columns Action Columns are here to link to other pages (f.ex. link to edit or view). diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6daad39..3425ad4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,7 @@ + diff --git a/src/DataLoader/DoctrineDataLoader.php b/src/DataLoader/DoctrineDataLoader.php index 3b5918c..3dfb07a 100644 --- a/src/DataLoader/DoctrineDataLoader.php +++ b/src/DataLoader/DoctrineDataLoader.php @@ -6,6 +6,7 @@ use araise\TableBundle\Extension\PaginationExtension; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Query\Parameter; use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\Tools\Pagination\Paginator; @@ -43,9 +44,24 @@ public function getResults(): iterable /** @var QueryBuilder $qb */ $qb = (clone $this->options[self::OPT_QUERY_BUILDER]); $qb->select('COUNT('.$qb->getRootAliases()[0].')'); - $qb->resetDQLPart('groupBy'); - $qb->resetDQLPart('orderBy'); - $this->paginationExtension->setTotalResults((int) $qb->getQuery()->getSingleScalarResult()); + + if (count($qb->getDQLPart('groupBy')) > 0) { + $sql = $qb->getQuery()->getSQL(); + $params = array_values( + array_map( + static fn (Parameter $parameter) => $parameter->getValue(), + $qb->getParameters()->toArray() + ) + ); + + $sql = sprintf('SELECT COUNT(*) as row_count FROM (%s) AS sub;', $sql); + $rsm = new ResultSetMapping(); + $rsm->addScalarResult('row_count', 'row_count', 'integer'); + $count = $this->entityManager->createNativeQuery($sql, $rsm)->setParameters($params)->getSingleScalarResult(); + $this->paginationExtension->setTotalResults((int) $count); + } else { + $this->paginationExtension->setTotalResults($qb->getQuery()->getSingleScalarResult()); + } if ($this->getOption(self::OPT_SAVE_LAST_QUERY) && $this->requestStack->getCurrentRequest()?->hasSession()) { /** @var QueryBuilder $qbSave */ diff --git a/src/DependencyInjection/araiseTableExtension.php b/src/DependencyInjection/araiseTableExtension.php index ec56e73..3eb5479 100644 --- a/src/DependencyInjection/araiseTableExtension.php +++ b/src/DependencyInjection/araiseTableExtension.php @@ -6,9 +6,9 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; class araiseTableExtension extends Extension implements PrependExtensionInterface { diff --git a/src/Exception/DataLoaderNotAvailableException.php b/src/Exception/DataLoaderNotAvailableException.php index 3a7885d..dab50b0 100644 --- a/src/Exception/DataLoaderNotAvailableException.php +++ b/src/Exception/DataLoaderNotAvailableException.php @@ -31,7 +31,7 @@ class DataLoaderNotAvailableException extends \Exception { - public function __construct($message = '', $code = 0, \Throwable $previous = null) + public function __construct($message = '', $code = 0, ?\Throwable $previous = null) { if (! $message) { $message = 'Table data loader is not availabe. Please set one by calling Table::setDataLoader($dataLoader)'; diff --git a/src/Exception/InvalidFilterAcronymException.php b/src/Exception/InvalidFilterAcronymException.php index ee058ef..6a10e54 100644 --- a/src/Exception/InvalidFilterAcronymException.php +++ b/src/Exception/InvalidFilterAcronymException.php @@ -31,7 +31,7 @@ class InvalidFilterAcronymException extends \InvalidArgumentException { - public function __construct($acronym, $message = '', $code = 0, \Throwable $previous = null) + public function __construct($acronym, $message = '', $code = 0, ?\Throwable $previous = null) { if (! $message) { $message = sprintf( diff --git a/src/Factory/TableFactory.php b/src/Factory/TableFactory.php index bf3231d..7a212fd 100644 --- a/src/Factory/TableFactory.php +++ b/src/Factory/TableFactory.php @@ -53,7 +53,7 @@ public function __construct( ) { } - public function create($identifier, string $dataLoader = null, $options = []): Table + public function create($identifier, ?string $dataLoader = null, $options = []): Table { if (! $dataLoader) { $dataLoader = DoctrineDataLoader::class; diff --git a/src/Filter/Type/DateFilterType.php b/src/Filter/Type/DateFilterType.php index 1f9f319..5600eb4 100644 --- a/src/Filter/Type/DateFilterType.php +++ b/src/Filter/Type/DateFilterType.php @@ -5,17 +5,36 @@ namespace araise\TableBundle\Filter\Type; use Doctrine\ORM\QueryBuilder; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; class DateFilterType extends DatetimeFilterType { + protected $locale; + + public function __construct( + ?string $column = null, + array $joins = [], + protected ?RequestStack $requestStack = null + ) { + parent::__construct($column, $joins); + $this->locale = $requestStack->getMainRequest()?->getLocale() ?? 'en'; + } + public function getValueField(?string $value = null, ?string $operator = null): string { $date = \DateTime::createFromFormat(static::getQueryDataFormat(), (string) $value) ?: new \DateTime(); $value = $date->format(static::getDateFormat()); - + $stimulusAttrs = (new StimulusHelper(null))->createStimulusAttributes(); + $stimulusAttrs + ->addController('araise/core-bundle/datetime', [ + 'lang' => $this->locale, + ]) + ; return sprintf( - '', - $operator !== static::CRITERIA_IS_EMPTY ? $value : '' + '', + $operator !== static::CRITERIA_IS_EMPTY ? $value : '', + $stimulusAttrs ); } diff --git a/src/Filter/Type/DatetimeFilterType.php b/src/Filter/Type/DatetimeFilterType.php index c1f6c31..bb5872e 100644 --- a/src/Filter/Type/DatetimeFilterType.php +++ b/src/Filter/Type/DatetimeFilterType.php @@ -5,6 +5,8 @@ namespace araise\TableBundle\Filter\Type; use Doctrine\ORM\QueryBuilder; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; class DatetimeFilterType extends FilterType { @@ -20,6 +22,14 @@ class DatetimeFilterType extends FilterType public const CRITERIA_IS_EMPTY = 'is_empty'; + public function __construct( + ?string $column = null, + array $joins = [], + protected ?RequestStack $requestStack = null + ) { + parent::__construct($column, $joins); + } + public function getOperators(): array { return [ @@ -38,10 +48,17 @@ public function getValueField(?string $value = null, ?string $operator = null): { $date = \DateTime::createFromFormat(static::getQueryDataFormat(), (string) $value) ?: new \DateTime(); $value = $date->format(static::getDateFormat()); - + $locale = $this->requestStack->getMainRequest()?->getLocale(); + $stimulusAttrs = (new StimulusHelper(null))->createStimulusAttributes(); + $stimulusAttrs + ->addController('araise/core-bundle/datetime', [ + 'lang' => $locale ?? 'en', + ]) + ; return sprintf( - '', - $operator !== static::CRITERIA_IS_EMPTY ? $value : '' + '', + $operator !== static::CRITERIA_IS_EMPTY ? $value : '', + $stimulusAttrs ); } diff --git a/src/Resources/assets/controllers/accordion_controller.js b/src/Resources/assets/controllers/accordion_controller.js index 40afd17..eabe006 100644 --- a/src/Resources/assets/controllers/accordion_controller.js +++ b/src/Resources/assets/controllers/accordion_controller.js @@ -1,70 +1,145 @@ import { Controller } from '@hotwired/stimulus'; -import * as StickyThead from 'stickythead' export default class extends Controller { - static targets = ['content', 'arrow'] + static targets = ['header', 'content', 'arrow'] + static classes = ['arrowRotate', 'contentHidden'] - toggle(event) { + connect() { + /** @type {HTMLElement[]} */ + const headerTargets = this.headerTargets; + + headerTargets.forEach((header) => { + const isOpenDefault = header.getAttribute('aria-expanded') === 'true'; + const contents = this.getAccordionContents(header); + const arrow = this.closestChildTarget(header, 'arrow'); + + if(!arrow) { + return; + } + + // Open all accordions on load. This can be definied in araise (OPT_SUB_TABLE_COLLAPSED) + if (isOpenDefault) { + this.applyOpenState({arrow, contents}); + } + }); + } + + /** + * @param {Event} event + */ + toggle(event) + { const current = event.currentTarget; - const arrow = current.querySelector('[data-araise--table-bundle--accordion-target=arrow]'); - const isOpen = current.dataset.ariaExpanded == 'true'; - const nextSiblings = this.nextUntil(current, '[data-action="click->araise--table-bundle--accordion#toggle"]'); + const header = this.closestTarget(current, 'header'); + const arrow = this.closestTarget(current, 'arrow'); + const contents = this.getAccordionContents(header); - if(isOpen) { - arrow.classList.remove('rotate-90'); - current.dataset.ariaExpanded = 'false'; + const isOpen = header.getAttribute('aria-expanded') === 'true'; - nextSiblings.forEach((sibling) => { - sibling.classList.add('hidden'); - }); + // Open the current accordion if it wasn't open before (else we toggle it) + if (!isOpen) { + this.applyOpenState({arrow, contents, header}); } else { - arrow.classList.add('rotate-90'); - current.dataset.ariaExpanded = 'true'; + this.applyCloseState({arrow, contents, header}); + } + } + + /** + * @param {HTMLElement[]} elements + */ + applyOpenState(elements) { + const {arrow, contents, header} = elements; - nextSiblings.forEach((sibling) => { - sibling.classList.remove('hidden'); - }); + /** @type {string[]} */ + const arrowRotateClasses = this.arrowRotateClasses; + + /** @type {string[]} */ + const contentHiddenClasses = this.contentHiddenClasses; + + if(header) { + header.setAttribute('aria-expanded', 'true'); } + arrow.classList.add(...arrowRotateClasses); + contents.forEach((item) => { + item.classList.remove(...contentHiddenClasses); + }); } - /*! - * Get all following siblings of each element up to but not including the element matched by the selector - * (c) 2017 Chris Ferdinandi, MIT License, https://gomakethings.com - * @param {Node} elem The element - * @param {String} selector The selector to stop at - * @param {String} filter The selector to match siblings against [optional] - * @return {Array} The siblings + /** + * @param {HTMLElement[]} elements */ - nextUntil(elem, selector, filter) { + applyCloseState(elements) { + const {arrow, contents, header} = elements; - // Setup siblings array - var siblings = []; + /** @type {string[]} */ + const arrowRotateClasses = this.arrowRotateClasses; - // Get the next sibling element - elem = elem.nextElementSibling; + /** @type {string[]} */ + const contentHiddenClasses = this.contentHiddenClasses; - // As long as a sibling exists - while (elem) { + if(header) { + header.setAttribute('aria-expanded', 'false'); + } + arrow.classList.remove(...arrowRotateClasses); + contents.forEach((item) => { + item.classList.add(...contentHiddenClasses); + }); + } - // If we've reached our match, bail - if (elem.matches(selector)) break; + /** + * @param {HTMLElement} header + * @return {HTMLElement[]} + * + * The content block can output subtables. Those can be defined in the definition of araise. + * Multiple subtables can be defined, so we want to toggle them in groups together. + * Those are children of the header and not wrapped inside a div, that's why we return an array of elements. + */ + getAccordionContents(header) { + return this.getNextSiblingsWithClass(header, 'whatwedo_table-subtable'); + } - // If filtering by a selector, check if the sibling matches - if (filter && !elem.matches(filter)) { - elem = elem.nextElementSibling; - continue; - } + /** + * @param {HTMLElement} element + * @param {string} target + */ + closestTarget(element, target) + { + const childElement = element.closest(`[data-araise--table-bundle--accordion-target='${target}']`); + if(childElement) { + return childElement; + } - // Otherwise, push it to the siblings array - siblings.push(elem); + return element.parentElement; + } - // Get the next sibling element - elem = elem.nextElementSibling; + /** + * @param {HTMLElement|null} element + * @param {string} target + */ + closestChildTarget(element, target) { + const childElement = element.querySelector(`[data-araise--table-bundle--accordion-target='${target}']`); + if(childElement) { + return childElement; } - return siblings; + return null; + } + + /** + * @param {HTMLElement} element + * @param {string} className + */ + getNextSiblingsWithClass(element, className) { + let siblings = []; + let next = element.nextElementSibling; - }; + while (next && next.classList.contains(className)) { + siblings.push(next); + next = next.nextElementSibling; + } + + return siblings; + } } diff --git a/src/Resources/assets/controllers/table_select_controller.js b/src/Resources/assets/controllers/table_select_controller.js index d9e6e5e..c372f66 100644 --- a/src/Resources/assets/controllers/table_select_controller.js +++ b/src/Resources/assets/controllers/table_select_controller.js @@ -3,10 +3,11 @@ import 'regenerator-runtime/runtime' export default class extends Controller { - static targets = ["ids", "selector", "checkAll", "unCheckAll", "selectedCount"] + static targets = ['ids', 'selector', 'checkAll', 'unCheckAll', 'selectedCount'] static values = { footSelectedTemplate: String } + static classes = ['hideCount'] connect() { if (!this.hasIdsTarget) { @@ -18,6 +19,9 @@ export default class extends Controller { }); } + /** + * @param {Event} event + */ selectId(event) { if (!event.target.dataset.entityId) { return; @@ -46,8 +50,8 @@ export default class extends Controller { this.addId(selector.dataset.entityId); selector.checked = true; }); - this.checkAllTarget.classList.add('hidden'); - this.unCheckAllTarget.classList.remove('hidden'); + this.checkAllTarget.classList.add(this.hideCountClasses); + this.unCheckAllTarget.classList.remove(this.hideCountClasses); } unCheckAll() { @@ -55,15 +59,21 @@ export default class extends Controller { this.removeId(selector.dataset.entityId); selector.checked = false; }); - this.checkAllTarget.classList.remove('hidden'); - this.unCheckAllTarget.classList.add('hidden'); + this.checkAllTarget.classList.remove(this.hideCountClasses); + this.unCheckAllTarget.classList.add(this.hideCountClasses); } + /** + * @param {string} id + */ hasId(id) { let ids = this.getIds(); return ids.includes(id) } + /** + * @param {string} id + */ addId(id) { let ids = this.getIds(); ids.push(id); @@ -71,6 +81,9 @@ export default class extends Controller { this.updateSelectedCount(); } + /** + * @param {string} id + */ removeId(id) { let ids = this.getIds(); ids = ids.filter(function (value, index, arr) { @@ -79,21 +92,23 @@ export default class extends Controller { this.idsTarget.value = JSON.stringify(ids); this.updateSelectedCount(); if (ids.length == 0) { - this.checkAllTarget.classList.remove('hidden'); - this.unCheckAllTarget.classList.add('hidden'); + this.checkAllTarget.classList.remove(this.hideCountClasses); + this.unCheckAllTarget.classList.add(this.hideCountClasses); } } updateSelectedCount() { const count = this.getIds().length; - if (count === 0) { - this.selectedCountTarget.classList.add('hidden'); - return; - } + if(this.hasSelectedCountTarget) { + if (count === 0) { + this.selectedCountTarget.classList.add(this.hideCountClasses); + return; + } - this.selectedCountTarget.classList.remove('hidden'); - this.selectedCountTarget.innerHTML = this.footSelectedTemplateValue.replace('{count}', count); + this.selectedCountTarget.classList.remove(this.hideCountClasses); + this.selectedCountTarget.innerHTML = this.footSelectedTemplateValue.replace('{count}', count); + } } syncSelectedIds() { diff --git a/src/Resources/assets/styles/_tailwind.scss b/src/Resources/assets/styles/_tailwind.scss index 590ddca..f3391a5 100644 --- a/src/Resources/assets/styles/_tailwind.scss +++ b/src/Resources/assets/styles/_tailwind.scss @@ -41,6 +41,10 @@ @apply border-none; } + .whatwedo_table-wrapper { + @apply border-solid border-b-0 border-neutral-200; + } + .whatwedo_table-head { @apply border-b border-neutral-200; @@ -56,4 +60,4 @@ .whatwedo_table-row td:not(.whatwedo_table-actions) svg { margin: auto; -} \ No newline at end of file +} diff --git a/src/Resources/translations/messages.de.yaml b/src/Resources/translations/messages.de.yaml index 640f5a3..4ec2ab1 100644 --- a/src/Resources/translations/messages.de.yaml +++ b/src/Resources/translations/messages.de.yaml @@ -1,5 +1,6 @@ araise_table: download: + tooltip: Export info: Was möchten Sie herunterladen? choices: Export wählen all: Alle Seiten @@ -41,6 +42,7 @@ araise_table: next_page: nächste Seite last_page: letzte Seite filter: + tooltip: Filter show_element_when: Zeige Elemente wenn or: oder and: und diff --git a/src/Resources/translations/messages.en.yaml b/src/Resources/translations/messages.en.yaml index 8c7661b..4eee469 100644 --- a/src/Resources/translations/messages.en.yaml +++ b/src/Resources/translations/messages.en.yaml @@ -23,6 +23,7 @@ araise_table: options: Options no_elements: No elements available search: + title: Search filter: Filter remove_search: Remove search placeholder: Search term... diff --git a/src/Resources/views/tailwind_2/_header.html.twig b/src/Resources/views/tailwind_2/_header.html.twig index f011f68..cd14d7b 100644 --- a/src/Resources/views/tailwind_2/_header.html.twig +++ b/src/Resources/views/tailwind_2/_header.html.twig @@ -1,7 +1,15 @@ {# Table Header #} -{% if table.option('title') or view is defined and view.definition.hasCapability(constant('araise\\CrudBundle\\Enums\\Page::EXPORT')) or table.filterExtension and table.filterExtension.filters|length > 0 or table.searchExtension and table.option('searchable') %} - -
+{% if + table.option('title') + or (view is defined and table.option('definition').hasCapability(constant('araise\\CrudBundle\\Enums\\Page::EXPORT'))) + or (table.filterExtension and table.filterExtension.filters|length > 0) + or (table.searchExtension and table.option('searchable')) + or (content.createUrl(view.data) + and (content.addVoterAttribute is null or is_granted(content.addVoterAttribute, view.data)) + and view.route in content.option(constant('araise\\CrudBundle\\Content\\RelationContent::OPT_ADD_VISIBILITY')) + ) +%} +
{% if table.option('title') %}

@@ -9,14 +17,15 @@

{% endif %}
-
+
{% if table.exporters|length > 0 and table.option('definition') and table.option('definition').hasCapability(constant('araise\\CrudBundle\\Enums\\Page::EXPORT')) %}
@@ -29,31 +38,40 @@ data-transition-leave-to="opacity-0 scale-95" tabindex="-1" > -
1 %} {{ stimulus_controller('araise/table-bundle/exporter') }} {% endif %}> + {% set stimulusController = (table.exporters|length > 1) ? stimulus_controller('araise/table-bundle/exporter') : '' %} +
{% if table.exporters|length > 1 %} -

{{ 'araise_table.download.choices' | trans }}

-
-
    - {% for acronym,exporter in table.exporters %} -
  • - 1 %} {{ stimulus_action('araise/table-bundle/exporter', 'select')}} {% endif %} - > - -
  • - {% endfor %} -
-
+

{{ 'araise_table.download.choices' | trans }}

+
    + {% for acronym,exporter in table.exporters %} +
  • + {% set stimulusAction = (table.exporters|length > 1) ? stimulus_action('araise/table-bundle/exporter', 'select') : '' %} + + +
  • + {% endfor %} +
{% endif %} -

{{ 'araise_table.download.info' | trans }}

+

{{ 'araise_table.download.info' | trans }}

{% for label,queryParameters in {'araise_table.page' : app.request.query.all, 'araise_table.all' : {'all':1}|merge(app.request.query.all)} %} + {% if block is defined and content is defined %} + {% set queryParameters = queryParameters|merge({ + 'definition': view.definition.alias, + 'block': block.acronym, + 'content': content.acronym, + 'entityId': app.request.attributes.get('id'), + }) %} + {% endif %} 1 %} {{ stimulus_target('araise/table-bundle/exporter', 'link') }} {% endif %} > @@ -66,6 +84,10 @@
{% endif %} + {% if block is defined and content is defined %} + {{ _self.add_button(content, view, isOnShow) }} + {% endif %} + {% if table.filterExtension and table.filterExtension.filters|length > 0 %} @@ -91,17 +114,85 @@
-
{% endif %}
- {% endif %} + +{% macro add_button(content, view, isOnShow) %} + {% set createUrl = content.createUrl(view.data) %} + {% if createUrl + and (content.addVoterAttribute is null or is_granted(content.addVoterAttribute, view.data)) + and view.route in content.option(constant('araise\\CrudBundle\\Content\\RelationContent::OPT_ADD_VISIBILITY')) + %} + + + {% set additionalClass = (not isOnShow) ? (content.option('attr')['class'] ?? '') : '' %} + {% set additionalAttributes = (not isOnShow) ? attr|map((value, attr) => "#{attr}=\"#{value}\"")|join(' ') : '' %} + +
+ + + + +
+ {% endif %} +{% endmacro %} + diff --git a/src/Resources/views/tailwind_2/_table.html.twig b/src/Resources/views/tailwind_2/_table.html.twig index 7c9465a..4717c37 100644 --- a/src/Resources/views/tailwind_2/_table.html.twig +++ b/src/Resources/views/tailwind_2/_table.html.twig @@ -62,8 +62,9 @@
- + {% if tableActionsVisible|length > 1 %} + {% else %} + {% set action = tableActionsVisible|first %} + "#{attr}=\"#{value}\"")|join(' ')|raw }} + > + + {% if action.option('icon') %} + {{ bootstrap_icon(action.icon, { class: 'h-4 w-4' }) }} + {% else %} + {{ bootstrap_icon('link-45deg', { class: 'h-4 w-4' }) }} + {% endif %} + {{ action.label|trans }} + + + {% endif %} + {% endif %} {% endif %} @@ -255,12 +294,34 @@ class="whatwedo_table-subtable hidden" {{ stimulus_target('araise/table-bundle/accordion', 'content') }} > - + + {% with {'table':subTable} only %}{{ araise_table_only_render(table) }}{% endwith %} {% endfor %} {% endfor %} + {% block footer_columns %} + {% if table.footerColumns is not null %} + + + {% for footerColumn in table.footerColumns %} + {% set attr = footerColumn.option('attributes')|default([])|filter((k,i) => k != 'class') %} + "#{attr}=\"#{value}\"")|join(' ')|raw }} + > + {{ araise_table_column_render(footerColumn, table.footerData) }} + + {% endfor %} + + {% if table.actions|length %} + + {% endif %} + + + {% endif %} + {% endblock %} diff --git a/src/Resources/views/tailwind_2_layout.html.twig b/src/Resources/views/tailwind_2_layout.html.twig index 1b8e1c7..bb99993 100644 --- a/src/Resources/views/tailwind_2_layout.html.twig +++ b/src/Resources/views/tailwind_2_layout.html.twig @@ -4,12 +4,17 @@ {% include "@araiseTable/tailwind_2/_filter.html.twig" %} {% endblock %} -
' ~ ('araise_table.foot.selected'|trans({ - '{count}': '{count}', - })) - }) }} +
' ~ ('araise_table.foot.selected'|trans({ + '{count}': '{count}', + })) + }, + { + 'hideCount': 'hidden' + } + ) }} > {% block table_header %} @@ -265,7 +270,7 @@ {{ stimulus_action('araise/core-bundle/modal-form', 'close', 'click') }} >
- {# This element is to trick the browser into centering the modal contents. #} + {# This element is to trick the browser into centering the modal contents. Code is coming from Tailwind UI #}
diff --git a/src/Table/Table.php b/src/Table/Table.php index e90b041..70dd07e 100644 --- a/src/Table/Table.php +++ b/src/Table/Table.php @@ -57,8 +57,14 @@ class Table public const OPT_SUB_TABLE_LOADER = 'sub_table_loader'; + public const OPT_SUB_TABLE_COLLAPSED = 'sub_table_collapsed'; + protected array $columns = []; + protected ?array $footerColumns = null; + + protected mixed $footerData = null; + protected array $actions = []; protected array $batchActions = []; @@ -112,6 +118,7 @@ public function configureOptions(OptionsResolver $resolver): void self::OPT_DEFINITION => null, self::OPT_DATALOADER_OPTIONS => [], self::OPT_SUB_TABLE_LOADER => null, + self::OPT_SUB_TABLE_COLLAPSED => true, ]); $resolver->setAllowedTypes(self::OPT_TITLE, ['null', 'string']); @@ -129,6 +136,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes(self::OPT_SUB_TABLE_LOADER, ['null', 'callable']); $resolver->setRequired(self::OPT_DATA_LOADER); $resolver->setAllowedTypes(self::OPT_DATA_LOADER, allowedTypes: DataLoaderInterface::class); + $resolver->setAllowedTypes(self::OPT_SUB_TABLE_COLLAPSED, ['boolean', 'callable']); } public function getSubTables(object|array $row): array @@ -168,6 +176,14 @@ public function getPrimaryLink(object|array $row) return is_callable($this->options[self::OPT_PRIMARY_LINK]) ? $this->options[self::OPT_PRIMARY_LINK]($row) : null; } + public function getSubTableCollapsed(object|array $row): bool + { + if (is_callable($this->options[self::OPT_SUB_TABLE_COLLAPSED])) { + return $this->options[self::OPT_SUB_TABLE_COLLAPSED]($row); + } + return $this->options[self::OPT_SUB_TABLE_COLLAPSED]; + } + public function setOption(string $key, $value): static { $resolver = new OptionsResolver(); @@ -193,33 +209,38 @@ public function getColumns(): array return $this->columns; } - public function addColumn(string $acronym, $type = null, array $options = [], ?int $position = null): static + /** + * @return Column[] + */ + public function getFooterColumns(): ?array { - if ($type === null) { - $type = Column::class; + if ($this->footerColumns === null) { + return null; } + uasort($this->footerColumns, fn (Column $a, Column $b) => $b->getOption(Column::OPT_PRIORITY) <=> $a->getOption(Column::OPT_PRIORITY)); - if ($this->options[self::OPT_DEFINITION]) { - if (!isset($options[Column::OPT_LABEL])) { - $options[Column::OPT_LABEL] = sprintf('wwd.%s.property.%s', $this->options[self::OPT_DEFINITION]->getEntityAlias(), $acronym); - } - } + return $this->footerColumns; + } - // set link_the_column_content on first column if not set - if (!isset($options[Column::OPT_LINK_THE_COLUMN_CONTENT]) && count($this->columns) === 0) { - $options[Column::OPT_LINK_THE_COLUMN_CONTENT] = true; + public function addColumn(string $acronym, $type = null, array $options = [], ?int $position = null): static + { + $column = $this->internalAddColumn($acronym, $type, $options); + if ($position === null) { + $this->columns[$acronym] = $column; + } else { + $this->insertColumnAtPosition($this->columns, $acronym, $column, $position); } - $column = new $type($this, $acronym, $options); - - if ($column instanceof FormattableColumnInterface) { - $column->setFormatterManager($this->formatterManager); - } + return $this; + } + public function addFooterColumn(string $acronym, $type = null, array $options = [], ?int $position = null): static + { + $column = $this->internalAddColumn($acronym, $type, $options, false); if ($position === null) { - $this->columns[$acronym] = $column; + $this->footerColumns[$acronym] = $column; } else { - $this->insertColumnAtPosition($acronym, $column, $position); + $this->insertColumnAtPosition($this->footerColumns, $acronym, $column, $position); } return $this; @@ -232,6 +253,13 @@ public function removeColumn($acronym): static return $this; } + public function removeFooterColumn($acronym): static + { + unset($this->footerColumns[$acronym]); + + return $this; + } + /** * @return Action[] */ @@ -355,6 +383,18 @@ public function getExtension(string $extension): ExtensionInterface return $this->extensions[$extension]->setTable($this); } + public function getFooterData(): mixed + { + return $this->footerData; + } + + public function setFooterData(mixed $footerData): static + { + $this->footerData = $footerData; + + return $this; + } + public function removeExtension(string $extension): void { unset($this->extensions[$extension]); @@ -439,12 +479,12 @@ protected function loadData(): void $this->eventDispatcher->dispatch(new DataLoadEvent($this), DataLoadEvent::POST_LOAD); } - protected function insertColumnAtPosition($key, $value, $position) + protected function insertColumnAtPosition(&$columns, $key, $value, $position) { $newArray = []; $added = false; $i = 0; - foreach ($this->columns as $elementsAcronym => $elementsElement) { + foreach ($columns as $elementsAcronym => $elementsElement) { if ($position === $i) { $newArray[$key] = $value; $added = true; @@ -455,7 +495,33 @@ protected function insertColumnAtPosition($key, $value, $position) if (! $added) { $newArray[$key] = $value; } - $this->columns = $newArray; + $columns = $newArray; + } + + private function internalAddColumn(string $acronym, $type = null, array $options = [], bool $mainColumns = true): Column + { + if ($type === null) { + $type = Column::class; + } + + if ($this->options[self::OPT_DEFINITION] && $mainColumns) { + if (!isset($options[Column::OPT_LABEL])) { + $options[Column::OPT_LABEL] = sprintf('wwd.%s.property.%s', $this->options[self::OPT_DEFINITION]->getEntityAlias(), $acronym); + } + } + + // set link_the_column_content on first column if not set + if (!isset($options[Column::OPT_LINK_THE_COLUMN_CONTENT]) && count($this->columns) === 0 && $mainColumns) { + $options[Column::OPT_LINK_THE_COLUMN_CONTENT] = true; + } + + $column = new $type($this, $acronym, $options); + + if ($column instanceof FormattableColumnInterface) { + $column->setFormatterManager($this->formatterManager); + } + + return $column; } /** diff --git a/tests/App/Entity/Category.php b/tests/App/Entity/Category.php index 0f9d97d..468f4f5 100644 --- a/tests/App/Entity/Category.php +++ b/tests/App/Entity/Category.php @@ -79,7 +79,7 @@ public function getLevel(): int return $this->lvl; } - public function setParent(self $parent = null): void + public function setParent(?self $parent = null): void { $this->parent = $parent; } diff --git a/tests/App/Factory/CategoryFactory.php b/tests/App/Factory/CategoryFactory.php index 09f89c9..ac835fc 100644 --- a/tests/App/Factory/CategoryFactory.php +++ b/tests/App/Factory/CategoryFactory.php @@ -5,37 +5,22 @@ namespace araise\TableBundle\Tests\App\Factory; use araise\TableBundle\Tests\App\Entity\Category; -use Zenstruck\Foundry\ModelFactory; -use Zenstruck\Foundry\Proxy; -use Zenstruck\Foundry\RepositoryProxy; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @method static Category|Proxy createOne(array $attributes = []) - * @method static Category[]|Proxy[] createMany(int $number, $attributes = []) - * @method static Category|Proxy find($criteria) - * @method static Category|Proxy findOrCreate(array $attributes) - * @method static Category|Proxy first(string $sortedField = 'id') - * @method static Category|Proxy last(string $sortedField = 'id') - * @method static Category|Proxy random(array $attributes = []) - * @method static Category|Proxy randomOrCreate(array $attributes = []) - * @method static Category[]|Proxy[] all() - * @method static Category[]|Proxy[] findBy(array $attributes) - * @method static Category[]|Proxy[] randomSet(int $number, array $attributes = []) - * @method static Category[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) - * @method static CategoryRepository|RepositoryProxy repository() - * @method Category|Proxy create($attributes = []) + * @extends PersistentObjectFactory */ -final class CategoryFactory extends ModelFactory +final class CategoryFactory extends PersistentObjectFactory { - protected function getDefaults(): array + public static function class(): string { - return [ - 'name' => self::faker()->company(), - ]; + return Category::class; } - protected static function getClass(): string + protected function defaults(): array { - return Category::class; + return [ + 'name' => self::faker()->company(), + ]; } } diff --git a/tests/App/Factory/CompanyFactory.php b/tests/App/Factory/CompanyFactory.php index 8af7935..bbde141 100644 --- a/tests/App/Factory/CompanyFactory.php +++ b/tests/App/Factory/CompanyFactory.php @@ -5,30 +5,19 @@ namespace araise\TableBundle\Tests\App\Factory; use araise\TableBundle\Tests\App\Entity\Company; -use araise\TableBundle\Tests\App\Repository\CompanyRepository; -use Zenstruck\Foundry\ModelFactory; -use Zenstruck\Foundry\Proxy; -use Zenstruck\Foundry\RepositoryProxy; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @method static Company|Proxy createOne(array $attributes = []) - * @method static Company[]|Proxy[] createMany(int $number, $attributes = []) - * @method static Company|Proxy find($criteria) - * @method static Company|Proxy findOrCreate(array $attributes) - * @method static Company|Proxy first(string $sortedField = 'id') - * @method static Company|Proxy last(string $sortedField = 'id') - * @method static Company|Proxy random(array $attributes = []) - * @method static Company|Proxy randomOrCreate(array $attributes = []) - * @method static Company[]|Proxy[] all() - * @method static Company[]|Proxy[] findBy(array $attributes) - * @method static Company[]|Proxy[] randomSet(int $number, array $attributes = []) - * @method static Company[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) - * @method static CompanyRepository|RepositoryProxy repository() - * @method Company|Proxy create($attributes = []) + * @extends PersistentObjectFactory */ -final class CompanyFactory extends ModelFactory +final class CompanyFactory extends PersistentObjectFactory { - protected function getDefaults(): array + public static function class(): string + { + return Company::class; + } + + protected function defaults(): array { return [ 'name' => self::faker()->company(), @@ -37,9 +26,4 @@ protected function getDefaults(): array 'taxIdentificationNumber' => self::faker()->numerify(self::faker()->countryCode().'###.####.###.#.###.##'), ]; } - - protected static function getClass(): string - { - return Company::class; - } } diff --git a/tests/App/Factory/ContactFactory.php b/tests/App/Factory/ContactFactory.php index c73e7c4..21b35ef 100644 --- a/tests/App/Factory/ContactFactory.php +++ b/tests/App/Factory/ContactFactory.php @@ -5,39 +5,23 @@ namespace araise\TableBundle\Tests\App\Factory; use araise\TableBundle\Tests\App\Entity\Contact; -use araise\TableBundle\Tests\App\Repository\ContactRepository; -use Zenstruck\Foundry\ModelFactory; -use Zenstruck\Foundry\Proxy; -use Zenstruck\Foundry\RepositoryProxy; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @method static Contact|Proxy createOne(array $attributes = []) - * @method static Contact[]|Proxy[] createMany(int $number, $attributes = []) - * @method static Contact|Proxy find($criteria) - * @method static Contact|Proxy findOrCreate(array $attributes) - * @method static Contact|Proxy first(string $sortedField = 'id') - * @method static Contact|Proxy last(string $sortedField = 'id') - * @method static Contact|Proxy random(array $attributes = []) - * @method static Contact|Proxy randomOrCreate(array $attributes = []) - * @method static Contact[]|Proxy[] all() - * @method static Contact[]|Proxy[] findBy(array $attributes) - * @method static Contact[]|Proxy[] randomSet(int $number, array $attributes = []) - * @method static Contact[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) - * @method static ContactRepository|RepositoryProxy repository() - * @method Contact|Proxy create($attributes = []) + * @extends PersistentObjectFactory */ -final class ContactFactory extends ModelFactory +final class ContactFactory extends PersistentObjectFactory { - protected function getDefaults(): array + public static function class(): string + { + return Contact::class; + } + + protected function defaults(): array { return [ 'name' => self::faker()->name(), 'company' => CompanyFactory::randomOrCreate(), ]; } - - protected static function getClass(): string - { - return Contact::class; - } } diff --git a/tests/App/Factory/PersonFactory.php b/tests/App/Factory/PersonFactory.php index c80242e..46085eb 100644 --- a/tests/App/Factory/PersonFactory.php +++ b/tests/App/Factory/PersonFactory.php @@ -5,38 +5,22 @@ namespace araise\TableBundle\Tests\App\Factory; use araise\TableBundle\Tests\App\Entity\Person; -use araise\TableBundle\Tests\App\Repository\PersonRepository; -use Zenstruck\Foundry\ModelFactory; -use Zenstruck\Foundry\Proxy; -use Zenstruck\Foundry\RepositoryProxy; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; /** - * @method static Person|Proxy createOne(array $attributes = []) - * @method static Person[]|Proxy[] createMany(int $number, $attributes = []) - * @method static Person|Proxy find($criteria) - * @method static Person|Proxy findOrCreate(array $attributes) - * @method static Person|Proxy first(string $sortedField = 'id') - * @method static Person|Proxy last(string $sortedField = 'id') - * @method static Person|Proxy random(array $attributes = []) - * @method static Person|Proxy randomOrCreate(array $attributes = []) - * @method static Person[]|Proxy[] all() - * @method static Person[]|Proxy[] findBy(array $attributes) - * @method static Person[]|Proxy[] randomSet(int $number, array $attributes = []) - * @method static Person[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) - * @method static PersonRepository|RepositoryProxy repository() - * @method Person|Proxy create($attributes = []) + * @extends PersistentObjectFactory */ -final class PersonFactory extends ModelFactory +final class PersonFactory extends PersistentObjectFactory { - protected function getDefaults(): array + public static function class(): string { - return [ - 'name' => self::faker()->name(), - ]; + return Person::class; } - protected static function getClass(): string + protected function defaults(): array { - return Person::class; + return [ + 'name' => self::faker()->name(), + ]; } } diff --git a/tests/App/config/packages/doctrine.yaml b/tests/App/config/packages/doctrine.yaml index 4f39710..296e446 100644 --- a/tests/App/config/packages/doctrine.yaml +++ b/tests/App/config/packages/doctrine.yaml @@ -4,7 +4,7 @@ doctrine: connections: default: # configure these for your database server - driver: 'pdo_sqlite' + driver: '%env(resolve:DATABASE_DRIVER)%' charset: utf8mb4 default_table_options: charset: utf8mb4 diff --git a/tests/HierarchicalEntityTest.php b/tests/HierarchicalEntityTest.php index 169a7a2..8cc9399 100644 --- a/tests/HierarchicalEntityTest.php +++ b/tests/HierarchicalEntityTest.php @@ -21,13 +21,13 @@ public function testCreateEntity() /** @var Category $category */ $category = CategoryFactory::createOne([ 'name' => 'Level 1', - ])->object(); + ]); /** @var Category $subcategory */ $subcategory = CategoryFactory::createOne([ 'name' => 'Level 2', 'parent' => $category, - ])->object(); + ]); $this->assertSame($category, $subcategory->getParent()); $this->assertSame(0, $category->getLevel());