configurable csv/xlsx export templates (#5531)

This commit is contained in:
Kevin Papst
2025-06-11 13:06:33 +02:00
committed by GitHub
parent 46129c7ab9
commit 05aaa1950a
83 changed files with 2632 additions and 412 deletions

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\ExportTemplate;
use App\Form\Type\ExportColumnsType;
use App\Form\Type\ExportRendererType;
use App\Form\Type\LanguageType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ExportTemplateSpreadsheetForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('title', TextType::class);
$builder->add('renderer', ExportRendererType::class, ['label' => 'type']);
$builder->add('language', LanguageType::class, ['required' => false]);
$builder->add('columns', ExportColumnsType::class, ['required' => true]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ExportTemplate::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'export_template_create',
'attr' => [
'data-form-event' => 'kimai.exportTemplate'
],
]);
}
}

View File

@@ -40,18 +40,22 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
$extendedOptions = ['class' => 'selectpicker'];
if ($options['multiple']) {
if (\array_key_exists('multiple', $options) && $options['multiple']) {
$extendedOptions['size'] = 1;
}
if (false !== $options['width']) {
if (\array_key_exists('width', $options) && false !== $options['width']) {
$extendedOptions['data-width'] = $options['width'];
}
if (false === $options['search']) {
if (\array_key_exists('search', $options) && false === $options['search']) {
$extendedOptions['data-disable-search'] = 1;
}
if (\array_key_exists('order', $options) && true === $options['order']) {
$extendedOptions['data-order'] = 1;
}
// there is a very weird logic in vendor/symfony/twig-bridge/Resources/views/Form/form_div_layout.html.twig
// in block "block choice_widget_collapsed" that resets "{% set required = false %}", so we fake it into the select
if (true === $options['required'] && \is_array($options['attr']) && (!\array_key_exists('size', $options['attr']) || $options['attr']['size'] <= 1)) {
@@ -75,5 +79,9 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
$resolver->setDefined(['search']);
$resolver->setAllowedTypes('search', 'boolean');
$resolver->setDefault('search', true);
$resolver->setDefined(['order']);
$resolver->setAllowedTypes('order', 'boolean');
$resolver->setDefault('order', false);
}
}

View File

@@ -0,0 +1,166 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use App\Entity\MetaTableTypeInterface;
use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Event\MetaDisplayEventInterface;
use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
final class ExportColumnsType extends AbstractType
{
/**
* @var array<int, string>
*/
private array $ordered = [];
public function __construct(
private readonly EventDispatcherInterface $dispatcher,
private readonly TranslatorInterface $translator,
)
{
}
public function configureOptions(OptionsResolver $resolver): void
{
$columns = [
'timesheet' => [
'date' => 'date',
'begin' => 'begin',
'end' => 'end',
$this->translator->trans('duration') . ' (1:30)' => 'duration',
$this->translator->trans('duration') . ' (1:30:00)' => 'duration_seconds',
$this->translator->trans('duration') . ' (1.5)' => 'duration_decimal',
'currency' => 'currency',
'rate' => 'rate',
'internalRate' => 'internal_rate',
'hourlyRate' => 'hourly_rate',
'fixedRate' => 'fixed_rate',
'description' => 'description',
'exported' => 'exported',
'billable' => 'billable',
'tags' => 'tags',
'type' => 'type',
'category' => 'category',
],
'user' => [
'alias' => 'user.alias',
'username' => 'user.name',
'account_number' => 'user.account_number',
],
'customer' => [
'customer' => 'customer.name',
'number' => 'customer.number',
'vat_id' => 'customer.vat_id',
],
'project' => [
'project' => 'project.name',
'project_number' => 'project.number',
'orderNumber' => 'project.order_number',
],
'activity' => [
'activity' => 'activity.name',
'activity_number' => 'activity.number',
],
];
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns['timesheet'][$metaField->getLabel()] = 'timesheet.meta.' . $metaField->getName();
}
}
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns['customer'][$metaField->getLabel()] = 'customer.meta.' . $metaField->getName();
}
}
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns['project'][$metaField->getLabel()] = 'project.meta.' . $metaField->getName();
}
}
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns['activity'][$metaField->getLabel()] = 'activity.meta.' . $metaField->getName();
}
}
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);
foreach ($event->getPreferences() as $metaField) {
if ($metaField->getName() !== null) {
$columns['user'][$metaField->getLabel()] = 'user.meta.' . $metaField->getName();
}
}
$resolver->setDefaults([
'choices' => $columns,
'label' => 'modal.columns.label',
'multiple' => true,
// does not work in the frontend
//'order' => true,
]);
}
/**
* @param array<string, mixed> $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) {
$data = $event->getData();
if (\is_array($data)) {
$this->ordered = $data; // @phpstan-ignore assign.propertyType
}
}
);
$builder->addEventListener(
FormEvents::SUBMIT,
function (FormEvent $event) {
$event->setData($this->ordered);
}
);
}
/**
* @return array<MetaTableTypeInterface>
*/
private function findMetaColumns(MetaDisplayEventInterface $event): array
{
$this->dispatcher->dispatch($event);
return $event->getFields();
}
public function getParent(): string
{
return ChoiceType::class;
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class ExportRendererType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'choices' => ['button.csv' => 'csv', 'button.xlsx' => 'xlsx'],
]);
}
public function getParent(): string
{
return ChoiceType::class;
}
}