diff --git a/docs/reference/columns/types/boolean.md b/docs/reference/columns/types/boolean.md index 9cba88a2..62f1d92d 100644 --- a/docs/reference/columns/types/boolean.md +++ b/docs/reference/columns/types/boolean.md @@ -6,13 +6,13 @@ The [BooleanType](https://github.com/Kreyu/data-table-bundle/blob/main/src/Colum ### `label_true` -**type**: `string` or `TranslatableMessage` **default**: `'Yes'` +**type**: `string` or `Symfony\Component\Translation\TranslatableMessage` **default**: `'Yes'` Sets the value that will be displayed if row value is true. ### `label_false` -**type**: `string` or `TranslatableMessage` **default**: `'No'` +**type**: `string` or `Symfony\Component\Translation\TranslatableMessage` **default**: `'No'` Sets the value that will be displayed if row value is false. diff --git a/docs/reference/columns/types/column.md b/docs/reference/columns/types/column.md index ebf3f87a..3e4694bf 100644 --- a/docs/reference/columns/types/column.md +++ b/docs/reference/columns/types/column.md @@ -6,7 +6,7 @@ The [ColumnType](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column ### `label` -**type**: `string` or `TranslatableMessage` **default**: the label is "guessed" from the column name +**type**: `string` or `Symfony\Component\Translation\TranslatableMessage` **default**: the label is "guessed" from the column name Sets the label that will be used when rendering the column header. @@ -34,13 +34,14 @@ Setting the option to `false` disables property accessor (for situations, where **type**: `bool` or `string` **default**: `false` - the sortable behavior is disabled -Sets the sort field used by the sortable behavior. +Sets the sort field used by the sortable behavior. + Setting the option to `true` enables column sorting and uses the column name as a sort field name. Setting the option to `false` disables column sorting. ### `block_name` -**type**: `string` **default**: `kreyu_data_table_column_` + column type block prefix +**type**: `string` **default**: `kreyu_data_table_column_` + column type block prefix Allows you to add a custom block name to the ones used by default to render the column type. Useful for example if you have multiple instances of the same column type, and you need to personalize the rendering of the columns individually. @@ -56,36 +57,89 @@ Useful for example if you have multiple instances of the same column type, and y ### `formatter` -**type**: `callable` **default**: `null` +**type**: `null` or `callable` **default**: `null` + +Formats the value to the desired string. -Formats the value retrieved by the property accessor to string: +The value passed as the argument can come either from the `value` option, +or the property accessor after the extraction using the `property_path` option. ```php $builder ->addColumn('ean', TextType::class, [ + 'value' => fn (Product $product) => $product->getEan(), 'formatter' => fn (string $value) => trim($value), ]) - ->addColumn('quantity', TextType::class, [ + ->addColumn('quantity', NumberType::class, [ + // no value specified, so property accessor will retrieve the value of the product "quantity" 'formatter' => fn (float $value) => number_format($value, 2) . 'kg', ]) + ->addColumn('name', TextType::class, [ + // the option accepts callables, not only closures + 'formatter' => 'trim', + ]) ; ``` -If you disabled property accessor by setting the `property_path` option to `false`, this is a way to retrieve a value manually: +### `export` + +**type**: `bool` or `array` **default**: `[]` with some exceptions on built-in types (e.g. [ActionsType](actions.md)) + +Determines whether the column should be included in the exports. + +This option accepts an array of options available for the column type. +It is used to differentiate options for regular rendering, and excel rendering. + +For example, if you wish to display quantity column with "Quantity" label, but export with a "Qty" header: ```php -$builder - ->addColumn('fullName', TextType::class, [ - 'property_path' => false, - 'formatter' => fn (User $value) => implode(' ', [$user->name, $user->surname]), +$columns + ->add('quantity', NumberType::class, [ + 'label' => 'Quantity', + 'translation_domain' => 'product', + 'export' => [ + 'label' => 'Qty', + // rest of the options are inherited, therefore "translation_domain" equals "product", etc. + ], ]) ; ``` -Because property accessor is not called, the value passed as the first argument is a "raw" row value (and for most cases it will be an entity). +Rest of the options are inherited from the column options. + +Setting this option to `true` automatically copies the column options as the export column options. +Setting this option to `false` excludes the column from the exports. + +### `non_resolvable_options` -### `exportable` +**type**: `array` **default**: `[]` + +Because some column options can be an instance of `\Closure`, the bundle will automatically +call them, passing column value, data, whole column object and array of options, as the closure arguments. + +This process is called "resolving", and the [formatter](#formatter) and [value](#value) options are excluded from the process. +Because it may be possible, that the user does **not** want to get an option resolved (not call the closure at all), +it is possible to pass the option name to this array, to exclude it from the resolving process. + +For example: + +```php +$columns + ->add('id', CustomType::class, [ + 'uniqid' => function (string $prefix) { + return uniqid($prefix); + }, + 'non_resolvable_options' => [ + 'uniqid', + ], + ]) +; +``` -**type**: `bool` **default**: `true` +The `uniqid` option will be available in the column views as a callable. For example, in templates: -If this value is true, the column will be included in the export results. +```twig +{% block kreyu_data_table_column_custom %} + {{ value }} ({{ uniqid('product_') }}) +{% endblock %} +``` diff --git a/docs/reference/columns/types/link.md b/docs/reference/columns/types/link.md index b748ad06..bdd42d2c 100644 --- a/docs/reference/columns/types/link.md +++ b/docs/reference/columns/types/link.md @@ -6,15 +6,14 @@ The [LinkType](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/T ### `href` -**type**: `string` or `callable` **default**: `'#'` +**type**: `string` or `\Closure` **default**: `'#'` Sets the value that will be used as a link `href` attribute (see [href attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-href)). -Callable can be used to provide an option value based on a row value, which is passed as a first argument. +Closure can be used to provide an option value based on a row value, which is passed as a first argument. ```php $columns ->add('category', LinkType::class, [ - 'property_path' => false, 'value' => function (Category $category): string { return $category->getName(), }, @@ -29,10 +28,10 @@ $columns ### `target` -**type**: `string` or `callable` **default**: `'_self'` +**type**: `string` or `\Closure` **default**: `'_self'` Sets the value that will be used as an anchor `target` attribute (see [target attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-target)). -Callable can be used to provide an option value based on a row value, which is passed as a first argument. +Closure can be used to provide an option value based on a row value, which is passed as a first argument. ### `display_icon` diff --git a/docs/reference/columns/types/template.md b/docs/reference/columns/types/template.md index fdfa5327..408b9923 100644 --- a/docs/reference/columns/types/template.md +++ b/docs/reference/columns/types/template.md @@ -6,17 +6,17 @@ The [TemplateType](https://github.com/Kreyu/data-table-bundle/blob/main/src/Colu ### `template_path` -**type**: `string` or `callable` +**type**: `string` or `\Closure` Sets the path to the template that should be rendered. -Callable can be used to provide an option value based on a row value, which is passed as a first argument. +Closure can be used to provide an option value based on a row value, which is passed as a first argument. ### `template_vars` -**type**: `string` or `callable` **default**: `'#'` +**type**: `string` or `\Closure` **default**: `'#'` Sets the variables used within the template. -Callable can be used to provide an option value based on a row value, which is passed as a first argument. +Closure can be used to provide an option value based on a row value, which is passed as a first argument. ## Inherited options diff --git a/src/Bridge/PhpSpreadsheet/Exporter/Type/AbstractType.php b/src/Bridge/PhpSpreadsheet/Exporter/Type/AbstractType.php index b68a67fb..461a6dd3 100644 --- a/src/Bridge/PhpSpreadsheet/Exporter/Type/AbstractType.php +++ b/src/Bridge/PhpSpreadsheet/Exporter/Type/AbstractType.php @@ -49,26 +49,26 @@ protected function createSpreadsheet(DataTableView $view, array $options = []): $headersRow = $view->vars['headers_row']; $columns = array_filter($headersRow->vars['columns'], function (ColumnView $view) { - return $view->vars['exportable'] ?? true; + return false !== $view->vars['export']; }); $this->appendRow( $worksheet, array_map(function (ColumnView $column) { - return $column->vars['label']; + return $column->vars['export']['label']; }, $columns), ); } foreach ($view->vars['values_rows'] as $valuesRow) { $columns = array_filter($valuesRow->vars['columns'], function (ColumnView $view) { - return $view->vars['exportable'] ?? true; + return false !== $view->vars['export']; }); $this->appendRow( $worksheet, array_map(function (ColumnView $column) { - return $column->vars['exportable_value']; + return $column->vars['export']['value']; }, $columns), ); } diff --git a/src/Column/Type/ActionsType.php b/src/Column/Type/ActionsType.php index 52773138..2b287d55 100644 --- a/src/Column/Type/ActionsType.php +++ b/src/Column/Type/ActionsType.php @@ -12,7 +12,7 @@ public function configureOptions(OptionsResolver $resolver): void { $resolver ->setDefaults([ - 'exportable' => false, + 'export' => false, 'property_path' => false, 'display_personalization_button' => true, 'actions' => function (OptionsResolver $resolver) { @@ -24,8 +24,8 @@ public function configureOptions(OptionsResolver $resolver): void ->setDefaults([ 'template_vars' => [], ]) - ->setAllowedTypes('template_path', ['string', 'callable']) - ->setAllowedTypes('template_vars', ['array', 'callable']) + ->setAllowedTypes('template_path', ['string', \Closure::class]) + ->setAllowedTypes('template_vars', ['array', \Closure::class]) ; }, ]) diff --git a/src/Column/Type/CollectionType.php b/src/Column/Type/CollectionType.php index 706b00fb..5689298e 100644 --- a/src/Column/Type/CollectionType.php +++ b/src/Column/Type/CollectionType.php @@ -37,7 +37,7 @@ public function configureOptions(OptionsResolver $resolver): void 'entry_type' => TextType::class, 'entry_options' => [], 'separator' => ',', - 'non_normalizable_options' => [ + 'non_resolvable_options' => [ 'entry_options', ], ]) diff --git a/src/Column/Type/ColumnType.php b/src/Column/Type/ColumnType.php index c166d9b3..bf238b7c 100644 --- a/src/Column/Type/ColumnType.php +++ b/src/Column/Type/ColumnType.php @@ -4,9 +4,9 @@ namespace Kreyu\Bundle\DataTableBundle\Column\Type; +use Closure; use Kreyu\Bundle\DataTableBundle\Column\ColumnInterface; use Kreyu\Bundle\DataTableBundle\Column\ColumnView; -use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -15,69 +15,17 @@ final class ColumnType implements ColumnTypeInterface { + public const DEFAULT_BLOCK_PREFIX = 'kreyu_data_table_column_'; + public function buildView(ColumnView $view, ColumnInterface $column, array $options): void { - $resolver = clone $column->getType()->getOptionsResolver(); - - $resolver - ->setDefaults([ - 'label' => ucfirst($column->getName()), - 'translation_domain' => $view->parent->vars['label_translation_domain'], - 'data' => $column->getData(), - 'value' => $column->getData(), - 'exportable_value' => $column->getData(), - 'property_path' => $column->getName(), - 'block_prefix' => $column->getType()->getBlockPrefix(), - 'block_name' => 'kreyu_data_table_column_'.$column->getType()->getBlockPrefix(), - ]) - ->setNormalizer('sort', function (Options $options, mixed $value) use ($column) { - if (true === $value) { - return $column->getName(); - } - - return $value; - }) - ; - - $options = $resolver->resolve(array_filter($options, fn ($option) => null !== $option)); - $options['sort_field'] = $options['sort']; - - unset($options['sort']); - - $value = $options['value']; - $propertyPath = $options['property_path']; - $propertyAccessor = $options['property_accessor']; - - if (false !== $propertyPath && (is_array($value) || is_object($value))) { - if ($propertyAccessor->isReadable($value, $propertyPath)) { - $value = $propertyAccessor->getValue($value, $propertyPath); - } - } - - $options['value'] = $options['exportable_value'] = $value; - - if (null !== $column->getData()) { - $normalizableOptions = array_diff_key($options, array_flip($options['non_normalizable_options'] + [ - 'formatter', - 'exportable_formatter', - ])); - - $normalizedOptions = $this->normalizeOptions($normalizableOptions, $value); - - $options = array_merge($options, $normalizedOptions); - - if (is_callable($formatter = $options['formatter'])) { - $options['value'] = $formatter($value, $column, $options); - } - - if (is_callable($exportableFormatter = $options['exportable_formatter'])) { - $options['exportable_value'] = $exportableFormatter($value, $column, $options); - } - } - + // Put the data table view as the option. + // Thanks to that, it can be accessed in the templates if needed. $options['data_table'] = $view->parent; - $view->vars += $options; + $options = array_merge($options, $this->resolveDefaultOptions($view, $column, $options)); + + $view->vars = array_merge($view->vars, $options); } public function configureOptions(OptionsResolver $resolver): void @@ -94,13 +42,12 @@ public function configureOptions(OptionsResolver $resolver): void 'value' => null, 'display_personalization_button' => false, 'property_accessor' => PropertyAccess::createPropertyAccessor(), + 'export' => true, 'formatter' => null, - 'exportable' => true, - 'exportable_formatter' => null, - 'non_normalizable_options' => [], + 'non_resolvable_options' => [], ]) ->setAllowedTypes('label', ['null', 'string', TranslatableMessage::class]) - ->setAllowedTypes('label_translation_parameters', ['array', 'callable']) + ->setAllowedTypes('label_translation_parameters', ['array', Closure::class]) ->setAllowedTypes('translation_domain', ['null', 'bool', 'string']) ->setAllowedTypes('property_path', ['null', 'bool', 'string', PropertyPathInterface::class]) ->setAllowedTypes('sort', ['bool', 'string']) @@ -108,10 +55,9 @@ public function configureOptions(OptionsResolver $resolver): void ->setAllowedTypes('block_prefix', ['null', 'string']) ->setAllowedTypes('display_personalization_button', ['bool']) ->setAllowedTypes('property_accessor', [PropertyAccessorInterface::class]) - ->setAllowedTypes('formatter', ['null', 'callable']) - ->setAllowedTypes('exportable', ['bool']) - ->setAllowedTypes('exportable_formatter', ['null', 'callable']) - ->setAllowedTypes('non_normalizable_options', ['string[]']) + ->setAllowedTypes('export', ['bool', 'array']) + ->setAllowedTypes('formatter', ['null', Closure::class]) + ->setAllowedTypes('non_resolvable_options', ['string[]']) ; } @@ -125,18 +71,133 @@ public function getParent(): ?string return null; } - private function normalizeOptions(array $options, mixed $value): array + /** + * Resolve the callable options, by calling each one, passing as the arguments + * the column value, an instance of the column object, and a whole options array. + */ + private function resolveCallableOptions(array $resolvableOptions, ColumnInterface $column, array $options): array { - foreach ($options as $key => $option) { + foreach ($resolvableOptions as $key => $option) { if (is_array($option)) { - $option = $this->normalizeOptions($option, $value); + $option = $this->resolveCallableOptions($option, $column, $options); } - if ($option instanceof \Closure) { - $option = $option($value); + if ($option instanceof Closure) { + $option = $option($options['value'], $options['data'], $column, $options); } - $options[$key] = $option; + $resolvableOptions[$key] = $option; + } + + return $resolvableOptions; + } + + /** + * Resolve default values of the options, based on the column view and column object. + * This is the place where, for example, the label can be defaulted to the column name. + */ + private function resolveDefaultOptions(ColumnView $view, ColumnInterface $column, array $options): array + { + // Put the column data as the "data" option. + // This way, it can be referenced later if needed. + $options['data'] = $column->getData(); + + // The column can have "value" option preconfigured. + // In that case, disable the property accessor feature. + if (isset($options['value'])) { + $options['property_path'] = false; + + // The "value" option can be callable, that should be called with the column data. + // This way, the user can retrieve a column value manually. + if (is_callable($options['value']) && null !== $options['data']) { + $options['value'] = $options['value']($options['data'], $column, $options); + } + } + + // The column "value" option by default should contain the column data. + $options['value'] ??= $column->getData(); + + // If the label is not given, then it should be replaced with a column name. + // Thanks to that, the boilerplate code is reduced, and the labels work as expected. + $options['label'] ??= ucfirst($column->getName()); + + // If the translation domain is not given, then it should be inherited + // from the parent data table view "label_translation_domain" option. + $options['translation_domain'] ??= $view->parent->vars['label_translation_domain']; + + // If the property path is not given, then the column name should be used. + // Thanks to that, similar to "label" option, the boilerplate code is reduced. + $options['property_path'] ??= $column->getName(); + + // If the block prefix or name is not specified, then the values + // should be inherited from the column type. + $options['block_prefix'] ??= $column->getType()->getBlockPrefix(); + $options['block_name'] ??= self::DEFAULT_BLOCK_PREFIX . $options['block_prefix']; + + // Because by default, the sorting feature is disabled, the user can enable it + // by setting the "sort" option to either sort field path, or just a true. + // Setting the value to true means the column name should be used as the sort field path. + if (true === $options['sort']) { + $options['sort'] = $column->getName(); + } + + $value = $options['value']; + $propertyPath = $options['property_path']; + $propertyAccessor = $options['property_accessor']; + + // Use property accessor to retrieve the value from the configured property path. + // Note: property accessor only supports array and object values! + if (false !== $propertyPath && (is_array($value) || is_object($value))) { + if ($propertyAccessor->isReadable($value, $propertyPath)) { + $options['value'] = $propertyAccessor->getValue($value, $propertyPath); + } + } + + // Because the user can provide callable options, every single one of those + // should be called with resolved column value, whole column for reference and an array of options. + if (null !== $options['data']) { + // Because every callable option is resolved by default, a way to exclude + // some options from this process may be necessary - a "non_resolvable_option" option, + // just in case if the user actually expects the option to be a callable. + $resolvableOptions = array_diff_key($options, array_flip($options['non_resolvable_options']) + [ + 'formatter' => true, + ]); + + // Because "value" options are getting resolved earlier only if the column data is present, + // they have to get excluded from the latter resolving whatsoever. + unset($resolvableOptions['value']); + + if (isset($resolvableOptions['export']['value'])) { + unset($resolvableOptions['export']['value']); + } + + // Resolve the callable options, passing the value, column and options. + // Note: the options passed to the callables are not resolved yet! + $resolvedOptions = $this->resolveCallableOptions($resolvableOptions, $column, $options); + + $options = array_merge($options, $resolvedOptions); + } + + // The "export" option has to inherit options from the column. + if (false !== ($options['export'] ?? false)) { + // Exclude the "export" option, as it's irrelevant to the export options whatsoever. + $inheritedExportOptions = array_diff_key($options, ['export' => true]); + + if (true === $options['export']) { + $options['export'] = $inheritedExportOptions; + } + + // Provided export options should be filled with inherited column options. + // Merging it this way, allows user to override only some export options. + $options['export'] = array_merge( + $this->resolveDefaultOptions($view, $column, $inheritedExportOptions), + $options['export'], + ); + } + + // Apply the formatter at the end of the process. + if (null !== $options['data'] && is_callable($options['formatter'])) { + $options['value'] = $options['formatter']($options['value']); } return $options; diff --git a/src/Column/Type/LinkType.php b/src/Column/Type/LinkType.php index cb243b8c..a3ff3521 100644 --- a/src/Column/Type/LinkType.php +++ b/src/Column/Type/LinkType.php @@ -16,8 +16,8 @@ public function configureOptions(OptionsResolver $resolver): void 'target' => '_self', 'display_icon' => true, ]) - ->setAllowedTypes('href', ['string', 'callable']) - ->setAllowedTypes('target', ['string', 'callable']) + ->setAllowedTypes('href', ['string', \Closure::class]) + ->setAllowedTypes('target', ['string', \Closure::class]) ->setAllowedTypes('display_icon', ['boolean']) ; } diff --git a/src/Resources/views/themes/base.html.twig b/src/Resources/views/themes/base.html.twig index fe56c829..0659b199 100644 --- a/src/Resources/views/themes/base.html.twig +++ b/src/Resources/views/themes/base.html.twig @@ -111,7 +111,7 @@ {% endblock %} {% block kreyu_data_table_column_header %} - {% if data_table.vars.sorting_enabled and sort_field %} + {% if data_table.vars.sorting_enabled and sort %} {% set query_parameters = app.request.query.all() %} {% set sort_parameter_name = data_table.vars.sort_parameter_name %} @@ -123,7 +123,7 @@ {% set attr = { href: path(app.request.get('_route'), app.request.query.all|merge({ - (sort_parameter_name ~ '[field]'): sort_field, + (sort_parameter_name ~ '[field]'): sort, (sort_parameter_name ~ '[direction]'): opposite_sort_direction })) }|merge(attr|default({})) %} @@ -131,7 +131,7 @@ {{ block('kreyu_data_table_column_label', theme, _context) }} - {% if sort_field == current_sort_field %} + {% if sort == current_sort_field %} {% if current_sort_direction == 'asc' %} {{ block('sort_arrow_asc', theme, _context) }} {% else %}