added project start and end date (#1303)

* added sortable js library
* activity in invoice is optional
* added javascript widget for paginated boxes
* fix activity dropdown for globals only
* added timesheet service to reduce code duplication
* use repository to query for teams in dropdowns
* added project validator
* validate project start and end against timesheet
* include begin and end in dynamic form requests for projects
* added timezone and language option to import flag, improve timesheet import speed
* deactivate cross-timezone filter
* add virtual fields to field order list
* composer update
* added param to ignore dates
* position loader icon fixed - fixes #1330
* permission problem when creating a new project - fixes #1340
* remove dev dependencies webserver and thanks bundle
* stop information leak (begin and end date) in duration mode - fixes #1307
* unify timesheet edit dialog for user and admins
* fix security issue, own rates exposed to unauthorized users in multi-update dialog
This commit is contained in:
Kevin Papst
2020-01-05 02:49:01 +01:00
committed by GitHub
parent 50383673ca
commit d6798eed1e
121 changed files with 2465 additions and 1439 deletions

160
src/Form/FormTrait.php Normal file
View File

@@ -0,0 +1,160 @@
<?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\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Form\Type\ActivityType;
use App\Form\Type\CustomerType;
use App\Form\Type\ProjectType;
use App\Form\Type\TagsInputType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
/**
* Defines the form used to manipulate Timesheet entries.
*/
trait FormTrait
{
protected function addCustomer(FormBuilderInterface $builder, ?Customer $customer = null)
{
$builder
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'data' => $customer ? $customer : '',
'required' => false,
'placeholder' => '',
'mapped' => false,
'project_enabled' => true,
]);
}
protected function addProject(FormBuilderInterface $builder, bool $isNew, ?Project $project = null, ?Customer $customer = null)
{
$builder->add('project', ProjectType::class, [
'placeholder' => '',
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $project, $customer, $isNew) {
$data = $event->getData();
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
$event->getForm()->add('project', ProjectType::class, [
'placeholder' => '',
'activity_enabled' => true,
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) {
// is there a better wa to prevent starting a record with a hidden project ?
if ($isNew && !empty($project) && (is_int($project) || is_string($project))) {
/** @var Project $project */
$project = $repo->find($project);
if (null !== $project) {
if (!$project->getCustomer()->isVisible()) {
$customer = null;
$project = null;
} elseif (!$project->isVisible()) {
$project = null;
}
}
}
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);
}
);
}
protected function addActivity(FormBuilderInterface $builder, ?Activity $activity = null, ?Project $project = null)
{
$builder
->add('activity', ActivityType::class, [
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($activity, $project) {
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $project));
},
])
;
// replaces the activity select after submission, to make sure only activities for the selected project are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($activity) {
$data = $event->getData();
if (!isset($data['project']) || empty($data['project'])) {
return;
}
$event->getForm()->add('activity', ActivityType::class, [
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
if (!empty($activity) && is_string($activity)) {
$activity = $repo->find($activity);
}
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $data['project']));
},
]);
}
);
}
protected function addDescription(FormBuilderInterface $builder)
{
$builder
->add('description', TextareaType::class, [
'label' => 'label.description',
'required' => false,
'attr' => [
'autofocus' => 'autofocus'
]
]);
}
protected function addTags(FormBuilderInterface $builder)
{
$builder
->add('tags', TagsInputType::class, [
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'string',
'description' => 'Comma separated list of tags',
],
'required' => false,
]);
}
}

View File

@@ -196,13 +196,15 @@ class TimesheetMultiUpdate extends AbstractType
]);
}
$builder
->add('fixedRate', FixedRateType::class, [
'currency' => $currency,
])
->add('hourlyRate', HourlyRateType::class, [
'currency' => $currency,
]);
if ($options['include_rate']) {
$builder
->add('fixedRate', FixedRateType::class, [
'currency' => $currency,
])
->add('hourlyRate', HourlyRateType::class, [
'currency' => $currency,
]);
}
$builder->add('entities', HiddenType::class, [
'required' => false,
@@ -241,6 +243,7 @@ class TimesheetMultiUpdate extends AbstractType
'csrf_field_name' => '_token',
'csrf_token_id' => 'timesheet_multiupdate',
'include_user' => false,
'include_rate' => false,
'include_exported' => false,
]);
}

View File

@@ -63,6 +63,14 @@ class ProjectEditForm extends AbstractType
'label' => 'label.orderDate',
'required' => false,
])
->add('start', DateTimePickerType::class, [
'label' => 'label.project_start',
'required' => false,
])
->add('end', DateTimePickerType::class, [
'label' => 'label.project_end',
'required' => false,
])
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);

View File

@@ -9,30 +9,20 @@
namespace App\Form;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Form\Type\ActivityType;
use App\Form\Type\CustomerType;
use App\Form\Type\DateTimePickerType;
use App\Form\Type\DurationType;
use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType;
use App\Form\Type\MetaFieldsCollectionType;
use App\Form\Type\ProjectType;
use App\Form\Type\TagsInputType;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Timesheet\UserDateTimeFactory;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
@@ -43,6 +33,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
class TimesheetEditForm extends AbstractType
{
use FormTrait;
/**
* @var CustomerRepository
*/
@@ -141,7 +133,7 @@ class TimesheetEditForm extends AbstractType
$this->addCustomer($builder, $customer);
}
$this->addProject($builder, $customerCount, $isNew, $project, $customer);
$this->addProject($builder, $isNew, $project, $customer);
$this->addActivity($builder, $activity, $project);
$this->addDescription($builder);
$this->addTags($builder);
@@ -169,114 +161,6 @@ class TimesheetEditForm extends AbstractType
return true;
}
protected function addCustomer(FormBuilderInterface $builder, ?Customer $customer = null)
{
$builder
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'data' => $customer ? $customer : '',
'required' => false,
'placeholder' => '',
'mapped' => false,
'project_enabled' => true,
]);
}
protected function addProject(FormBuilderInterface $builder, int $customerCount, bool $isNew, ?Project $project = null, ?Customer $customer = null)
{
$projectOptions = [];
if ($customerCount < 2) {
$projectOptions['group_by'] = null;
}
$builder
->add(
'project',
ProjectType::class,
array_merge($projectOptions, [
'placeholder' => '',
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
])
);
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $project, $customer, $isNew) {
$data = $event->getData();
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
$event->getForm()->add('project', ProjectType::class, [
'placeholder' => '',
'activity_enabled' => true,
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) {
// is there a better wa to prevent starting a record with a hidden project ?
if ($isNew && !empty($project) && (is_int($project) || is_string($project))) {
/** @var Project $project */
$project = $repo->find($project);
if (null !== $project) {
if (!$project->getCustomer()->isVisible()) {
$customer = null;
$project = null;
} elseif (!$project->isVisible()) {
$project = null;
}
}
}
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);
}
);
}
protected function addActivity(FormBuilderInterface $builder, ?Activity $activity = null, ?Project $project = null)
{
$builder
->add('activity', ActivityType::class, [
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($activity, $project) {
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $project));
},
])
;
// replaces the activity select after submission, to make sure only activities for the selected project are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($activity) {
$data = $event->getData();
if (!isset($data['project']) || empty($data['project'])) {
return;
}
$event->getForm()->add('activity', ActivityType::class, [
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $data['project']));
},
]);
}
);
}
protected function addBegin(FormBuilderInterface $builder, array $dateTimeOptions)
{
$builder->add('begin', DateTimePickerType::class, array_merge($dateTimeOptions, [
@@ -330,31 +214,6 @@ class TimesheetEditForm extends AbstractType
);
}
protected function addDescription(FormBuilderInterface $builder)
{
$builder
->add('description', TextareaType::class, [
'label' => 'label.description',
'required' => false,
'attr' => [
'autofocus' => 'autofocus'
]
]);
}
protected function addTags(FormBuilderInterface $builder)
{
$builder
->add('tags', TagsInputType::class, [
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'string',
'description' => 'Comma separated list of tags for this timesheet record',
],
'required' => false,
]);
}
protected function addRates(FormBuilderInterface $builder, $currency, array $options)
{
if (!$options['include_rate']) {

View File

@@ -71,18 +71,20 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
protected function addCustomerChoice(FormBuilderInterface $builder, bool $required = false)
protected function addCustomerChoice(FormBuilderInterface $builder, array $options = [])
{
// just a fake field for having this field at the right position in the frontend
$builder->add('customer', HiddenType::class);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $required) {
function (FormEvent $event) use ($builder, $options) {
$data = $event->getData();
$event->getForm()->add('customer', CustomerType::class, [
'required' => $required,
$event->getForm()->add('customer', CustomerType::class, array_merge([
'required' => false,
'project_enabled' => true,
'end_date_param' => '%daterange%',
'start_date_param' => '%daterange%',
'query_builder' => function (CustomerRepository $repo) use ($builder, $data) {
$query = new CustomerFormTypeQuery();
$query->setUser($builder->getOption('user'));
@@ -92,7 +94,7 @@ abstract class AbstractToolbarForm extends AbstractType
return $repo->getQueryBuilderForFormType($query);
},
]);
], $options));
}
);
}
@@ -130,19 +132,19 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
protected function addProjectChoice(FormBuilderInterface $builder)
protected function addProjectChoice(FormBuilderInterface $builder, array $options = [])
{
// just a fake field for having this field at the right position in the frontend
$builder->add('project', HiddenType::class);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder) {
function (FormEvent $event) use ($builder, $options) {
$data = $event->getData();
$event->getForm()->add('project', ProjectType::class, [
$event->getForm()->add('project', ProjectType::class, array_merge([
'required' => false,
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($builder, $data) {
'query_builder' => function (ProjectRepository $repo) use ($builder, $data, $options) {
$query = new ProjectFormTypeQuery();
$query->setUser($builder->getOption('user'));
@@ -152,10 +154,13 @@ abstract class AbstractToolbarForm extends AbstractType
if (isset($data['project']) && !empty($data['project'])) {
$query->setProject($data['project']);
}
if (isset($options['ignore_date']) && true === $options['ignore_date']) {
$query->setIgnoreDate(true);
}
return $repo->getQueryBuilderForFormType($query);
},
]);
], $options));
}
);
}
@@ -175,7 +180,12 @@ abstract class AbstractToolbarForm extends AbstractType
$query = new ActivityFormTypeQuery();
if (isset($data['activity']) && !empty($data['activity'])) {
$query->setActivity($data['activity']);
$activity = $data['activity'];
if (is_string($data['activity'])) {
$activity = $repo->find($data['activity']);
}
$query->setActivity($activity);
}
if (isset($data['project']) && !empty($data['project'])) {
$query->setProject($data['project']);

View File

@@ -31,8 +31,8 @@ class ExportToolbarForm extends AbstractToolbarForm
$this->addTimesheetStateChoice($builder);
$this->addUsersChoice($builder);
$this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addCustomerChoice($builder, ['start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true]);
$this->addProjectChoice($builder, ['ignore_date' => true]);
$this->addActivityChoice($builder);
$this->addExportType($builder);
$this->addTagInputField($builder);

View File

@@ -32,8 +32,8 @@ class InvoiceToolbarForm extends AbstractToolbarForm
$this->addUsersChoice($builder);
}
$this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder, true);
$this->addProjectChoice($builder);
$this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => '']);
$this->addProjectChoice($builder, ['ignore_date' => true]);
$this->addActivityChoice($builder);
$this->addTagInputField($builder);
$this->addExportStateChoice($builder);

View File

@@ -39,6 +39,9 @@ class CustomerType extends AbstractType
'choice_label' => 'name',
'query_builder_for_user' => true,
'project_enabled' => false,
'start_date_param' => '%begin%',
'end_date_param' => '%end%',
'ignore_date' => false,
'project_visibility' => ProjectQuery::SHOW_VISIBLE,
]);
@@ -55,11 +58,29 @@ class CustomerType extends AbstractType
$resolver->setDefault('api_data', function (Options $options) {
if (true === $options['project_enabled']) {
$routeParams = ['customer' => '%customer%', 'visible' => $options['project_visibility']];
$emptyRouteParams = ['visible' => $options['project_visibility']];
if (!$options['ignore_date']) {
if (!empty($options['start_date_param'])) {
$routeParams['start'] = $options['start_date_param'];
$emptyRouteParams['start'] = $options['start_date_param'];
}
if (!empty($options['end_date_param'])) {
$routeParams['end'] = $options['end_date_param'];
$emptyRouteParams['end'] = $options['end_date_param'];
}
} else {
$routeParams['ignoreDates'] = 1;
$emptyRouteParams['ignoreDates'] = 1;
}
return [
'select' => 'project',
'route' => 'get_projects',
'route_params' => ['customer' => '-s-', 'visible' => $options['project_visibility']],
'empty_route_params' => ['visible' => $options['project_visibility']],
'route_params' => $routeParams,
'empty_route_params' => $emptyRouteParams,
];
}

View File

@@ -65,6 +65,7 @@ class ProjectType extends AbstractType
'query_builder_for_user' => true,
'activity_enabled' => false,
'activity_visibility' => ActivityQuery::SHOW_VISIBLE,
'ignore_date' => false,
]);
$resolver->setDefault('query_builder', function (Options $options) {
@@ -73,6 +74,9 @@ class ProjectType extends AbstractType
if (true === $options['query_builder_for_user']) {
$query->setUser($options['user']);
}
if (true === $options['ignore_date']) {
$query->setIgnoreDate(true);
}
return $repo->getQueryBuilderForFormType($query);
};
@@ -83,7 +87,7 @@ class ProjectType extends AbstractType
return [
'select' => 'activity',
'route' => 'get_activities',
'route_params' => ['project' => '-s-', 'visible' => $options['activity_visibility']],
'route_params' => ['project' => '%project%', 'visible' => $options['activity_visibility']],
'empty_route_params' => ['globals' => 'true', 'visible' => $options['activity_visibility']],
];
}

View File

@@ -10,9 +10,12 @@
namespace App\Form\Type;
use App\Entity\Team;
use App\Entity\User;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamType extends AbstractType
@@ -25,13 +28,26 @@ class TeamType extends AbstractType
$resolver->setDefaults([
'class' => Team::class,
'label' => 'label.team',
'query_builder' => function (TeamRepository $repo) {
return $repo->createQueryBuilder('t')->orderBy('t.name', 'ASC');
},
'teamlead_only' => true,
'choice_label' => function (Team $team) {
return $team->getName();
},
]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (TeamRepository $repo) use ($options) {
/** @var User $user */
$user = $options['user'];
$query = new TeamQuery();
$query->setCurrentUser($user);
if (!$options['teamlead_only']) {
$query->setTeams($user->getTeams()->toArray());
}
return $repo->getQueryBuilderForFormType($query);
};
});
}
/**

View File

@@ -9,7 +9,7 @@
namespace App\Form\Type;
use App\Repository\Query\VisibilityQuery;
use App\Repository\Query\VisibilityInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -27,9 +27,9 @@ class VisibilityType extends AbstractType
$resolver->setDefaults([
'label' => 'label.visible',
'choices' => [
'both' => VisibilityQuery::SHOW_BOTH,
'yes' => VisibilityQuery::SHOW_VISIBLE,
'no' => VisibilityQuery::SHOW_HIDDEN,
'both' => VisibilityInterface::SHOW_BOTH,
'yes' => VisibilityInterface::SHOW_VISIBLE,
'no' => VisibilityInterface::SHOW_HIDDEN,
],
]);
}