diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index a8f5a713..78da69ec 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -50,6 +50,7 @@ export default defineConfig({ { text: 'Exporting', link: '/docs/features/exporting' }, { text: 'Pagination', link: '/docs/features/pagination' }, { text: 'Personalization', link: '/docs/features/personalization' }, + { text: 'Column visibility groups', link: '/docs/features/column-visibility-group' }, { text: 'Persistence', link: '/docs/features/persistence' }, { text: 'Theming', link: '/docs/features/theming' }, { text: 'Asynchronicity', link: '/docs/features/asynchronicity' }, diff --git a/docs/src/docs/features/column-visibility-group.md b/docs/src/docs/features/column-visibility-group.md new file mode 100644 index 00000000..ba7b7d7b --- /dev/null +++ b/docs/src/docs/features/column-visibility-group.md @@ -0,0 +1,227 @@ +# Column Visibility Groups + +Column visibility groups let you define several **predefined views** over the same data table. +Each group is a curated subset of columns, and the user switches between them via a dropdown rendered above the table. +Typical use cases: showing "general" vs "details" views on a wide table, or giving different roles their own column sets. + +::: tip Visibility groups vs personalization +Visibility groups are for **curated, designer-controlled** column sets defined in code. The +[personalization feature](personalization.md) is for **user-controlled** column selection saved per +user. The two features compose (see [Interactions](#interactions-with-other-features)) but serve +different intents. +::: + +[[toc]] + +## Basic Usage + +Define groups with `$builder->addColumnVisibilityGroup()`, then opt columns into one or more groups +with the `column_visibility_groups` column option. + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Column\Type\NumberColumnType; +use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; + +class CustomerDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addColumnVisibilityGroup('general', ['is_default' => true]) + ->addColumnVisibilityGroup('address', ['label' => 'Address details']) + ; + + $builder + // No "column_visibility_groups" option: always visible, in every group. + ->addColumn('id', NumberColumnType::class) + ->addColumn('name', TextColumnType::class) + + // Visible only when the "general" group is selected. + ->addColumn('email', TextColumnType::class, [ + 'column_visibility_groups' => ['general'], + ]) + + // Visible only when the "address" group is selected. + ->addColumn('streetName', TextColumnType::class, [ + 'column_visibility_groups' => ['address'], + ]) + ; + } +} +``` + +As soon as a data table has at least one visibility group, a `` with nothing to switch to. Define at least two +groups — or don't define any and let the default "all columns visible" behavior do the job. +::: + +## Defining groups + +Each call to `addColumnVisibilityGroup($name, $options)` adds one group. The `$name` must be unique +within the data table and is used as the value in the URL query parameter. + +The following options are accepted: + +| Option | Type | Default | Description | +|--------------|------------------|----------|------------------------------------------------------------------------------| +| `label` | `null \| string` | `null` | Display name used in the dropdown. Falls back to the group `$name` if null. | +| `is_default` | `bool` | `false` | Marks this group as the default selection when no URL parameter is present. | + +::: warning Only one default group +At most **one** group may be marked as `is_default: true`. Setting two or more throws an +`InvalidArgumentException` the first time the data table is built (via `getDataTable()` or during +rendering) with a message listing the offending groups. +::: + +::: tip Fallback when no default is set +If no group is marked as default, the **first group defined** via `addColumnVisibilityGroup()` is +used as the default. +::: + +## Assigning columns to groups + +Use the `column_visibility_groups` option on `addColumn()`: + +```php +$builder + // Belongs to no group — always visible. + ->addColumn('id', NumberColumnType::class) + + // Shorthand: a single group name as a string. + ->addColumn('email', TextColumnType::class, [ + 'column_visibility_groups' => 'general', + ]) + + // Multiple groups: the column appears in any of them. + ->addColumn('phone', TextColumnType::class, [ + 'column_visibility_groups' => ['general', 'contact'], + ]) +; +``` + +The option accepts `null`, a `string`, or an `array` of strings. `null` or `[]` means the column +does not belong to any group and is visible regardless of the selected group. + +::: warning Unknown group names throw +If a column references a group name that was never defined with `addColumnVisibilityGroup()`, +`getDataTable()` throws an `InvalidArgumentException` listing the offending column, the unknown +group, and the groups that are actually defined. This catches typos at build time rather than +producing a silently hidden column. +::: + +## Default group resolution + +When the request arrives, the selected group is resolved in this order: + +1. The value of the URL query parameter if it matches a defined group name +2. Otherwise the group marked `is_default: true` +3. Otherwise the first group defined via `addColumnVisibilityGroup()` + +An invalid group name in the query parameter is treated as if no parameter was provided +(it falls through to step 2, then step 3). + +## URL query parameter + +The selected group is persisted in the URL so it survives refreshes and can be shared. +The parameter is `column_visibility_group` optionally suffixed with an underscore and the data table +name: + +``` +?column_visibility_group_=address +``` + +The data table name comes from `$dataTable->getConfig()->getName()`. By default it is derived from +the data table type's class name, stripped of its `DataTableType` suffix and converted to snake +case — `CustomerDataTableType` becomes `customer`, so the parameter is `column_visibility_group_customer`. +If the data table has no name, the parameter falls back to plain `column_visibility_group`. + +You can always read the exact parameter name from +`$dataTable->getConfig()->getColumnVisibilityGroupParameterName()` when you need to build URLs yourself. + +## Translating group labels + +Group labels are translated at build time using the default Symfony translator. If `label` is `null`, +the group `$name` is passed to the translator instead — so a technical name like `address` ends up +rendered in the UI if no translation exists for it. + +```php +$builder->addColumnVisibilityGroup('address', [ + 'label' => 'customer.visibility.address', +]); +``` + +::: warning Translation domain +The label is translated using the **default catalog** (typically `messages`), not the +`translation_domain` configured on the data table. Make sure your translation keys live in that +catalog, or pass a pre-translated string directly. +::: + +## Interactions with other features + +### Personalization + +Personalization and visibility groups compose asymmetrically: + +- If personalization **hides** a column, it stays hidden regardless of the selected group. + Manual personalization wins. +- If personalization **shows** a column that the selected group excludes, the group still hides it. + The group wins. + +In other words, a column is visible only if both personalization and the selected group agree it +should be. + +### Sorting + +A column that is hidden — whether by personalization or because it does not belong to the selected +group — is removed from the header row, so its sort link is not rendered. Existing sort state on +a hidden column has no visible effect until the column becomes visible again. + +### Filtering + +::: warning Filters on hidden columns stay active +Switching to a group that does not contain a previously filtered column does **not** clear the +filter. The underlying query is still filtered, but the user has no visual indication of which +column is filtering the results. If this matters for your UX, clear filters explicitly when +switching groups. +::: + +### Pagination and sorting state + +When the user changes the selected group, the current `page`, `limit`, `sort`, and filter values +are re-submitted as hidden form inputs. The user stays on the same page with the same sort and +filters applied — only the visible columns change. + +## Customizing the UI + +The dropdown is rendered by the `column_visibility_group_selector` block defined in +`@KreyuDataTable/themes/base.html.twig`. Override it in your own theme to change the markup or +styling: + +```twig +{# templates/data_table/my_theme.html.twig #} +{% extends '@KreyuDataTable/themes/bootstrap_5.html.twig' %} + +{% block column_visibility_group_selector %} + {% if data_table.columnVisibilityGroups is not empty %} +
+ {{ parent() }} +
+ {% endif %} +{% endblock %} +``` + +The block receives: + +- `data_table.columnVisibilityGroups` — an array of `ColumnVisibilityGroupView` with `name`, + `label`, `isDefault`, and `isSelected` public properties +- `data_table.vars.column_visibility_group_parameter_name` — the query parameter name +- `data_table.vars.url_query_parameters` — the current URL state (shared with pagination, sorting, + and filtering); re-emitted as hidden inputs to preserve page/sort/filter on group change + +A companion block, `column_visibility_group_selector_hidden_input`, recursively renders the +hidden inputs for nested query parameters (filters in particular). diff --git a/src/Column/Type/ColumnType.php b/src/Column/Type/ColumnType.php index 119f8bdc..eaf9fedd 100755 --- a/src/Column/Type/ColumnType.php +++ b/src/Column/Type/ColumnType.php @@ -330,6 +330,12 @@ public function configureOptions(OptionsResolver $resolver): void ->allowedTypes('bool') ->info('Defines whether the column can be personalized by the user in personalization feature.') ; + + $resolver->define('column_visibility_groups') + ->default(null) + ->allowedTypes('null', 'string', 'array') + ->info('Defines the visibility groups of a column.') + ; } public function getBlockPrefix(): string diff --git a/src/ColumnVisibilityGroup/ColumnVisibilityGroup.php b/src/ColumnVisibilityGroup/ColumnVisibilityGroup.php new file mode 100644 index 00000000..714a522b --- /dev/null +++ b/src/ColumnVisibilityGroup/ColumnVisibilityGroup.php @@ -0,0 +1,30 @@ +name; + } + + public function getLabel(): string + { + return $this->label; + } + + public function isDefault(): bool + { + return $this->isDefault; + } +} diff --git a/src/ColumnVisibilityGroup/ColumnVisibilityGroupFactory.php b/src/ColumnVisibilityGroup/ColumnVisibilityGroupFactory.php new file mode 100644 index 00000000..09a7c18c --- /dev/null +++ b/src/ColumnVisibilityGroup/ColumnVisibilityGroupFactory.php @@ -0,0 +1,41 @@ +configureOptions($optionsResolver); + + $resolvedOptions = $optionsResolver->resolve($options); + + return new ColumnVisibilityGroup( + name: $name, + label: $this->translator->trans($resolvedOptions['label'] ?? $name), + isDefault: $resolvedOptions['is_default'], + ); + } + + private function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => null, + 'is_default' => false, + ]); + + $resolver->setAllowedTypes('label', ['null', 'string']); + $resolver->setAllowedTypes('is_default', 'bool'); + } +} diff --git a/src/ColumnVisibilityGroup/ColumnVisibilityGroupFactoryInterface.php b/src/ColumnVisibilityGroup/ColumnVisibilityGroupFactoryInterface.php new file mode 100644 index 00000000..df5a4179 --- /dev/null +++ b/src/ColumnVisibilityGroup/ColumnVisibilityGroupFactoryInterface.php @@ -0,0 +1,10 @@ + + */ + private array $columnVisibilityGroups = []; + /** * The sorting data currently applied to the data table. */ @@ -113,6 +119,7 @@ class DataTable implements DataTableInterface private bool $initialized = false; private ?string $turboFrameId = null; + private ?string $requestedColumnVisibilityGroup = null; public function __construct( private ProxyQueryInterface $query, @@ -202,6 +209,14 @@ public function getVisibleColumns(): array $visible = $this->personalizationData?->getColumn($column)?->isVisible() ?? $visible; } + if ($visible && null !== $this->requestedColumnVisibilityGroup) { + $groups = (array) $column->getConfig()->getOption('column_visibility_groups'); + + if (!empty($groups)) { + $visible = in_array($this->requestedColumnVisibilityGroup, $groups, true); + } + } + return $visible; }); } @@ -508,6 +523,18 @@ public function removeExporter(string $name): static return $this; } + public function addColumnVisibilityGroup(ColumnVisibilityGroupInterface $columnVisibilityGroup): static + { + $this->columnVisibilityGroups[$columnVisibilityGroup->getName()] = $columnVisibilityGroup; + + return $this; + } + + public function getColumnVisibilityGroups(): array + { + return $this->columnVisibilityGroups; + } + public function paginate(PaginationData $data, bool $persistence = true): void { if (!$this->config->isPaginationEnabled()) { @@ -880,6 +907,18 @@ public function isRequestFromTurboFrame(): bool return null !== $this->turboFrameId && 'kreyu_data_table_'.$this->getName() === $this->turboFrameId; } + public function setRequestedColumnVisibilityGroup(?string $requestedColumnVisibilityGroup): self + { + $this->requestedColumnVisibilityGroup = $requestedColumnVisibilityGroup; + + return $this; + } + + public function getRequestedColumnVisibilityGroup(): ?string + { + return $this->requestedColumnVisibilityGroup; + } + private function dispatch(string $eventName, DataTableEvent $event): void { $dispatcher = $this->config->getEventDispatcher(); diff --git a/src/DataTableBuilder.php b/src/DataTableBuilder.php index 8f49c975..dda36574 100755 --- a/src/DataTableBuilder.php +++ b/src/DataTableBuilder.php @@ -13,6 +13,7 @@ use Kreyu\Bundle\DataTableBundle\Column\Type\CheckboxColumnType; use Kreyu\Bundle\DataTableBundle\Column\Type\ColumnTypeInterface; use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupInterface; use Kreyu\Bundle\DataTableBundle\Exception\BadMethodCallException; use Kreyu\Bundle\DataTableBundle\Exception\InvalidArgumentException; use Kreyu\Bundle\DataTableBundle\Exporter\ExporterBuilderInterface; @@ -136,6 +137,20 @@ class DataTableBuilder extends DataTableConfigBuilder implements DataTableBuilde */ private array $unresolvedExporters = []; + /** + * The column visibility groups defined for the data table. + * + * @var array + */ + private array $columnVisibilityGroups = []; + + /** + * The data of column visibility groups that haven't been converted to column visibility group yet. + * + * @var array + */ + private array $unresolvedColumnVisibilityGroups = []; + public function __construct( string $name, ResolvedDataTableTypeInterface $type, @@ -715,6 +730,55 @@ public function removeExporter(string $name): static return $this; } + public function addColumnVisibilityGroup(ColumnVisibilityGroupInterface|string $columnVisibilityGroup, array $options = []): static + { + if ($this->locked) { + throw $this->createBuilderLockedException(); + } + + if ($columnVisibilityGroup instanceof ColumnVisibilityGroupInterface) { + $this->columnVisibilityGroups[$columnVisibilityGroup->getName()] = $columnVisibilityGroup; + + unset($this->unresolvedColumnVisibilityGroups[$columnVisibilityGroup->getName()]); + + return $this; + } + + $this->columnVisibilityGroups[$columnVisibilityGroup] = null; + $this->unresolvedColumnVisibilityGroups[$columnVisibilityGroup] = $options; + + return $this; + } + + public function hasColumnVisibilityGroup(string $name): bool + { + if ($this->locked) { + throw $this->createBuilderLockedException(); + } + + return isset($this->columnVisibilityGroups[$name]) || isset($this->unresolvedColumnVisibilityGroups[$name]); + } + + public function removeColumnVisibilityGroup(string $name): static + { + if ($this->locked) { + throw $this->createBuilderLockedException(); + } + + unset($this->unresolvedColumnVisibilityGroups[$name], $this->columnVisibilityGroups[$name]); + + return $this; + } + + public function createColumnVisibilityGroup(string $name, array $options = []): ColumnVisibilityGroupInterface + { + if ($this->locked) { + throw $this->createBuilderLockedException(); + } + + return $this->getColumnVisibilityGroupFactory()->create($name, $options); + } + public function getDataTable(): DataTableInterface { if ($this->locked) { @@ -745,6 +809,12 @@ public function getDataTable(): DataTableInterface $dataTable->addColumn($column->getColumn()); } + $this->resolveColumnVisibilityGroups(); + + foreach ($this->columnVisibilityGroups as $columnVisibilityGroup) { + $dataTable->addColumnVisibilityGroup($columnVisibilityGroup); + } + $this->resolveFilters(); foreach ($this->filters as $filter) { @@ -883,6 +953,43 @@ private function resolveExporters(): void } } + private function resolveColumnVisibilityGroups(): void + { + foreach (array_keys($this->unresolvedColumnVisibilityGroups) as $name) { + $this->resolveColumnVisibilityGroup($name); + } + + $defaults = array_filter( + $this->columnVisibilityGroups, + static fn (ColumnVisibilityGroupInterface $group) => $group->isDefault(), + ); + + if (count($defaults) > 1) { + throw new InvalidArgumentException(sprintf('Only one column visibility group can be marked as default, but %d were found: "%s".', count($defaults), implode('", "', array_keys($defaults)))); + } + + foreach ($this->columns as $column) { + $referenced = (array) $column->getColumnConfig()->getOption('column_visibility_groups'); + + foreach ($referenced as $groupName) { + if (!isset($this->columnVisibilityGroups[$groupName])) { + throw new InvalidArgumentException(sprintf('Column "%s" references the column visibility group "%s", but no such group has been defined on the data table. Available groups: %s.', $column->getName(), $groupName, $this->columnVisibilityGroups ? '"'.implode('", "', array_keys($this->columnVisibilityGroups)).'"' : 'none')); + } + } + } + } + + private function resolveColumnVisibilityGroup(string $name): ColumnVisibilityGroupInterface + { + $options = $this->unresolvedColumnVisibilityGroups[$name]; + + unset($this->unresolvedColumnVisibilityGroups[$name]); + + $columnVisibilityGroup = $this->getColumnVisibilityGroupFactory()->create($name, $options); + + return $this->columnVisibilityGroups[$name] = $columnVisibilityGroup; + } + private function shouldPrependBatchCheckboxColumn(): bool { return $this->isAutoAddingBatchCheckboxColumn() diff --git a/src/DataTableBuilderInterface.php b/src/DataTableBuilderInterface.php index 803bde4f..ff9d1797 100755 --- a/src/DataTableBuilderInterface.php +++ b/src/DataTableBuilderInterface.php @@ -9,6 +9,7 @@ use Kreyu\Bundle\DataTableBundle\Column\ColumnBuilderInterface; use Kreyu\Bundle\DataTableBundle\Column\Type\ActionsColumnType; use Kreyu\Bundle\DataTableBundle\Column\Type\ColumnTypeInterface; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupInterface; use Kreyu\Bundle\DataTableBundle\Exception\InvalidArgumentException; use Kreyu\Bundle\DataTableBundle\Exporter\ExporterBuilderInterface; use Kreyu\Bundle\DataTableBundle\Exporter\Type\ExporterTypeInterface; @@ -196,4 +197,12 @@ public function getQuery(): ?ProxyQueryInterface; public function setQuery(?ProxyQueryInterface $query): static; public function getDataTable(): DataTableInterface; + + public function addColumnVisibilityGroup(ColumnVisibilityGroupInterface|string $columnVisibilityGroup, array $options = []): static; + + public function removeColumnVisibilityGroup(string $name): static; + + public function hasColumnVisibilityGroup(string $name): bool; + + public function createColumnVisibilityGroup(string $name, array $options = []): ColumnVisibilityGroupInterface; } diff --git a/src/DataTableConfigBuilder.php b/src/DataTableConfigBuilder.php index 4e65af54..270b40bd 100755 --- a/src/DataTableConfigBuilder.php +++ b/src/DataTableConfigBuilder.php @@ -6,6 +6,7 @@ use Kreyu\Bundle\DataTableBundle\Action\ActionFactoryInterface; use Kreyu\Bundle\DataTableBundle\Column\ColumnFactoryInterface; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupFactoryInterface; use Kreyu\Bundle\DataTableBundle\Exception\BadMethodCallException; use Kreyu\Bundle\DataTableBundle\Exporter\ExportData; use Kreyu\Bundle\DataTableBundle\Exporter\ExporterFactoryInterface; @@ -60,6 +61,7 @@ class DataTableConfigBuilder implements DataTableConfigBuilderInterface private ?FilterFactoryInterface $filterFactory = null; private ?ActionFactoryInterface $actionFactory = null; private ?ExporterFactoryInterface $exporterFactory = null; + private ?ColumnVisibilityGroupFactoryInterface $columnVisibilityGroupFactory = null; private ?RequestHandlerInterface $requestHandler = null; private bool $sortingClearable = false; @@ -191,6 +193,26 @@ public function setColumnFactory(ColumnFactoryInterface $columnFactory): static return $this; } + public function getColumnVisibilityGroupFactory(): ColumnVisibilityGroupFactoryInterface + { + if (!isset($this->columnVisibilityGroupFactory)) { + throw new BadMethodCallException(sprintf('The column visibility group factory is not set, use the "%s::setColumnVisibilityGroupFactory()" method to set the factory.', $this::class)); + } + + return $this->columnVisibilityGroupFactory; + } + + public function setColumnVisibilityGroupFactory(ColumnVisibilityGroupFactoryInterface $columnVisibilityGroupFactory): static + { + if ($this->locked) { + throw $this->createBuilderLockedException(); + } + + $this->columnVisibilityGroupFactory = $columnVisibilityGroupFactory; + + return $this; + } + public function getFilterFactory(): FilterFactoryInterface { if (!isset($this->filterFactory)) { @@ -850,6 +872,11 @@ public function getPersonalizationParameterName(): string return $this->getParameterName(static::PERSONALIZATION_PARAMETER); } + public function getColumnVisibilityGroupParameterName(): string + { + return $this->getParameterName(static::COLUMN_VISIBILITY_GROUP_PARAMETER); + } + public function getExportParameterName(): string { return $this->getParameterName(static::EXPORT_PARAMETER); diff --git a/src/DataTableConfigBuilderInterface.php b/src/DataTableConfigBuilderInterface.php index 064499b1..88b5bb0b 100755 --- a/src/DataTableConfigBuilderInterface.php +++ b/src/DataTableConfigBuilderInterface.php @@ -6,6 +6,7 @@ use Kreyu\Bundle\DataTableBundle\Action\ActionFactoryInterface; use Kreyu\Bundle\DataTableBundle\Column\ColumnFactoryInterface; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupFactoryInterface; use Kreyu\Bundle\DataTableBundle\Exporter\ExportData; use Kreyu\Bundle\DataTableBundle\Exporter\ExporterFactoryInterface; use Kreyu\Bundle\DataTableBundle\Filter\FilterFactoryInterface; @@ -51,6 +52,8 @@ public function setActionFactory(ActionFactoryInterface $actionFactory): static; public function setExporterFactory(ExporterFactoryInterface $exporterFactory): static; + public function setColumnVisibilityGroupFactory(ColumnVisibilityGroupFactoryInterface $columnVisibilityGroupFactory): static; + public function setExportingEnabled(bool $exportingEnabled): static; public function setExportFormFactory(?FormFactoryInterface $exportFormFactory): static; diff --git a/src/DataTableConfigInterface.php b/src/DataTableConfigInterface.php index c78eba6a..01fb33f8 100755 --- a/src/DataTableConfigInterface.php +++ b/src/DataTableConfigInterface.php @@ -6,6 +6,7 @@ use Kreyu\Bundle\DataTableBundle\Action\ActionFactoryInterface; use Kreyu\Bundle\DataTableBundle\Column\ColumnFactoryInterface; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupFactoryInterface; use Kreyu\Bundle\DataTableBundle\Exporter\ExportData; use Kreyu\Bundle\DataTableBundle\Exporter\ExporterFactoryInterface; use Kreyu\Bundle\DataTableBundle\Filter\FilterFactoryInterface; @@ -27,6 +28,7 @@ interface DataTableConfigInterface public const SORT_PARAMETER = 'sort'; public const FILTRATION_PARAMETER = 'filter'; public const PERSONALIZATION_PARAMETER = 'personalization'; + public const COLUMN_VISIBILITY_GROUP_PARAMETER = 'column_visibility_group'; public const EXPORT_PARAMETER = 'export'; public function getEventDispatcher(): EventDispatcherInterface; @@ -51,6 +53,8 @@ public function getActionFactory(): ActionFactoryInterface; public function getExporterFactory(): ExporterFactoryInterface; + public function getColumnVisibilityGroupFactory(): ColumnVisibilityGroupFactoryInterface; + public function isExportingEnabled(): bool; public function getExportFormFactory(): ?FormFactoryInterface; @@ -133,5 +137,7 @@ public function getFiltrationParameterName(): string; public function getPersonalizationParameterName(): string; + public function getColumnVisibilityGroupParameterName(): string; + public function getExportParameterName(): string; } diff --git a/src/DataTableInterface.php b/src/DataTableInterface.php index 177bb90e..3373c490 100755 --- a/src/DataTableInterface.php +++ b/src/DataTableInterface.php @@ -10,6 +10,7 @@ use Kreyu\Bundle\DataTableBundle\Column\ColumnInterface; use Kreyu\Bundle\DataTableBundle\Column\Type\ColumnType; use Kreyu\Bundle\DataTableBundle\Column\Type\ColumnTypeInterface; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupInterface; use Kreyu\Bundle\DataTableBundle\Exception\OutOfBoundsException; use Kreyu\Bundle\DataTableBundle\Exporter\ExportData; use Kreyu\Bundle\DataTableBundle\Exporter\ExporterInterface; @@ -219,4 +220,15 @@ public function createExportView(): DataTableView; public function setTurboFrameId(string $turboFrameId): static; public function isRequestFromTurboFrame(): bool; + + public function setRequestedColumnVisibilityGroup(?string $requestedColumnVisibilityGroup): self; + + public function getRequestedColumnVisibilityGroup(): ?string; + + /** + * @return array + */ + public function getColumnVisibilityGroups(): array; + + public function addColumnVisibilityGroup(ColumnVisibilityGroupInterface $columnVisibilityGroup): static; } diff --git a/src/DataTableView.php b/src/DataTableView.php index 2fa5ea09..ccd0bd0e 100755 --- a/src/DataTableView.php +++ b/src/DataTableView.php @@ -5,6 +5,7 @@ namespace Kreyu\Bundle\DataTableBundle; use Kreyu\Bundle\DataTableBundle\Action\ActionView; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupView; use Kreyu\Bundle\DataTableBundle\Filter\FilterView; use Kreyu\Bundle\DataTableBundle\Pagination\PaginationView; @@ -32,4 +33,9 @@ class DataTableView * @var array */ public array $actions = []; + + /** + * @var array + */ + public array $columnVisibilityGroups = []; } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index e1ec2e20..eaa03c41 100755 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -48,6 +48,9 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarNode('action_factory') ->defaultValue('kreyu_data_table.action.factory') ->end() + ->scalarNode('column_visibility_group_factory') + ->defaultValue('kreyu_data_table.column_visibility_group.factory') + ->end() ->scalarNode('request_handler') ->defaultValue('kreyu_data_table.request_handler.http_foundation') ->end() diff --git a/src/DependencyInjection/KreyuDataTableExtension.php b/src/DependencyInjection/KreyuDataTableExtension.php index 57b9ddec..67281ddd 100755 --- a/src/DependencyInjection/KreyuDataTableExtension.php +++ b/src/DependencyInjection/KreyuDataTableExtension.php @@ -116,6 +116,7 @@ private function resolveConfiguration(array $configs, ContainerBuilder $containe 'action_factory', 'filter_factory', 'exporter_factory', + 'column_visibility_group_factory', 'request_handler', ]; diff --git a/src/Request/HttpFoundationRequestHandler.php b/src/Request/HttpFoundationRequestHandler.php index c0a78ead..8cac73d8 100755 --- a/src/Request/HttpFoundationRequestHandler.php +++ b/src/Request/HttpFoundationRequestHandler.php @@ -38,6 +38,7 @@ public function handle(DataTableInterface $dataTable, mixed $request = null): vo $this->paginate($dataTable, $request); $this->export($dataTable, $request); $this->turbo($dataTable, $request); + $this->columnVisibilityGroup($dataTable, $request); } private function filter(DataTableInterface $dataTable, Request $request): void @@ -153,4 +154,32 @@ private function turbo(DataTableInterface $dataTable, Request $request): void { $dataTable->setTurboFrameId($request->headers->get('Turbo-Frame')); } + + private function columnVisibilityGroup(DataTableInterface $dataTable, Request $request): void + { + $groups = $dataTable->getColumnVisibilityGroups(); + + if (empty($groups)) { + return; + } + + $parameterName = $dataTable->getConfig()->getColumnVisibilityGroupParameterName(); + $requested = $this->extractQueryParameter($request, "[$parameterName]"); + + if (null !== $requested && isset($groups[$requested])) { + $dataTable->setRequestedColumnVisibilityGroup($requested); + + return; + } + + foreach ($groups as $group) { + if ($group->isDefault()) { + $dataTable->setRequestedColumnVisibilityGroup($group->getName()); + + return; + } + } + + $dataTable->setRequestedColumnVisibilityGroup(array_key_first($groups)); + } } diff --git a/src/Resources/config/columns.php b/src/Resources/config/columns.php index 46e9ffe5..b558bc50 100755 --- a/src/Resources/config/columns.php +++ b/src/Resources/config/columns.php @@ -26,6 +26,8 @@ use Kreyu\Bundle\DataTableBundle\Column\Type\ResolvedColumnTypeFactoryInterface; use Kreyu\Bundle\DataTableBundle\Column\Type\TemplateColumnType; use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupFactory; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupFactoryInterface; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -56,6 +58,12 @@ ->alias(ColumnFactoryInterface::class, 'kreyu_data_table.column.factory') ; + $services + ->set('kreyu_data_table.column_visibility_group.factory', ColumnVisibilityGroupFactory::class) + ->args([service('translator')]) + ->alias(ColumnVisibilityGroupFactoryInterface::class, 'kreyu_data_table.column_visibility_group.factory') + ; + $services ->set('kreyu_data_table.column.type.column', ColumnType::class) ->args([service('translator')->nullOnInvalid()]) diff --git a/src/Resources/views/themes/base.html.twig b/src/Resources/views/themes/base.html.twig index 37534b7f..40043374 100755 --- a/src/Resources/views/themes/base.html.twig +++ b/src/Resources/views/themes/base.html.twig @@ -59,7 +59,46 @@ {{ block('action_bar', theme) }} {% endblock %} -{% block action_bar %}{% endblock %} +{% block action_bar %} + {{ block('column_visibility_group_selector', theme) }} +{% endblock %} + +{% block column_visibility_group_selector %} + {% if data_table.columnVisibilityGroups is not empty %} + + {% set form_attr = { 'data-turbo-frame': '_self', 'data-turbo-action': 'advance' }|merge(form_attr ?? {}) %} + +
+ {% for name, value in data_table.vars.url_query_parameters ?? {} %} + {{ block('column_visibility_group_selector_hidden_input', theme) }} + {% endfor %} + + {% set select_attr = { + name: data_table.vars.column_visibility_group_parameter_name, + autocomplete: 'off', + onchange: 'this.form.requestSubmit()', + }|merge(select_attr|default({})) %} + + +
+ {% endif %} +{% endblock %} + +{% block column_visibility_group_selector_hidden_input %} + {% if value is iterable %} + {% for sub_key, sub_value in value %} + {% with { name: name ~ '[' ~ sub_key ~ ']', value: sub_value } %} + {{ block('column_visibility_group_selector_hidden_input', theme) }} + {% endwith %} + {% endfor %} + {% else %} + + {% endif %} +{% endblock %} {% block table %} diff --git a/src/Resources/views/themes/bootstrap_5.html.twig b/src/Resources/views/themes/bootstrap_5.html.twig index 4734bf7b..e141b48b 100755 --- a/src/Resources/views/themes/bootstrap_5.html.twig +++ b/src/Resources/views/themes/bootstrap_5.html.twig @@ -24,7 +24,7 @@ {% set display_filter_action = filtration_enabled and filters|length > 0 and filtration_form.children|length > 0 %} {% set display_export_action = exporting_enabled and exporters|length > 0 %} - {% if title or actions is not empty or has_active_filters or display_filter_action or display_export_action or personalization_enabled %} + {% if title or actions is not empty or has_active_filters or display_filter_action or display_export_action or personalization_enabled or data_table.columnVisibilityGroups is not empty %}
{% if translation_domain is not same as false %} @@ -36,6 +36,8 @@
+ {{ block('column_visibility_group_selector', theme) }} + {% if filtration_enabled and filtration_form and filtration_form.vars.search_fields|length > 0 %}
{{ data_table_theme_block(data_table, 'action_search') }} @@ -492,6 +494,12 @@ {% endwith %} {% endblock %} +{% block column_visibility_group_selector %} + {% with { select_attr: { class: 'col form-select' } } %} + {{ parent() }} + {% endwith %} +{% endblock %} + {% block pagination_page %}
{% with { attr: { class: 'page-link' } } %} diff --git a/src/Test/DataTableIntegrationTestCase.php b/src/Test/DataTableIntegrationTestCase.php index c804a2aa..fcfd32df 100644 --- a/src/Test/DataTableIntegrationTestCase.php +++ b/src/Test/DataTableIntegrationTestCase.php @@ -12,6 +12,8 @@ use Kreyu\Bundle\DataTableBundle\Column\Type\ResolvedColumnTypeFactory; use Kreyu\Bundle\DataTableBundle\Column\Type\ResolvedColumnTypeFactoryInterface; use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupFactory; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupFactoryInterface; use Kreyu\Bundle\DataTableBundle\DataTableFactory; use Kreyu\Bundle\DataTableBundle\DataTableFactoryInterface; use Kreyu\Bundle\DataTableBundle\DataTableRegistry; @@ -21,6 +23,7 @@ use Kreyu\Bundle\DataTableBundle\Type\ResolvedDataTableTypeFactory; use Kreyu\Bundle\DataTableBundle\Type\ResolvedDataTableTypeFactoryInterface; use PHPUnit\Framework\TestCase; +use Symfony\Contracts\Translation\TranslatorInterface; abstract class DataTableIntegrationTestCase extends TestCase { @@ -54,6 +57,7 @@ protected function getDataTableTypes(): array return [ new DataTableType([ 'column_factory' => $this->createColumnFactory(), + 'column_visibility_group_factory' => $this->createColumnVisibilityGroupFactory(), ]), ]; } @@ -106,4 +110,17 @@ protected function getResolvedColumnTypeFactory(): ResolvedColumnTypeFactoryInte { return new ResolvedColumnTypeFactory(); } + + protected function createColumnVisibilityGroupFactory(): ColumnVisibilityGroupFactoryInterface + { + return new ColumnVisibilityGroupFactory($this->createTranslator()); + } + + protected function createTranslator(): TranslatorInterface + { + $translator = $this->createStub(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + return $translator; + } } diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php index e76e935a..cd6d773a 100755 --- a/src/Type/DataTableType.php +++ b/src/Type/DataTableType.php @@ -9,6 +9,9 @@ use Kreyu\Bundle\DataTableBundle\Action\ActionView; use Kreyu\Bundle\DataTableBundle\Column\ColumnFactoryInterface; use Kreyu\Bundle\DataTableBundle\Column\ColumnInterface; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupFactoryInterface; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupInterface; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupView; use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; use Kreyu\Bundle\DataTableBundle\DataTableInterface; use Kreyu\Bundle\DataTableBundle\DataTableView; @@ -43,6 +46,7 @@ public function buildDataTable(DataTableBuilderInterface $builder, array $option $setters = [ 'themes' => $builder->setThemes(...), 'column_factory' => $builder->setColumnFactory(...), + 'column_visibility_group_factory' => $builder->setColumnVisibilityGroupFactory(...), 'filter_factory' => $builder->setFilterFactory(...), 'action_factory' => $builder->setActionFactory(...), 'exporter_factory' => $builder->setExporterFactory(...), @@ -101,6 +105,7 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar 'filtration_parameter_name' => $dataTable->getConfig()->getFiltrationParameterName(), 'personalization_parameter_name' => $dataTable->getConfig()->getPersonalizationParameterName(), 'export_parameter_name' => $dataTable->getConfig()->getExportParameterName(), + 'column_visibility_group_parameter_name' => $dataTable->getConfig()->getColumnVisibilityGroupParameterName(), 'has_active_filters' => $dataTable->hasActiveFilters(), 'filtration_data' => $dataTable->getFiltrationData(), 'sorting_data' => $dataTable->getSortingData(), @@ -115,6 +120,7 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar $view->pagination = $this->createPaginationView($view, $dataTable); $view->filters = $this->createFilterViews($view, $dataTable); $view->actions = $this->createActionViews($view, $dataTable); + $view->columnVisibilityGroups = $this->createColumnVisibilityGroupViews($dataTable); $view->vars = array_replace($view->vars, [ 'header_row' => $view->headerRow, @@ -163,6 +169,7 @@ public function configureOptions(OptionsResolver $resolver): void 'filter_factory' => $this->defaults['filtration']['filter_factory'] ?? null, 'action_factory' => $this->defaults['action_factory'] ?? null, 'exporter_factory' => $this->defaults['exporting']['exporter_factory'] ?? null, + 'column_visibility_group_factory' => $this->defaults['column_visibility_group_factory'] ?? null, 'request_handler' => $this->defaults['request_handler'] ?? null, 'sorting_enabled' => $this->defaults['sorting']['enabled'] ?? true, 'sorting_clearable' => $this->defaults['sorting']['clearable'] ?? true, @@ -195,6 +202,7 @@ public function configureOptions(OptionsResolver $resolver): void ->setAllowedTypes('filter_factory', ['null', FilterFactoryInterface::class]) ->setAllowedTypes('action_factory', ['null', ActionFactoryInterface::class]) ->setAllowedTypes('exporter_factory', ['null', ExporterFactoryInterface::class]) + ->setAllowedTypes('column_visibility_group_factory', ['null', ColumnVisibilityGroupFactoryInterface::class]) ->setAllowedTypes('request_handler', ['null', RequestHandlerInterface::class]) ->setAllowedTypes('sorting_enabled', 'bool') ->setAllowedTypes('sorting_clearable', 'bool') @@ -261,6 +269,32 @@ private function createBatchActionViews(DataTableView $view, DataTableInterface ); } + /** + * @return array + */ + private function createColumnVisibilityGroupViews(DataTableInterface $dataTable): array + { + $requested = $dataTable->getRequestedColumnVisibilityGroup(); + + $views = []; + + foreach ($dataTable->getColumnVisibilityGroups() as $group) { + $views[$group->getName()] = $this->createColumnVisibilityGroupView($group, $requested); + } + + return $views; + } + + private function createColumnVisibilityGroupView(ColumnVisibilityGroupInterface $group, ?string $requested): ColumnVisibilityGroupView + { + return new ColumnVisibilityGroupView( + name: $group->getName(), + label: $group->getLabel(), + isDefault: $group->isDefault(), + isSelected: $group->getName() === $requested, + ); + } + /** * @return array */ diff --git a/tests/Unit/ColumnVisibilityGroup/ColumnVisibilityGroupFactoryTest.php b/tests/Unit/ColumnVisibilityGroup/ColumnVisibilityGroupFactoryTest.php new file mode 100644 index 00000000..76a93064 --- /dev/null +++ b/tests/Unit/ColumnVisibilityGroup/ColumnVisibilityGroupFactoryTest.php @@ -0,0 +1,76 @@ +createFactory()->create('address'); + + $this->assertSame('address', $group->getName()); + $this->assertSame('address', $group->getLabel()); + $this->assertFalse($group->isDefault()); + } + + public function testCreateWithLabel() + { + $group = $this->createFactory()->create('address', ['label' => 'Address details']); + + $this->assertSame('Address details', $group->getLabel()); + } + + public function testCreateTranslatesLabel() + { + $translator = $this->createStub(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback(static fn (string $id) => strtoupper($id)); + + $group = (new ColumnVisibilityGroupFactory($translator))->create('address', ['label' => 'foo']); + + $this->assertSame('FOO', $group->getLabel()); + } + + public function testCreateWithIsDefault() + { + $group = $this->createFactory()->create('address', ['is_default' => true]); + + $this->assertTrue($group->isDefault()); + } + + public function testCreateRejectsInvalidOption() + { + $this->expectException(UndefinedOptionsException::class); + + $this->createFactory()->create('address', ['unknown' => 'foo']); + } + + public function testCreateRejectsInvalidLabelType() + { + $this->expectException(InvalidOptionsException::class); + + $this->createFactory()->create('address', ['label' => 42]); + } + + public function testCreateRejectsInvalidIsDefaultType() + { + $this->expectException(InvalidOptionsException::class); + + $this->createFactory()->create('address', ['is_default' => 'yes']); + } + + private function createFactory(): ColumnVisibilityGroupFactory + { + $translator = $this->createStub(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + return new ColumnVisibilityGroupFactory($translator); + } +} diff --git a/tests/Unit/DataTableBuilderTest.php b/tests/Unit/DataTableBuilderTest.php index d9837dfc..2732b74a 100644 --- a/tests/Unit/DataTableBuilderTest.php +++ b/tests/Unit/DataTableBuilderTest.php @@ -21,6 +21,7 @@ use Kreyu\Bundle\DataTableBundle\Column\Type\NumberColumnType; use Kreyu\Bundle\DataTableBundle\Column\Type\ResolvedColumnTypeFactory; use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; +use Kreyu\Bundle\DataTableBundle\ColumnVisibilityGroup\ColumnVisibilityGroupFactory; use Kreyu\Bundle\DataTableBundle\DataTableBuilder; use Kreyu\Bundle\DataTableBundle\Exception\InvalidArgumentException; use Kreyu\Bundle\DataTableBundle\Exporter\ExporterBuilderInterface; @@ -41,6 +42,7 @@ use Kreyu\Bundle\DataTableBundle\Type\ResolvedDataTableTypeInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class DataTableBuilderTest extends TestCase { @@ -717,6 +719,78 @@ public function testGetDataTableResolvesActions() $this->assertEquals($expectedActions, $dataTable->getActions()); } + public function testAddColumnVisibilityGroupAsString() + { + $builder = $this->createBuilder(); + $builder->setColumnVisibilityGroupFactory($this->createColumnVisibilityGroupFactory()); + + $builder->addColumnVisibilityGroup('foo'); + + $this->assertTrue($builder->hasColumnVisibilityGroup('foo')); + } + + public function testAddColumnVisibilityGroupAsObject() + { + $factory = $this->createColumnVisibilityGroupFactory(); + $group = $factory->create('foo'); + + $builder = $this->createBuilder(); + $builder->addColumnVisibilityGroup($group); + + $this->assertTrue($builder->hasColumnVisibilityGroup('foo')); + } + + public function testRemoveColumnVisibilityGroup() + { + $builder = $this->createBuilder(); + $builder->setColumnVisibilityGroupFactory($this->createColumnVisibilityGroupFactory()); + + $builder->addColumnVisibilityGroup('foo'); + $builder->removeColumnVisibilityGroup('foo'); + + $this->assertFalse($builder->hasColumnVisibilityGroup('foo')); + } + + public function testCreateColumnVisibilityGroup() + { + $builder = $this->createBuilder(); + $builder->setColumnVisibilityGroupFactory($this->createColumnVisibilityGroupFactory()); + + $group = $builder->createColumnVisibilityGroup('foo', ['label' => 'Foo group']); + + $this->assertSame('foo', $group->getName()); + $this->assertSame('Foo group', $group->getLabel()); + } + + public function testGetDataTableResolvesColumnVisibilityGroups() + { + $builder = $this->createBuilder(); + $builder->setColumnVisibilityGroupFactory($this->createColumnVisibilityGroupFactory()); + $builder->addColumnVisibilityGroup('foo'); + $builder->addColumnVisibilityGroup('bar', ['is_default' => true]); + + $dataTable = $builder->getDataTable(); + + $groups = $dataTable->getColumnVisibilityGroups(); + + $this->assertCount(2, $groups); + $this->assertFalse($groups['foo']->isDefault()); + $this->assertTrue($groups['bar']->isDefault()); + } + + public function testGetDataTableThrowsWhenMultipleDefaultGroups() + { + $builder = $this->createBuilder(); + $builder->setColumnVisibilityGroupFactory($this->createColumnVisibilityGroupFactory()); + $builder->addColumnVisibilityGroup('foo', ['is_default' => true]); + $builder->addColumnVisibilityGroup('bar', ['is_default' => true]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Only one column visibility group can be marked as default'); + + $builder->getDataTable(); + } + public function testGetDataTableResolvesExporters() { $builder = $this->createBuilder(); @@ -791,6 +865,14 @@ private function createActionFactory(): ActionFactory ); } + private function createColumnVisibilityGroupFactory(): ColumnVisibilityGroupFactory + { + $translator = $this->createStub(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); + + return new ColumnVisibilityGroupFactory($translator); + } + private function createExporterFactory(): ExporterFactory { return new ExporterFactory( diff --git a/tests/Unit/DataTableTest.php b/tests/Unit/DataTableTest.php index 22ef1fe4..3e82b534 100644 --- a/tests/Unit/DataTableTest.php +++ b/tests/Unit/DataTableTest.php @@ -144,6 +144,81 @@ public function testGetVisibleColumnsIgnoresDisabledPersonalization() $this->assertEquals(['fifth', 'third', 'first'], $columns); } + public function testGetVisibleColumnsWithoutRequestedGroupShowsAll() + { + $dataTable = $this->createDataTableBuilder() + ->addColumnVisibilityGroup('foo') + ->addColumnVisibilityGroup('bar') + ->addColumn('always', options: ['priority' => 3]) + ->addColumn('foo_only', options: ['priority' => 2, 'column_visibility_groups' => ['foo']]) + ->addColumn('bar_only', options: ['priority' => 1, 'column_visibility_groups' => ['bar']]) + ->getDataTable(); + + $columns = array_keys($dataTable->getVisibleColumns()); + + $this->assertEquals(['always', 'foo_only', 'bar_only'], $columns); + } + + public function testGetVisibleColumnsFiltersByRequestedGroup() + { + $dataTable = $this->createDataTableBuilder() + ->addColumnVisibilityGroup('foo') + ->addColumnVisibilityGroup('bar') + ->addColumn('always', options: ['priority' => 3]) + ->addColumn('foo_only', options: ['priority' => 2, 'column_visibility_groups' => ['foo']]) + ->addColumn('bar_only', options: ['priority' => 1, 'column_visibility_groups' => ['bar']]) + ->getDataTable(); + + $dataTable->setRequestedColumnVisibilityGroup('foo'); + + $columns = array_keys($dataTable->getVisibleColumns()); + + $this->assertEquals(['always', 'foo_only'], $columns); + } + + public function testGetVisibleColumnsSupportsMultipleGroupsPerColumn() + { + $dataTable = $this->createDataTableBuilder() + ->addColumnVisibilityGroup('foo') + ->addColumnVisibilityGroup('bar') + ->addColumn('shared', options: ['column_visibility_groups' => ['foo', 'bar']]) + ->addColumn('foo_only', options: ['column_visibility_groups' => ['foo']]) + ->getDataTable(); + + $dataTable->setRequestedColumnVisibilityGroup('bar'); + + $this->assertEquals(['shared'], array_keys($dataTable->getVisibleColumns())); + } + + public function testPersonalizationHiddenWinsOverVisibilityGroup() + { + $dataTable = $this->createDataTableBuilder(['personalization_enabled' => true]) + ->addColumnVisibilityGroup('foo') + ->addColumn('foo_only', options: ['column_visibility_groups' => ['foo']]) + ->getDataTable(); + + $dataTable->setRequestedColumnVisibilityGroup('foo'); + $dataTable->setPersonalizationData(PersonalizationData::fromArray([ + 'columns' => [ + 'foo_only' => ['visible' => false], + ], + ])); + + $this->assertEmpty($dataTable->getVisibleColumns()); + } + + public function testGetDataTableThrowsWhenColumnReferencesUnknownGroup() + { + $builder = $this->createDataTableBuilder() + ->addColumnVisibilityGroup('foo') + ->addColumn('bad', options: ['column_visibility_groups' => ['typo']]); + + $this->expectException(\Kreyu\Bundle\DataTableBundle\Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('Column "bad" references the column visibility group "typo"'); + + $builder->getDataTable(); + } + public function testGetHiddenColumns() { $dataTable = $this->createDataTableBuilder() diff --git a/tests/Unit/Request/HttpFoundationRequestHandlerTest.php b/tests/Unit/Request/HttpFoundationRequestHandlerTest.php new file mode 100644 index 00000000..6eb02249 --- /dev/null +++ b/tests/Unit/Request/HttpFoundationRequestHandlerTest.php @@ -0,0 +1,91 @@ +createDataTable(['foo', 'bar']); + + (new HttpFoundationRequestHandler())->handle( + $dataTable, + new Request([$this->parameterName($dataTable) => 'bar']), + ); + + $this->assertSame('bar', $dataTable->getRequestedColumnVisibilityGroup()); + } + + public function testColumnVisibilityGroupFallsBackToDefaultWhenParamMissing() + { + $dataTable = $this->createDataTable(['foo', 'bar'], defaultName: 'bar'); + + (new HttpFoundationRequestHandler())->handle($dataTable, new Request()); + + $this->assertSame('bar', $dataTable->getRequestedColumnVisibilityGroup()); + } + + public function testColumnVisibilityGroupFallsBackToFirstWhenNoDefault() + { + $dataTable = $this->createDataTable(['foo', 'bar']); + + (new HttpFoundationRequestHandler())->handle($dataTable, new Request()); + + $this->assertSame('foo', $dataTable->getRequestedColumnVisibilityGroup()); + } + + public function testColumnVisibilityGroupIgnoresUnknownRequestedAndUsesDefault() + { + $dataTable = $this->createDataTable(['foo', 'bar'], defaultName: 'bar'); + + (new HttpFoundationRequestHandler())->handle( + $dataTable, + new Request([$this->parameterName($dataTable) => 'unknown']), + ); + + $this->assertSame('bar', $dataTable->getRequestedColumnVisibilityGroup()); + } + + public function testColumnVisibilityGroupIsNoOpWhenNoGroupsConfigured() + { + $dataTable = $this->createDataTable([]); + + (new HttpFoundationRequestHandler())->handle( + $dataTable, + new Request([$this->parameterName($dataTable) => 'foo']), + ); + + $this->assertNull($dataTable->getRequestedColumnVisibilityGroup()); + } + + private function parameterName(DataTableInterface $dataTable): string + { + return $dataTable->getConfig()->getColumnVisibilityGroupParameterName(); + } + + /** + * @param list $groupNames + */ + private function createDataTable(array $groupNames, ?string $defaultName = null): DataTableInterface + { + $builder = $this->dataTableFactory->createBuilder(DataTableType::class, []); + $builder->addColumn('name', TextColumnType::class); + + foreach ($groupNames as $name) { + $builder->addColumnVisibilityGroup($name, [ + 'is_default' => $name === $defaultName, + ]); + } + + return $builder->getDataTable(); + } +} diff --git a/tests/Unit/Twig/ColumnVisibilityGroupSelectorBlockTest.php b/tests/Unit/Twig/ColumnVisibilityGroupSelectorBlockTest.php new file mode 100644 index 00000000..42630695 --- /dev/null +++ b/tests/Unit/Twig/ColumnVisibilityGroupSelectorBlockTest.php @@ -0,0 +1,149 @@ +renderSelector([ + 'page_foo' => 2, + 'limit_foo' => 25, + ]); + + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + } + + public function testFormIncludesHiddenInputsForNestedQueryParameters(): void + { + $html = $this->renderSelector([ + 'sort_foo' => ['id' => 'asc', 'name' => 'desc'], + ]); + + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + } + + public function testFormIncludesHiddenInputsForDeeplyNestedQueryParameters(): void + { + $html = $this->renderSelector([ + 'filter_foo' => [ + 'status' => ['value' => 'active', 'operator' => 'eq'], + ], + ]); + + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + } + + public function testFormHasNoHiddenInputsWhenNoQueryParameters(): void + { + $html = $this->renderSelector([]); + + $this->assertStringNotContainsString('renderSelector([], columnVisibilityGroups: []); + + $this->assertSame('', trim($html)); + } + + private function renderSelector(array $urlQueryParameters, ?array $columnVisibilityGroups = null): string + { + $loader = new FilesystemLoader(__DIR__.'/../../../src/Resources/views/themes'); + $twig = new Environment($loader, ['strict_variables' => false]); + + // Avoid Symfony TranslationExtension: its TranslationDefaultDomainNodeVisitor is incompatible + // with the lowest supported Twig version (raises "EmptyNode cannot have children"). + // A stub that no-ops `|trans` and `{% trans_default_domain %}` is enough for our purpose — + // we only render one block that doesn't translate anything. + $twig->addExtension(new NoOpTranslationExtension()); + + $twig->addExtension(new FormExtension()); + $twig->addExtension(new IntlExtension()); + $twig->addExtension(new DataTableExtension( + $this->createStub(ColumnSortUrlGeneratorInterface::class), + $this->createStub(FilterClearUrlGeneratorInterface::class), + $this->createStub(PaginationUrlGeneratorInterface::class), + )); + + $template = $twig->load('base.html.twig'); + + $view = new DataTableView(); + $view->vars = [ + 'url_query_parameters' => $urlQueryParameters, + 'column_visibility_group_parameter_name' => 'column_visibility_group_foo', + ]; + $view->columnVisibilityGroups = $columnVisibilityGroups ?? [ + 'default' => new ColumnVisibilityGroupView('default', 'Default', true, true), + ]; + + return $template->renderBlock('column_visibility_group_selector', [ + 'data_table' => $view, + 'theme' => $template, + ]); + } +} + +final class NoOpTranslationExtension extends AbstractExtension +{ + public function getFilters(): array + { + return [ + new TwigFilter('trans', static fn (mixed $value) => $value), + ]; + } + + public function getTokenParsers(): array + { + return [new NoOpTransDefaultDomainTokenParser()]; + } +} + +final class NoOpTransDefaultDomainTokenParser extends AbstractTokenParser +{ + public function parse(Token $token): Node + { + $stream = $this->parser->getStream(); + + while (!$stream->test(Token::BLOCK_END_TYPE)) { + $stream->next(); + } + + $stream->expect(Token::BLOCK_END_TYPE); + + return new Node([], [], $token->getLine()); + } + + public function getTag(): string + { + return 'trans_default_domain'; + } +}