support custom fields for timesheets, customers, projects and activities (#871)

This commit is contained in:
Kevin Papst
2019-06-26 00:39:05 +02:00
committed by GitHub
parent 994c671fd8
commit d8621f0b7a
103 changed files with 2567 additions and 238 deletions

View File

@@ -1,15 +1,16 @@
# Contributing # Contributing
Kimai is an open source project, contributions made by the community are welcome. Kimai is an open source project, contributions made by the community are welcome.
Send us your ideas, code reviews, pull requests and feature requests to help us improve this project. Send your ideas, code reviews, pull requests and feature requests to help improving this project.
## Pull request rules ## Pull request rules
- We use PSR-2 with some additional code-style checks (see our [php-cs-fixer config](.php_cs.dist)). You can run `bin/console kimai:codestyle` to check and `bin/console kimai:codestyle --fix` to fix violations. - Use the [pre-configured codesniffer](.php_cs.dist)) to check for `composer kimai:codestyle` and fix `composer kimai:codestyle-fix` violations
- Add PHPUnit tests for your changes, verify everything still works and execute our test-suites `bin/console kimai:test-unit` and `bin/console kimai:test-integration`. - Add PHPUnit tests for your changes!
- If you contribute new files, please add them with the file-header template from below (our chode-style fixer can do that for you). - Verify everything still works with `composer kimai:tests-unit` and `composer kimai:tests-integration`
- With sending in a PR, you accept that your contributions/code will be published under MIT license (see the [LICENSE](LICENSE) file as well). - If you contribute new files, add them with the file-header template from below (the code-style fixer can do that for you)
- If one of the PR checks fails, please fix them before asking us for a review. - When sending in a PR, you must accept that your contributions/code will be published under MIT license (see the [LICENSE](LICENSE) file as well), otherwise your PR will be closed
- If one of the PR checks/builds fails, fix it before asking for a review
Further documentation can be found in the [developer documentation](https://www.kimai.org/documentation/developers.html). Further documentation can be found in the [developer documentation](https://www.kimai.org/documentation/developers.html).

View File

@@ -7,7 +7,7 @@
[![Gitter](https://badges.gitter.im/kimai2/support.svg)](https://gitter.im/kimai2/support) [![Gitter](https://badges.gitter.im/kimai2/support.svg)](https://gitter.im/kimai2/support)
Kimai is a free, open source and online time-tracking software designed for small businesses and freelancers. Kimai is a free, open source and online time-tracking software designed for small businesses and freelancers.
It is built with modern technologies such as Symfony, Bootstrap, RESTful API, responsive and mobile-ready etc. It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Doctrine, AdminLTE, Webpack, ES6 etc.
## Introduction ## Introduction

View File

@@ -29,7 +29,7 @@ kimai:
# The time-tracking mode that should be used. # The time-tracking mode that should be used.
# See https://www.kimai.org/documentation/timesheet.html#tracking-modes # See https://www.kimai.org/documentation/timesheet.html#tracking-modes
mode: default # mode: default
# The default time to pre-fill the "create timesheet" form (in some cases). # The default time to pre-fill the "create timesheet" form (in some cases).
# This setting is only respected by some timetracking modes and not in all situations. # This setting is only respected by some timetracking modes and not in all situations.

View File

@@ -2,17 +2,21 @@ nelmio_api_doc:
models: models:
use_jms: true use_jms: true
names: names:
- { alias: CustomerEditForm, type: App\Form\CustomerEditForm, groups: [Default, Entity, Customer] } - { alias: CustomerEditForm, type: App\Form\API\CustomerApiEditForm, groups: [Default, Entity, Customer] }
- { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer] } - { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer] }
- { alias: CustomerMetaField, type: App\Entity\CustomerMeta, groups: [Default, Customer] }
- { alias: CustomerCollection, type: App\Entity\Customer, groups: [Default, Collection, Customer] } - { alias: CustomerCollection, type: App\Entity\Customer, groups: [Default, Collection, Customer] }
- { alias: ProjectEditForm, type: App\Form\ProjectEditForm, groups: [Default, Entity, Project] } - { alias: ProjectEditForm, type: App\Form\API\ProjectApiEditForm, groups: [Default, Entity, Project] }
- { alias: ProjectEntity, type: App\Entity\Project, groups: [Default, Entity, Project] } - { alias: ProjectEntity, type: App\Entity\Project, groups: [Default, Entity, Project] }
- { alias: ProjectMetaField, type: App\Entity\ProjectMeta, groups: [Default, Project] }
- { alias: ProjectCollection, type: App\Entity\Project, groups: [Default, Collection, Project] } - { alias: ProjectCollection, type: App\Entity\Project, groups: [Default, Collection, Project] }
- { alias: ActivityEditForm, type: App\Form\ActivityEditForm, groups: [Default, Entity, Activity] } - { alias: ActivityEditForm, type: App\Form\API\ActivityApiEditForm, groups: [Default, Entity, Activity] }
- { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity] } - { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity] }
- { alias: ActivityMetaField, type: App\Entity\ActivityMeta, groups: [Default, Activity] }
- { alias: ActivityCollection, type: App\Entity\Activity, groups: [Default, Collection, Activity] } - { alias: ActivityCollection, type: App\Entity\Activity, groups: [Default, Collection, Activity] }
- { alias: TimesheetEditForm, type: App\Form\TimesheetEditForm, groups: [Default, Entity, Timesheet] } - { alias: TimesheetEditForm, type: App\Form\API\TimesheetApiEditForm, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet] } - { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetMeta, type: App\Entity\TimesheetMeta, groups: [Default, Timesheet] }
- { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] } - { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] }
- { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] } - { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] }
- { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] } - { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] }

View File

@@ -1,6 +1,6 @@
App\Entity\Activity: App\Entity\Activity:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, project, fixedRate, hourlyRate, color, budget, timeBudget] custom_accessor_order: [id, name, comment, visible, project, fixedRate, hourlyRate, color, budget, timeBudget, metaFields]
properties: properties:
id: id:
include: true include: true
@@ -32,9 +32,16 @@ App\Entity\Activity:
groups: [Default] groups: [Default]
color: color:
include: true include: true
metaFields:
exclude: true
virtual_properties: virtual_properties:
getProject: getProject:
serialized_name: project serialized_name: project
exp: "object.getProject() === null ? null : object.getProject().getId()" exp: "object.getProject() === null ? null : object.getProject().getId()"
type: integer type: integer
groups: [Default] groups: [Default]
getMetaFields:
serialized_name: metaFields
exp: "object.getVisibleMetaFields()"
type: array<App\Entity\ActivityMeta>
groups: [Default]

View File

@@ -0,0 +1,15 @@
App\Entity\ActivityMeta:
exclusion_policy: All
custom_accessor_order: [name, value]
properties:
name:
include: false
value:
include: false
virtual_properties:
getName:
serialized_name: name
exp: "object.isVisible() ? object.getName() : null"
getValue:
serialized_name: value
exp: "object.isVisible() ? object.getValue() : null"

View File

@@ -1,6 +1,6 @@
App\Entity\Customer: App\Entity\Customer:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, email, homepage, timezone, fixedRate, hourlyRate, color, budget, timeBudget] custom_accessor_order: [id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, email, homepage, timezone, fixedRate, hourlyRate, color, budget, timeBudget, metaFields]
properties: properties:
id: id:
include: true include: true
@@ -61,3 +61,11 @@ App\Entity\Customer:
groups: [Customer] groups: [Customer]
color: color:
include: true include: true
metaFields:
exclude: true
virtual_properties:
getMetaFields:
serialized_name: metaFields
exp: "object.getVisibleMetaFields()"
type: array<App\Entity\CustomerMeta>
groups: [Default]

View File

@@ -0,0 +1,15 @@
App\Entity\CustomerMeta:
exclusion_policy: All
custom_accessor_order: [name, value]
properties:
name:
include: false
value:
include: false
virtual_properties:
getName:
serialized_name: name
exp: "object.isVisible() ? object.getName() : null"
getValue:
serialized_name: value
exp: "object.isVisible() ? object.getValue() : null"

View File

@@ -1,6 +1,6 @@
App\Entity\Project: App\Entity\Project:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, orderNumber, customer, fixedRate, hourlyRate, color, budget, timeBudget] custom_accessor_order: [id, name, comment, visible, orderNumber, customer, fixedRate, hourlyRate, color, budget, timeBudget, metaFields]
properties: properties:
id: id:
include: true include: true
@@ -30,9 +30,16 @@ App\Entity\Project:
groups: [Subresource] groups: [Subresource]
color: color:
include: true include: true
metaFields:
exclude: true
virtual_properties: virtual_properties:
getCustomer: getCustomer:
serialized_name: customer serialized_name: customer
exp: "object.getCustomer() === null ? null : object.getCustomer().getId()" exp: "object.getCustomer() === null ? null : object.getCustomer().getId()"
type: integer type: integer
groups: [Entity, Collection] groups: [Entity, Collection]
getMetaFields:
serialized_name: metaFields
exp: "object.getVisibleMetaFields()"
type: array<App\Entity\ProjectMeta>
groups: [Default]

View File

@@ -0,0 +1,15 @@
App\Entity\ProjectMeta:
exclusion_policy: All
custom_accessor_order: [name, value]
properties:
name:
include: false
value:
include: false
virtual_properties:
getName:
serialized_name: name
exp: "object.isVisible() ? object.getName() : null"
getValue:
serialized_name: value
exp: "object.isVisible() ? object.getValue() : null"

View File

@@ -1,6 +1,6 @@
App\Entity\Timesheet: App\Entity\Timesheet:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, begin, end, duration, rate, activity, project, user, description, fixedRate, hourlyRate, tags, exported] custom_accessor_order: [id, begin, end, duration, rate, activity, project, user, description, fixedRate, hourlyRate, tags, exported, metaFields]
properties: properties:
id: id:
include: true include: true
@@ -29,6 +29,8 @@ App\Entity\Timesheet:
groups: [Subresource] groups: [Subresource]
user: user:
exclude: true exclude: true
metaFields:
exclude: true
virtual_properties: virtual_properties:
getBegin: getBegin:
serialized_name: begin serialized_name: begin
@@ -53,6 +55,11 @@ App\Entity\Timesheet:
exp: "object.getUser().getId()" exp: "object.getUser().getId()"
type: integer type: integer
getTags: getTags:
serialized_name: tags serialized_name: tags
exp: "object.getTagsAsArray()" exp: "object.getTagsAsArray()"
type: array<string> type: array<string>
getMetaFields:
serialized_name: metaFields
exp: "object.getVisibleMetaFields()"
type: array<App\Entity\TimesheetMeta>
groups: [Default]

View File

@@ -0,0 +1,15 @@
App\Entity\TimesheetMeta:
exclusion_policy: All
custom_accessor_order: [name, value]
properties:
name:
include: false
value:
include: false
virtual_properties:
getName:
serialized_name: name
exp: "object.isVisible() ? object.getName() : null"
getValue:
serialized_name: value
exp: "object.isVisible() ? object.getValue() : null"

View File

@@ -12,7 +12,8 @@ declare(strict_types=1);
namespace App\API; namespace App\API;
use App\Entity\Activity; use App\Entity\Activity;
use App\Form\ActivityEditForm; use App\Event\ActivityMetaDefinitionEvent;
use App\Form\API\ActivityApiEditForm;
use App\Repository\ActivityRepository; use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityQuery; use App\Repository\Query\ActivityQuery;
use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\Controller\Annotations as Rest;
@@ -22,6 +23,7 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -37,20 +39,20 @@ class ActivityController extends BaseApiController
* @var ActivityRepository * @var ActivityRepository
*/ */
protected $repository; protected $repository;
/** /**
* @var ViewHandlerInterface * @var ViewHandlerInterface
*/ */
protected $viewHandler; protected $viewHandler;
/** /**
* @param ViewHandlerInterface $viewHandler * @var EventDispatcherInterface
* @param ActivityRepository $repository
*/ */
public function __construct(ViewHandlerInterface $viewHandler, ActivityRepository $repository) protected $dispatcher;
public function __construct(ViewHandlerInterface $viewHandler, ActivityRepository $repository, EventDispatcherInterface $dispatcher)
{ {
$this->viewHandler = $viewHandler; $this->viewHandler = $viewHandler;
$this->repository = $repository; $this->repository = $repository;
$this->dispatcher = $dispatcher;
} }
/** /**
@@ -133,12 +135,18 @@ class ActivityController extends BaseApiController
*/ */
public function getAction($id) public function getAction($id)
{ {
/** @var Activity $data */
$data = $this->repository->find($id); $data = $this->repository->find($id);
if (null === $data) { if (null === $data) {
throw new NotFoundException(); throw new NotFoundException();
} }
// make sure the fields are properly setup and we know, which meta fields
// should be exposed and which not
$event = new ActivityMetaDefinitionEvent($data);
$this->dispatcher->dispatch(ActivityMetaDefinitionEvent::class, $event);
$view = new View($data, 200); $view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Activity']); $view->getContext()->setGroups(['Default', 'Entity', 'Activity']);
@@ -177,9 +185,7 @@ class ActivityController extends BaseApiController
$activity = new Activity(); $activity = new Activity();
$form = $this->createForm(ActivityEditForm::class, $activity, [ $form = $this->createForm(ActivityApiEditForm::class, $activity);
'csrf_protection' => false,
]);
$form->submit($request->request->all()); $form->submit($request->request->all());
@@ -241,9 +247,7 @@ class ActivityController extends BaseApiController
throw new AccessDeniedHttpException('User cannot update activity'); throw new AccessDeniedHttpException('User cannot update activity');
} }
$form = $this->createForm(ActivityEditForm::class, $activity, [ $form = $this->createForm(ActivityApiEditForm::class, $activity);
'csrf_protection' => false,
]);
$form->setData($activity); $form->setData($activity);
$form->submit($request->request->all(), false); $form->submit($request->request->all(), false);

View File

@@ -12,7 +12,8 @@ declare(strict_types=1);
namespace App\API; namespace App\API;
use App\Entity\Customer; use App\Entity\Customer;
use App\Form\CustomerEditForm; use App\Event\CustomerMetaDefinitionEvent;
use App\Form\API\CustomerApiEditForm;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerQuery; use App\Repository\Query\CustomerQuery;
use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\Controller\Annotations as Rest;
@@ -22,6 +23,7 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -37,20 +39,20 @@ class CustomerController extends BaseApiController
* @var CustomerRepository * @var CustomerRepository
*/ */
protected $repository; protected $repository;
/** /**
* @var ViewHandlerInterface * @var ViewHandlerInterface
*/ */
protected $viewHandler; protected $viewHandler;
/** /**
* @param ViewHandlerInterface $viewHandler * @var EventDispatcherInterface
* @param CustomerRepository $repository
*/ */
public function __construct(ViewHandlerInterface $viewHandler, CustomerRepository $repository) protected $dispatcher;
public function __construct(ViewHandlerInterface $viewHandler, CustomerRepository $repository, EventDispatcherInterface $dispatcher)
{ {
$this->viewHandler = $viewHandler; $this->viewHandler = $viewHandler;
$this->repository = $repository; $this->repository = $repository;
$this->dispatcher = $dispatcher;
} }
/** /**
@@ -111,12 +113,18 @@ class CustomerController extends BaseApiController
*/ */
public function getAction($id) public function getAction($id)
{ {
/** @var Customer $data */
$data = $this->repository->find($id); $data = $this->repository->find($id);
if (null === $data) { if (null === $data) {
throw new NotFoundException(); throw new NotFoundException();
} }
// make sure the fields are properly setup and we know, which meta fields
// should be exposed and which not
$event = new CustomerMetaDefinitionEvent($data);
$this->dispatcher->dispatch(CustomerMetaDefinitionEvent::class, $event);
$view = new View($data, 200); $view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Customer']); $view->getContext()->setGroups(['Default', 'Entity', 'Customer']);
@@ -155,9 +163,7 @@ class CustomerController extends BaseApiController
$customer = new Customer(); $customer = new Customer();
$form = $this->createForm(CustomerEditForm::class, $customer, [ $form = $this->createForm(CustomerApiEditForm::class, $customer);
'csrf_protection' => false,
]);
$form->submit($request->request->all()); $form->submit($request->request->all());
@@ -219,9 +225,7 @@ class CustomerController extends BaseApiController
throw new AccessDeniedHttpException('User cannot update customer'); throw new AccessDeniedHttpException('User cannot update customer');
} }
$form = $this->createForm(CustomerEditForm::class, $customer, [ $form = $this->createForm(CustomerApiEditForm::class, $customer);
'csrf_protection' => false,
]);
$form->setData($customer); $form->setData($customer);
$form->submit($request->request->all(), false); $form->submit($request->request->all(), false);

View File

@@ -12,7 +12,8 @@ declare(strict_types=1);
namespace App\API; namespace App\API;
use App\Entity\Project; use App\Entity\Project;
use App\Form\ProjectEditForm; use App\Event\ProjectMetaDefinitionEvent;
use App\Form\API\ProjectApiEditForm;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery; use App\Repository\Query\ProjectQuery;
use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\Controller\Annotations as Rest;
@@ -22,6 +23,7 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -37,20 +39,20 @@ class ProjectController extends BaseApiController
* @var ProjectRepository * @var ProjectRepository
*/ */
protected $repository; protected $repository;
/** /**
* @var ViewHandlerInterface * @var ViewHandlerInterface
*/ */
protected $viewHandler; protected $viewHandler;
/** /**
* @param ViewHandlerInterface $viewHandler * @var EventDispatcherInterface
* @param ProjectRepository $repository
*/ */
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository) protected $dispatcher;
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository, EventDispatcherInterface $dispatcher)
{ {
$this->viewHandler = $viewHandler; $this->viewHandler = $viewHandler;
$this->repository = $repository; $this->repository = $repository;
$this->dispatcher = $dispatcher;
} }
/** /**
@@ -117,10 +119,18 @@ class ProjectController extends BaseApiController
*/ */
public function getAction($id) public function getAction($id)
{ {
/** @var Project $data */
$data = $this->repository->find($id); $data = $this->repository->find($id);
if (null === $data) { if (null === $data) {
throw new NotFoundException(); throw new NotFoundException();
} }
// make sure the fields are properly setup and we know, which meta fields
// should be exposed and which not
$event = new ProjectMetaDefinitionEvent($data);
$this->dispatcher->dispatch(ProjectMetaDefinitionEvent::class, $event);
$view = new View($data, 200); $view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Project']); $view->getContext()->setGroups(['Default', 'Entity', 'Project']);
@@ -159,9 +169,7 @@ class ProjectController extends BaseApiController
$project = new Project(); $project = new Project();
$form = $this->createForm(ProjectEditForm::class, $project, [ $form = $this->createForm(ProjectApiEditForm::class, $project);
'csrf_protection' => false,
]);
$form->submit($request->request->all()); $form->submit($request->request->all());
@@ -223,9 +231,7 @@ class ProjectController extends BaseApiController
throw new AccessDeniedHttpException('User cannot update project'); throw new AccessDeniedHttpException('User cannot update project');
} }
$form = $this->createForm(ProjectEditForm::class, $project, [ $form = $this->createForm(ProjectApiEditForm::class, $project);
'csrf_protection' => false,
]);
$form->setData($project); $form->setData($project);
$form->submit($request->request->all(), false); $form->submit($request->request->all(), false);

View File

@@ -14,7 +14,7 @@ namespace App\API;
use App\Configuration\TimesheetConfiguration; use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Form\TimesheetEditForm; use App\Form\API\TimesheetApiEditForm;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository; use App\Repository\TagRepository;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
@@ -234,17 +234,18 @@ class TimesheetController extends BaseApiController
*/ */
public function getAction($id) public function getAction($id)
{ {
$timesheet = $this->repository->find($id); /** @var Timesheet $data */
$data = $this->repository->find($id);
if (null === $timesheet) { if (null === $data) {
throw new NotFoundException(); throw new NotFoundException();
} }
if (!$this->isGranted('view', $timesheet)) { if (!$this->isGranted('view', $data)) {
throw new AccessDeniedHttpException('You are not allowed to view this timesheet'); throw new AccessDeniedHttpException('You are not allowed to view this timesheet');
} }
$view = new View($timesheet, 200); $view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view); return $this->viewHandler->handle($view);
@@ -284,13 +285,11 @@ class TimesheetController extends BaseApiController
$mode = $this->getTrackingMode(); $mode = $this->getTrackingMode();
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [ $form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
'csrf_protection' => false,
'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet), 'include_exported' => $this->isGranted('edit_export', $timesheet),
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(), 'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(), 'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
'allow_duration' => false,
'date_format' => self::DATE_FORMAT, 'date_format' => self::DATE_FORMAT,
]); ]);
@@ -366,13 +365,11 @@ class TimesheetController extends BaseApiController
$mode = $this->getTrackingMode(); $mode = $this->getTrackingMode();
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [ $form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
'csrf_protection' => false,
'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet), 'include_exported' => $this->isGranted('edit_export', $timesheet),
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(), 'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(), 'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
'allow_duration' => false,
'date_format' => self::DATE_FORMAT, 'date_format' => self::DATE_FORMAT,
]); ]);

View File

@@ -11,6 +11,7 @@ namespace App\Controller;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Project; use App\Entity\Project;
use App\Event\ActivityMetaDefinitionEvent;
use App\Form\ActivityEditForm; use App\Form\ActivityEditForm;
use App\Form\Toolbar\ActivityToolbarForm; use App\Form\Toolbar\ActivityToolbarForm;
use App\Form\Type\ActivityType; use App\Form\Type\ActivityType;
@@ -19,7 +20,11 @@ use App\Repository\Query\ActivityQuery;
use Doctrine\ORM\ORMException; use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta; use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
/** /**
@@ -34,10 +39,15 @@ class ActivityController extends AbstractController
* @var ActivityRepository * @var ActivityRepository
*/ */
private $repository; private $repository;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
public function __construct(ActivityRepository $repository) public function __construct(ActivityRepository $repository, EventDispatcherInterface $dispatcher)
{ {
$this->repository = $repository; $this->repository = $repository;
$this->dispatcher = $dispatcher;
} }
protected function getRepository(): ActivityRepository protected function getRepository(): ActivityRepository
@@ -52,7 +62,7 @@ class ActivityController extends AbstractController
* *
* @param int $page * @param int $page
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\Response * @return Response
*/ */
public function indexAction($page, Request $request) public function indexAction($page, Request $request)
{ {
@@ -88,7 +98,7 @@ class ActivityController extends AbstractController
* *
* @param Request $request * @param Request $request
* @param Project|null $project * @param Project|null $project
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response * @return RedirectResponse|Response
*/ */
public function createAction(Request $request, ?Project $project = null) public function createAction(Request $request, ?Project $project = null)
{ {
@@ -105,7 +115,7 @@ class ActivityController extends AbstractController
* @Security("is_granted('budget', activity)") * @Security("is_granted('budget', activity)")
* *
* @param Activity $activity * @param Activity $activity
* @return \Symfony\Component\HttpFoundation\Response * @return Response
*/ */
public function budgetAction(Activity $activity) public function budgetAction(Activity $activity)
{ {
@@ -121,7 +131,7 @@ class ActivityController extends AbstractController
* *
* @param Activity $activity * @param Activity $activity
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response * @return RedirectResponse|Response
*/ */
public function editAction(Activity $activity, Request $request) public function editAction(Activity $activity, Request $request)
{ {
@@ -134,7 +144,7 @@ class ActivityController extends AbstractController
* *
* @param Activity $activity * @param Activity $activity
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response * @return RedirectResponse|Response
*/ */
public function deleteAction(Activity $activity, Request $request) public function deleteAction(Activity $activity, Request $request)
{ {
@@ -193,29 +203,32 @@ class ActivityController extends AbstractController
/** /**
* @param Activity $activity * @param Activity $activity
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response * @return RedirectResponse|Response
*/ */
protected function renderActivityForm(Activity $activity, Request $request) protected function renderActivityForm(Activity $activity, Request $request)
{ {
$editForm = $this->createEditForm($activity); $event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch(ActivityMetaDefinitionEvent::class, $event);
$editForm = $this->createEditForm($activity);
$editForm->handleRequest($request); $editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
$entityManager = $this->getDoctrine()->getManager(); try {
$entityManager->persist($activity); $this->getRepository()->saveActivity($activity);
$entityManager->flush(); $this->flashSuccess('action.update.success');
$this->flashSuccess('action.update.success'); if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) {
$newActivity = new Activity();
if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) { $newActivity->setProject($activity->getProject());
$newActivity = new Activity(); $editForm = $this->createEditForm($newActivity);
$newActivity->setProject($activity->getProject()); $editForm->get('create_more')->setData(true);
$editForm = $this->createEditForm($newActivity); $activity = $newActivity;
$editForm->get('create_more')->setData(true); } else {
$activity = $newActivity; return $this->redirectToRoute('admin_activity');
} else { }
return $this->redirectToRoute('admin_activity'); } catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
} }
} }
@@ -230,7 +243,7 @@ class ActivityController extends AbstractController
/** /**
* @param ActivityQuery $query * @param ActivityQuery $query
* @return \Symfony\Component\Form\FormInterface * @return FormInterface
*/ */
protected function getToolbarForm(ActivityQuery $query) protected function getToolbarForm(ActivityQuery $query)
{ {
@@ -244,7 +257,7 @@ class ActivityController extends AbstractController
/** /**
* @param Activity $activity * @param Activity $activity
* @return \Symfony\Component\Form\FormInterface * @return FormInterface
*/ */
private function createEditForm(Activity $activity) private function createEditForm(Activity $activity)
{ {

View File

@@ -11,6 +11,7 @@ namespace App\Controller;
use App\Configuration\FormConfiguration; use App\Configuration\FormConfiguration;
use App\Entity\Customer; use App\Entity\Customer;
use App\Event\CustomerMetaDefinitionEvent;
use App\Form\CustomerEditForm; use App\Form\CustomerEditForm;
use App\Form\Toolbar\CustomerToolbarForm; use App\Form\Toolbar\CustomerToolbarForm;
use App\Form\Type\CustomerType; use App\Form\Type\CustomerType;
@@ -19,7 +20,11 @@ use App\Repository\Query\CustomerQuery;
use Doctrine\ORM\ORMException; use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta; use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
/** /**
@@ -38,15 +43,20 @@ class CustomerController extends AbstractController
* @var FormConfiguration * @var FormConfiguration
*/ */
private $configuration; private $configuration;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
/** /**
* @param CustomerRepository $repository * @param CustomerRepository $repository
* @param FormConfiguration $configuration * @param FormConfiguration $configuration
*/ */
public function __construct(CustomerRepository $repository, FormConfiguration $configuration) public function __construct(CustomerRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{ {
$this->repository = $repository; $this->repository = $repository;
$this->configuration = $configuration; $this->configuration = $configuration;
$this->dispatcher = $dispatcher;
} }
/** /**
@@ -64,7 +74,7 @@ class CustomerController extends AbstractController
* *
* @param int $page * @param int $page
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\Response * @return Response
*/ */
public function indexAction($page, Request $request) public function indexAction($page, Request $request)
{ {
@@ -97,7 +107,7 @@ class CustomerController extends AbstractController
* @Security("is_granted('create_customer')") * @Security("is_granted('create_customer')")
* *
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response * @return RedirectResponse|Response
*/ */
public function createAction(Request $request) public function createAction(Request $request)
{ {
@@ -114,7 +124,7 @@ class CustomerController extends AbstractController
* @Security("is_granted('budget', customer)") * @Security("is_granted('budget', customer)")
* *
* @param Customer $customer * @param Customer $customer
* @return \Symfony\Component\HttpFoundation\Response * @return Response
*/ */
public function budgetAction(Customer $customer) public function budgetAction(Customer $customer)
{ {
@@ -130,7 +140,7 @@ class CustomerController extends AbstractController
* *
* @param Customer $customer * @param Customer $customer
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response * @return RedirectResponse|Response
*/ */
public function editAction(Customer $customer, Request $request) public function editAction(Customer $customer, Request $request)
{ {
@@ -143,7 +153,7 @@ class CustomerController extends AbstractController
* *
* @param Customer $customer * @param Customer $customer
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response * @return RedirectResponse|Response
*/ */
public function deleteAction(Customer $customer, Request $request) public function deleteAction(Customer $customer, Request $request)
{ {
@@ -195,22 +205,26 @@ class CustomerController extends AbstractController
/** /**
* @param Customer $customer * @param Customer $customer
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response * @return RedirectResponse|Response
*/ */
protected function renderCustomerForm(Customer $customer, Request $request) protected function renderCustomerForm(Customer $customer, Request $request)
{ {
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch(CustomerMetaDefinitionEvent::class, $event);
$editForm = $this->createEditForm($customer); $editForm = $this->createEditForm($customer);
$editForm->handleRequest($request); $editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
$entityManager = $this->getDoctrine()->getManager(); try {
$entityManager->persist($customer); $this->getRepository()->saveCustomer($customer);
$entityManager->flush(); $this->flashSuccess('action.update.success');
$this->flashSuccess('action.update.success'); return $this->redirectToRoute('admin_customer');
} catch (ORMException $ex) {
return $this->redirectToRoute('admin_customer'); $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
} }
return $this->render('customer/edit.html.twig', [ return $this->render('customer/edit.html.twig', [
@@ -221,7 +235,7 @@ class CustomerController extends AbstractController
/** /**
* @param CustomerQuery $query * @param CustomerQuery $query
* @return \Symfony\Component\Form\FormInterface * @return FormInterface
*/ */
protected function getToolbarForm(CustomerQuery $query) protected function getToolbarForm(CustomerQuery $query)
{ {
@@ -235,7 +249,7 @@ class CustomerController extends AbstractController
/** /**
* @param Customer $customer * @param Customer $customer
* @return \Symfony\Component\Form\FormInterface * @return FormInterface
*/ */
private function createEditForm(Customer $customer) private function createEditForm(Customer $customer)
{ {

View File

@@ -16,7 +16,9 @@ use App\Repository\Query\ExportQuery;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Timesheet\UserDateTimeFactory; use App\Timesheet\UserDateTimeFactory;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
/** /**
@@ -76,7 +78,7 @@ class ExportController extends AbstractController
* @Security("is_granted('view_export')") * @Security("is_granted('view_export')")
* *
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\Response * @return Response
* @throws \Exception * @throws \Exception
*/ */
public function indexAction(Request $request) public function indexAction(Request $request)
@@ -105,7 +107,7 @@ class ExportController extends AbstractController
* @Security("is_granted('create_export')") * @Security("is_granted('create_export')")
* *
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\Response * @return Response
* @throws \Exception * @throws \Exception
*/ */
public function export(Request $request) public function export(Request $request)
@@ -126,8 +128,6 @@ class ExportController extends AbstractController
$renderer = $this->export->getRendererById($type); $renderer = $this->export->getRendererById($type);
// this code should not be reached, as the query already filters invalid values
// when trying to call setType() with an unknown value
if (null === $renderer) { if (null === $renderer) {
throw $this->createNotFoundException('Unknown export renderer'); throw $this->createNotFoundException('Unknown export renderer');
} }
@@ -141,7 +141,7 @@ class ExportController extends AbstractController
* @param ExportQuery $query * @param ExportQuery $query
* @return Timesheet[] * @return Timesheet[]
*/ */
protected function getEntries(ExportQuery $query) protected function getEntries(ExportQuery $query): array
{ {
$query->getBegin()->setTime(0, 0, 0); $query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59); $query->getEnd()->setTime(23, 59, 59);
@@ -151,9 +151,9 @@ class ExportController extends AbstractController
/** /**
* @param ExportQuery $query * @param ExportQuery $query
* @return \Symfony\Component\Form\FormInterface * @return FormInterface
*/ */
protected function getToolbarForm(ExportQuery $query) protected function getToolbarForm(ExportQuery $query): FormInterface
{ {
return $this->createForm(ExportToolbarForm::class, $query, [ return $this->createForm(ExportToolbarForm::class, $query, [
'action' => $this->generateUrl('export', []), 'action' => $this->generateUrl('export', []),

View File

@@ -11,6 +11,7 @@ namespace App\Controller;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Event\ProjectMetaDefinitionEvent;
use App\Form\ProjectEditForm; use App\Form\ProjectEditForm;
use App\Form\Toolbar\ProjectToolbarForm; use App\Form\Toolbar\ProjectToolbarForm;
use App\Form\Type\ProjectType; use App\Form\Type\ProjectType;
@@ -19,7 +20,11 @@ use App\Repository\Query\ProjectQuery;
use Doctrine\ORM\ORMException; use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta; use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
/** /**
@@ -34,10 +39,15 @@ class ProjectController extends AbstractController
* @var ProjectRepository * @var ProjectRepository
*/ */
private $repository; private $repository;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
public function __construct(ProjectRepository $repository) public function __construct(ProjectRepository $repository, EventDispatcherInterface $dispatcher)
{ {
$this->repository = $repository; $this->repository = $repository;
$this->dispatcher = $dispatcher;
} }
protected function getRepository(): ProjectRepository protected function getRepository(): ProjectRepository
@@ -52,7 +62,7 @@ class ProjectController extends AbstractController
* *
* @param int $page * @param int $page
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\Response * @return Response
*/ */
public function indexAction($page, Request $request) public function indexAction($page, Request $request)
{ {
@@ -88,7 +98,7 @@ class ProjectController extends AbstractController
* *
* @param Request $request * @param Request $request
* @param Customer|null $customer * @param Customer|null $customer
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response * @return RedirectResponse|Response
*/ */
public function createAction(Request $request, ?Customer $customer = null) public function createAction(Request $request, ?Customer $customer = null)
{ {
@@ -106,7 +116,7 @@ class ProjectController extends AbstractController
* @Security("is_granted('budget', project)") * @Security("is_granted('budget', project)")
* *
* @param Project $project * @param Project $project
* @return \Symfony\Component\HttpFoundation\Response * @return Response
*/ */
public function budgetAction(Project $project) public function budgetAction(Project $project)
{ {
@@ -122,7 +132,7 @@ class ProjectController extends AbstractController
* *
* @param Project $project * @param Project $project
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response * @return RedirectResponse|Response
*/ */
public function editAction(Project $project, Request $request) public function editAction(Project $project, Request $request)
{ {
@@ -135,7 +145,7 @@ class ProjectController extends AbstractController
* *
* @param Project $project * @param Project $project
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response * @return RedirectResponse|Response
*/ */
public function deleteAction(Project $project, Request $request) public function deleteAction(Project $project, Request $request)
{ {
@@ -188,29 +198,32 @@ class ProjectController extends AbstractController
/** /**
* @param Project $project * @param Project $project
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response * @return RedirectResponse|Response
*/ */
protected function renderProjectForm(Project $project, Request $request) protected function renderProjectForm(Project $project, Request $request)
{ {
$editForm = $this->createEditForm($project); $event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch(ProjectMetaDefinitionEvent::class, $event);
$editForm = $this->createEditForm($project);
$editForm->handleRequest($request); $editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
$entityManager = $this->getDoctrine()->getManager(); try {
$entityManager->persist($project); $this->getRepository()->saveProject($project);
$entityManager->flush(); $this->flashSuccess('action.update.success');
$this->flashSuccess('action.update.success'); if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) {
$newProject = new Project();
if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) { $newProject->setCustomer($project->getCustomer());
$newProject = new Project(); $editForm = $this->createEditForm($newProject);
$newProject->setCustomer($project->getCustomer()); $editForm->get('create_more')->setData(true);
$editForm = $this->createEditForm($newProject); $project = $newProject;
$editForm->get('create_more')->setData(true); } else {
$project = $newProject; return $this->redirectToRoute('admin_project');
} else { }
return $this->redirectToRoute('admin_project'); } catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
} }
} }
@@ -222,7 +235,7 @@ class ProjectController extends AbstractController
/** /**
* @param ProjectQuery $query * @param ProjectQuery $query
* @return \Symfony\Component\Form\FormInterface * @return FormInterface
*/ */
protected function getToolbarForm(ProjectQuery $query) protected function getToolbarForm(ProjectQuery $query)
{ {
@@ -236,7 +249,7 @@ class ProjectController extends AbstractController
/** /**
* @param Project $project * @param Project $project
* @return \Symfony\Component\Form\FormInterface * @return FormInterface
*/ */
private function createEditForm(Project $project) private function createEditForm(Project $project)
{ {

View File

@@ -12,6 +12,7 @@ namespace App\Controller;
use App\Configuration\TimesheetConfiguration; use App\Configuration\TimesheetConfiguration;
use App\Entity\Tag; use App\Entity\Tag;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Event\TimesheetMetaDefinitionEvent;
use App\Form\TimesheetEditForm; use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetToolbarForm; use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\ActivityRepository; use App\Repository\ActivityRepository;
@@ -23,6 +24,7 @@ use App\Timesheet\TrackingMode\TrackingModeInterface;
use App\Timesheet\TrackingModeService; use App\Timesheet\TrackingModeService;
use App\Timesheet\UserDateTimeFactory; use App\Timesheet\UserDateTimeFactory;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -45,17 +47,23 @@ abstract class TimesheetAbstractController extends AbstractController
* @var TrackingModeService * @var TrackingModeService
*/ */
protected $trackingModeService; protected $trackingModeService;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
public function __construct( public function __construct(
UserDateTimeFactory $dateTime, UserDateTimeFactory $dateTime,
TimesheetConfiguration $configuration, TimesheetConfiguration $configuration,
TimesheetRepository $repository, TimesheetRepository $repository,
TrackingModeService $service TrackingModeService $service,
EventDispatcherInterface $dispatcher
) { ) {
$this->dateTime = $dateTime; $this->dateTime = $dateTime;
$this->configuration = $configuration; $this->configuration = $configuration;
$this->repository = $repository; $this->repository = $repository;
$this->trackingModeService = $service; $this->trackingModeService = $service;
$this->dispatcher = $dispatcher;
} }
protected function getTrackingMode(): TrackingModeInterface protected function getTrackingMode(): TrackingModeInterface
@@ -127,17 +135,21 @@ abstract class TimesheetAbstractController extends AbstractController
*/ */
protected function edit(Timesheet $entry, Request $request, string $renderTemplate) protected function edit(Timesheet $entry, Request $request, string $renderTemplate)
{ {
$event = new TimesheetMetaDefinitionEvent($entry);
$this->dispatcher->dispatch(TimesheetMetaDefinitionEvent::class, $event);
$editForm = $this->getEditForm($entry, $request->get('page')); $editForm = $this->getEditForm($entry, $request->get('page'));
$editForm->handleRequest($request); $editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
$entityManager = $this->getDoctrine()->getManager(); try {
$entityManager->persist($entry); $this->getRepository()->save($entry);
$entityManager->flush(); $this->flashSuccess('action.update.success');
$this->flashSuccess('action.update.success'); return $this->redirectToRoute($this->getTimesheetRoute(), ['page' => $request->get('page', 1)]);
} catch (\Exception $ex) {
return $this->redirectToRoute($this->getTimesheetRoute(), ['page' => $request->get('page', 1)]); $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
} }
return $this->render($renderTemplate, [ return $this->render($renderTemplate, [
@@ -169,6 +181,9 @@ abstract class TimesheetAbstractController extends AbstractController
$entry->setActivity($activity); $entry->setActivity($activity);
} }
$event = new TimesheetMetaDefinitionEvent($entry);
$this->dispatcher->dispatch(TimesheetMetaDefinitionEvent::class, $event);
$mode = $this->getTrackingMode(); $mode = $this->getTrackingMode();
$mode->create($entry, $request); $mode->create($entry, $request);
@@ -176,8 +191,6 @@ abstract class TimesheetAbstractController extends AbstractController
$createForm->handleRequest($request); $createForm->handleRequest($request);
if ($createForm->isSubmitted() && $createForm->isValid()) { if ($createForm->isSubmitted() && $createForm->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
try { try {
if (null === $entry->getEnd()) { if (null === $entry->getEnd()) {
$this->getRepository()->stopActiveEntries( $this->getRepository()->stopActiveEntries(
@@ -185,15 +198,13 @@ abstract class TimesheetAbstractController extends AbstractController
$this->configuration->getActiveEntriesHardLimit() $this->configuration->getActiveEntriesHardLimit()
); );
} }
$entityManager->persist($entry); $this->getRepository()->save($entry);
$entityManager->flush();
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute($this->getTimesheetRoute());
} catch (\Exception $ex) { } catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
} }
return $this->redirectToRoute($this->getTimesheetRoute());
} }
return $this->render($renderTemplate, [ return $this->render($renderTemplate, [

View File

@@ -83,12 +83,6 @@ class Configuration implements ConfigurationInterface
->end() ->end()
->scalarNode('mode') ->scalarNode('mode')
->defaultValue('default') ->defaultValue('default')
->validate()
->ifTrue(function ($value) {
return !in_array($value, ['default', 'duration_only', 'punch']);
})
->thenInvalid('Chosen timesheet mode is invalid, allowed values: default, duration_only, punch')
->end()
->end() ->end()
->booleanNode('markdown_content') ->booleanNode('markdown_content')
->defaultValue(false) ->defaultValue(false)

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\Table(name="kimai2_activities") * @ORM\Table(name="kimai2_activities")
* @ORM\Entity(repositoryClass="App\Repository\ActivityRepository") * @ORM\Entity(repositoryClass="App\Repository\ActivityRepository")
*/ */
class Activity class Activity implements EntityWithMetaFields
{ {
/** /**
* @var int * @var int
@@ -73,9 +73,17 @@ class Activity
use ColorTrait; use ColorTrait;
use BudgetTrait; use BudgetTrait;
/**
* @var ActivityMeta[]|Collection
*
* @ORM\OneToMany(targetEntity="App\Entity\ActivityMeta", mappedBy="activity", cascade={"persist"})
*/
private $meta;
public function __construct() public function __construct()
{ {
$this->timesheets = new ArrayCollection(); $this->timesheets = new ArrayCollection();
$this->meta = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -139,6 +147,55 @@ class Activity
return $this->visible; return $this->visible;
} }
/**
* @internal only here for symfony forms
* @return Collection|MetaTableTypeInterface[]
*/
public function getMetaFields(): Collection
{
return $this->meta;
}
/**
* @return MetaTableTypeInterface[]
*/
public function getVisibleMetaFields(): array
{
$all = [];
foreach ($this->meta as $meta) {
if ($meta->isVisible()) {
$all[] = $meta;
}
}
return $all;
}
public function getMetaField(string $name): ?MetaTableTypeInterface
{
foreach ($this->meta as $field) {
if ($field->getName() === $name) {
return $field;
}
}
return null;
}
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
{
if (null === ($current = $this->getMetaField($meta->getName()))) {
$meta->setEntity($this);
$this->meta->add($meta);
return $this;
}
$current->merge($meta);
return $this;
}
/** /**
* @return string * @return string
*/ */

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* 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\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity()
* @ORM\Table(name="kimai2_activities_meta",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"activity_id", "name"})
* }
* )
*/
class ActivityMeta implements MetaTableTypeInterface
{
use MetaTableTypeTrait;
/**
* @var Activity
*
* @ORM\ManyToOne(targetEntity="App\Entity\Activity", inversedBy="meta")
* @ORM\JoinColumn(onDelete="CASCADE")
* @Assert\NotNull()
*/
private $activity;
public function setEntity(EntityWithMetaFields $entity): MetaTableTypeInterface
{
if (!($entity instanceof Activity)) {
throw new \InvalidArgumentException(
sprintf('Expected instanceof Activity, received "%s"', get_class($entity))
);
}
$this->activity = $entity;
return $this;
}
public function getEntity(): ?EntityWithMetaFields
{
return $this->activity;
}
}

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\Table(name="kimai2_customers") * @ORM\Table(name="kimai2_customers")
* @ORM\Entity(repositoryClass="App\Repository\CustomerRepository") * @ORM\Entity(repositoryClass="App\Repository\CustomerRepository")
*/ */
class Customer class Customer implements EntityWithMetaFields
{ {
public const DEFAULT_CURRENCY = 'EUR'; public const DEFAULT_CURRENCY = 'EUR';
@@ -154,9 +154,17 @@ class Customer
use ColorTrait; use ColorTrait;
use BudgetTrait; use BudgetTrait;
/**
* @var CustomerMeta[]|Collection
*
* @ORM\OneToMany(targetEntity="App\Entity\CustomerMeta", mappedBy="customer", cascade={"persist"})
*/
private $meta;
public function __construct() public function __construct()
{ {
$this->projects = new ArrayCollection(); $this->projects = new ArrayCollection();
$this->meta = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -352,6 +360,55 @@ class Customer
return $this->projects; return $this->projects;
} }
/**
* @internal only here for symfony forms
* @return Collection|MetaTableTypeInterface[]
*/
public function getMetaFields(): Collection
{
return $this->meta;
}
/**
* @return MetaTableTypeInterface[]
*/
public function getVisibleMetaFields(): array
{
$all = [];
foreach ($this->meta as $meta) {
if ($meta->isVisible()) {
$all[] = $meta;
}
}
return $all;
}
public function getMetaField(string $name): ?MetaTableTypeInterface
{
foreach ($this->meta as $field) {
if ($field->getName() === $name) {
return $field;
}
}
return null;
}
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
{
if (null === ($current = $this->getMetaField($meta->getName()))) {
$meta->setEntity($this);
$this->meta->add($meta);
return $this;
}
$current->merge($meta);
return $this;
}
/** /**
* @return string * @return string
*/ */

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* 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\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity()
* @ORM\Table(name="kimai2_customers_meta",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"customer_id", "name"})
* }
* )
*/
class CustomerMeta implements MetaTableTypeInterface
{
use MetaTableTypeTrait;
/**
* @var Customer
*
* @ORM\ManyToOne(targetEntity="App\Entity\Customer", inversedBy="meta")
* @ORM\JoinColumn(onDelete="CASCADE")
* @Assert\NotNull()
*/
private $customer;
public function setEntity(EntityWithMetaFields $entity): MetaTableTypeInterface
{
if (!($entity instanceof Customer)) {
throw new \InvalidArgumentException(
sprintf('Expected instanceof Customer, received "%s"', get_class($entity))
);
}
$this->customer = $entity;
return $this;
}
public function getEntity(): ?EntityWithMetaFields
{
return $this->customer;
}
}

View File

@@ -0,0 +1,25 @@
<?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\Entity;
use Doctrine\Common\Collections\Collection;
interface EntityWithMetaFields
{
/**
* @internal only here for symfony forms
* @return Collection|MetaTableTypeInterface[]
*/
public function getMetaFields(): Collection;
public function getMetaField(string $name): ?MetaTableTypeInterface;
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields;
}

View File

@@ -0,0 +1,136 @@
<?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\Entity;
use Symfony\Component\Validator\Constraint;
interface MetaTableTypeInterface
{
/**
* Returns the name of this entry.
*
* @return string|null
*/
public function getName(): ?string;
/**
* Sets the name of this entry.
*
* @param string $name
* @return MetaTableTypeInterface
*/
public function setName(string $name): MetaTableTypeInterface;
/**
* @return mixed|null
*/
public function getValue();
/**
* Value will not be serialized before its stored, so it should be a primitive type.
*
* @param mixed|null $value
* @return MetaTableTypeInterface
*/
public function setValue($value): MetaTableTypeInterface;
/**
* Get the linked entity.
*
* @return EntityWithMetaFields|null
*/
public function getEntity(): ?EntityWithMetaFields;
/**
* Set the linked entity of this entry.
*
* @param EntityWithMetaFields $entity
* @return MetaTableTypeInterface
*/
public function setEntity(EntityWithMetaFields $entity): MetaTableTypeInterface;
/**
* This will merge the current object with the values from the given $meta instance.
* It should NOT update the name or value, but only the form settings.
*
* @param MetaTableTypeInterface $meta
* @return MetaTableTypeInterface
*/
public function merge(MetaTableTypeInterface $meta): MetaTableTypeInterface;
/**
* Whether this field can be displayed in "public" places like API results or export.
*
* @param bool $include
* @return MetaTableTypeInterface
*/
public function setIsVisible(bool $include): MetaTableTypeInterface;
/**
* Whether this field can be displayed in "public" places like API results or export.
*
* @return bool
*/
public function isVisible(): bool;
/**
* Whether this field is required to be filled out in the form.
*
* @param bool $isRequired
* @return MetaTableTypeInterface
*/
public function setIsRequired(bool $isRequired): MetaTableTypeInterface;
/**
* Whether the form field is required.
*
* @return bool
*/
public function isRequired(): bool;
/**
* The form type for this field.
* If this method returns null, it will not be shown on the form.
*
* @return string|null
*/
public function getType(): ?string;
/**
* Sets the form type.
*
* @param string $type
* @return MetaTableTypeInterface
*/
public function setType(string $type): MetaTableTypeInterface;
/**
* Get all constraints that should be attached to the form type.
*
* @return Constraint[]
*/
public function getConstraints(): array;
/**
* Adds a constraint to the form type.
*
* @param Constraint $constraint
* @return MetaTableTypeInterface
*/
public function addConstraint(Constraint $constraint): MetaTableTypeInterface;
/**
* Sets all constraints for the form type, overwriting all previously attached.
*
* @param Constraint[] $constraints
* @return MetaTableTypeInterface
*/
public function setConstraints(array $constraints): MetaTableTypeInterface;
}

View File

@@ -0,0 +1,179 @@
<?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\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints as Assert;
trait MetaTableTypeTrait
{
/**
* @var int
*
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(name="id", type="integer")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=50, nullable=false)
* @Assert\Length(min=2, max=50)
*/
private $name;
/**
* @var string
*
* @ORM\Column(name="value", type="string", length=255, nullable=true)
*/
private $value;
/**
* @var bool
*
* @ORM\Column(name="visible", type="boolean", nullable=false)
* @Assert\NotNull()
*/
private $visible = false;
/**
* @var string
*/
private $type;
/**
* @var bool
*/
private $required = false;
/**
* @var Constraint[]
*/
private $constraints = [];
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): MetaTableTypeInterface
{
$this->name = $name;
return $this;
}
/**
* @return mixed|null
*/
public function getValue()
{
switch ($this->type) {
case CheckboxType::class:
return (bool) $this->value;
case IntegerType::class:
return (int) $this->value;
}
return $this->value;
}
/**
* Value will not be serialized before its stored, so it should be a primitive type.
*
* @param mixed $value
* @return MetaTableTypeInterface
*/
public function setValue($value): MetaTableTypeInterface
{
$this->value = $value;
return $this;
}
public function setConstraints(array $constraints): MetaTableTypeInterface
{
$this->constraints = [];
foreach ($constraints as $constraint) {
$this->addConstraint($constraint);
}
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): MetaTableTypeInterface
{
$this->type = $type;
return $this;
}
public function addConstraint(Constraint $constraint): MetaTableTypeInterface
{
$this->constraints[] = $constraint;
return $this;
}
/**
* @return Constraint[]
*/
public function getConstraints(): array
{
return $this->constraints;
}
public function isRequired(): bool
{
return $this->required;
}
public function setIsRequired(bool $isRequired): MetaTableTypeInterface
{
$this->required = $isRequired;
return $this;
}
public function isVisible(): bool
{
return $this->visible;
}
public function setIsVisible(bool $visible): MetaTableTypeInterface
{
$this->visible = $visible;
return $this;
}
public function merge(MetaTableTypeInterface $meta): MetaTableTypeInterface
{
$this
->setType($meta->getType())
->setConstraints($meta->getConstraints())
->setIsRequired($meta->isRequired())
->setIsVisible($meta->isVisible());
return $this;
}
}

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\Table(name="kimai2_projects") * @ORM\Table(name="kimai2_projects")
* @ORM\Entity(repositoryClass="App\Repository\ProjectRepository") * @ORM\Entity(repositoryClass="App\Repository\ProjectRepository")
*/ */
class Project class Project implements EntityWithMetaFields
{ {
/** /**
* @var int * @var int
@@ -89,10 +89,18 @@ class Project
*/ */
private $timesheets; private $timesheets;
/**
* @var ProjectMeta[]|Collection
*
* @ORM\OneToMany(targetEntity="App\Entity\ProjectMeta", mappedBy="project", cascade={"persist"})
*/
private $meta;
public function __construct() public function __construct()
{ {
$this->activities = new ArrayCollection(); $this->activities = new ArrayCollection();
$this->timesheets = new ArrayCollection(); $this->timesheets = new ArrayCollection();
$this->meta = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -190,6 +198,55 @@ class Project
return $this; return $this;
} }
/**
* @internal only here for symfony forms
* @return Collection|MetaTableTypeInterface[]
*/
public function getMetaFields(): Collection
{
return $this->meta;
}
/**
* @return MetaTableTypeInterface[]
*/
public function getVisibleMetaFields(): array
{
$all = [];
foreach ($this->meta as $meta) {
if ($meta->isVisible()) {
$all[] = $meta;
}
}
return $all;
}
public function getMetaField(string $name): ?MetaTableTypeInterface
{
foreach ($this->meta as $field) {
if ($field->getName() === $name) {
return $field;
}
}
return null;
}
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
{
if (null === ($current = $this->getMetaField($meta->getName()))) {
$meta->setEntity($this);
$this->meta->add($meta);
return $this;
}
$current->merge($meta);
return $this;
}
/** /**
* @return string * @return string
*/ */

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* 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\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity()
* @ORM\Table(name="kimai2_projects_meta",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"project_id", "name"})
* }
* )
*/
class ProjectMeta implements MetaTableTypeInterface
{
use MetaTableTypeTrait;
/**
* @var Project
*
* @ORM\ManyToOne(targetEntity="App\Entity\Project", inversedBy="meta")
* @ORM\JoinColumn(onDelete="CASCADE")
* @Assert\NotNull()
*/
private $project;
public function setEntity(EntityWithMetaFields $entity): MetaTableTypeInterface
{
if (!($entity instanceof Project)) {
throw new \InvalidArgumentException(
sprintf('Expected instanceof Project, received "%s"', get_class($entity))
);
}
$this->project = $entity;
return $this;
}
public function getEntity(): ?EntityWithMetaFields
{
return $this->project;
}
}

View File

@@ -25,7 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\HasLifecycleCallbacks() * @ORM\HasLifecycleCallbacks()
* @App\Validator\Constraints\Timesheet * @App\Validator\Constraints\Timesheet
*/ */
class Timesheet class Timesheet implements EntityWithMetaFields
{ {
/** /**
* @var int * @var int
@@ -138,7 +138,14 @@ class Timesheet
* } * }
* ) * )
*/ */
protected $tags; private $tags;
/**
* @var TimesheetMeta[]|Collection
*
* @ORM\OneToMany(targetEntity="App\Entity\TimesheetMeta", mappedBy="timesheet", cascade={"persist"})
*/
private $meta;
/** /**
* Default constructor, initializes collections * Default constructor, initializes collections
@@ -146,6 +153,7 @@ class Timesheet
public function __construct() public function __construct()
{ {
$this->tags = new ArrayCollection(); $this->tags = new ArrayCollection();
$this->meta = new ArrayCollection();
} }
/** /**
@@ -405,6 +413,7 @@ class Timesheet
/** /**
* BE WARNED: this method should NOT be used programmatically, there is very likely no reason for it! * BE WARNED: this method should NOT be used programmatically, there is very likely no reason for it!
* *
* @internal
* @deprecated since it was introduced, only meant for the initial migration. Will be removed with 1.0. * @deprecated since it was introduced, only meant for the initial migration. Will be removed with 1.0.
* @param string $timezone * @param string $timezone
* @return Timesheet * @return Timesheet
@@ -415,4 +424,53 @@ class Timesheet
return $this; return $this;
} }
/**
* @internal only here for symfony forms
* @return Collection|MetaTableTypeInterface[]
*/
public function getMetaFields(): Collection
{
return $this->meta;
}
/**
* @return MetaTableTypeInterface[]
*/
public function getVisibleMetaFields(): array
{
$all = [];
foreach ($this->meta as $meta) {
if ($meta->isVisible()) {
$all[] = $meta;
}
}
return $all;
}
public function getMetaField(string $name): ?MetaTableTypeInterface
{
foreach ($this->meta as $field) {
if ($field->getName() === $name) {
return $field;
}
}
return null;
}
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
{
if (null === ($current = $this->getMetaField($meta->getName()))) {
$meta->setEntity($this);
$this->meta->add($meta);
return $this;
}
$current->merge($meta);
return $this;
}
} }

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* 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\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity()
* @ORM\Table(name="kimai2_timesheet_meta",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"timesheet_id", "name"})
* }
* )
*/
class TimesheetMeta implements MetaTableTypeInterface
{
use MetaTableTypeTrait;
/**
* @var Timesheet
*
* @ORM\ManyToOne(targetEntity="App\Entity\Timesheet", inversedBy="meta")
* @ORM\JoinColumn(onDelete="CASCADE")
* @Assert\NotNull()
*/
private $timesheet;
public function setEntity(EntityWithMetaFields $entity): MetaTableTypeInterface
{
if (!($entity instanceof Timesheet)) {
throw new \InvalidArgumentException(
sprintf('Expected instanceof Timesheet, received "%s"', get_class($entity))
);
}
$this->timesheet = $entity;
return $this;
}
public function getEntity(): ?EntityWithMetaFields
{
return $this->timesheet;
}
}

View File

@@ -0,0 +1,34 @@
<?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\Event;
use App\Entity\Activity;
use Symfony\Component\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta fields to activities
*/
final class ActivityMetaDefinitionEvent extends Event
{
/**
* @var Activity
*/
protected $entity;
public function __construct(Activity $entity)
{
$this->entity = $entity;
}
public function getEntity(): Activity
{
return $this->entity;
}
}

View File

@@ -16,7 +16,7 @@ use Symfony\Component\HttpFoundation\Request;
/** /**
* The ConfigureAdminMenuEvent is used for populating the administration navigation. * The ConfigureAdminMenuEvent is used for populating the administration navigation.
*/ */
class ConfigureAdminMenuEvent extends Event final class ConfigureAdminMenuEvent extends Event
{ {
public const CONFIGURE = 'app.admin_menu_configure'; public const CONFIGURE = 'app.admin_menu_configure';

View File

@@ -17,7 +17,7 @@ use Symfony\Component\HttpFoundation\Request;
/** /**
* The ConfigureMainMenuEvent is used for populating the main navigation. * The ConfigureMainMenuEvent is used for populating the main navigation.
*/ */
class ConfigureMainMenuEvent extends Event final class ConfigureMainMenuEvent extends Event
{ {
public const CONFIGURE = 'app.main_menu_configure'; public const CONFIGURE = 'app.main_menu_configure';

View File

@@ -0,0 +1,34 @@
<?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\Event;
use App\Entity\Customer;
use Symfony\Component\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta fields to customers
*/
final class CustomerMetaDefinitionEvent extends Event
{
/**
* @var Customer
*/
protected $entity;
public function __construct(Customer $entity)
{
$this->entity = $entity;
}
public function getEntity(): Customer
{
return $this->entity;
}
}

View File

@@ -13,7 +13,7 @@ use App\Entity\User;
use App\Widget\WidgetContainerInterface; use App\Widget\WidgetContainerInterface;
use Symfony\Component\EventDispatcher\Event; use Symfony\Component\EventDispatcher\Event;
class DashboardEvent extends Event final class DashboardEvent extends Event
{ {
public const DASHBOARD = 'app.dashboard'; public const DASHBOARD = 'app.dashboard';

View File

@@ -13,9 +13,9 @@ use App\Entity\User;
use Symfony\Component\EventDispatcher\Event; use Symfony\Component\EventDispatcher\Event;
/** /**
* This event should be used, if a user profile is loaded and we full data including dynamic user preferences * This event should be used, if a user profile is loaded and want to fill the dynamic user preferences
*/ */
class PrepareUserEvent extends Event final class PrepareUserEvent extends Event
{ {
public const PREPARE = 'app.prepare_user'; public const PREPARE = 'app.prepare_user';

View File

@@ -0,0 +1,34 @@
<?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\Event;
use App\Entity\Project;
use Symfony\Component\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta fields to projects
*/
final class ProjectMetaDefinitionEvent extends Event
{
/**
* @var Project
*/
protected $entity;
public function __construct(Project $entity)
{
$this->entity = $entity;
}
public function getEntity(): Project
{
return $this->entity;
}
}

View File

@@ -15,7 +15,7 @@ use Symfony\Component\EventDispatcher\Event;
/** /**
* This event should be used, if system configurations should be changed/added dynamically. * This event should be used, if system configurations should be changed/added dynamically.
*/ */
class SystemConfigurationEvent extends Event final class SystemConfigurationEvent extends Event
{ {
public const CONFIGURE = 'app.system_configuration'; public const CONFIGURE = 'app.system_configuration';

View File

@@ -12,7 +12,7 @@ namespace App\Event;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\EventDispatcher\Event; use Symfony\Component\EventDispatcher\Event;
class ThemeEvent extends Event final class ThemeEvent extends Event
{ {
public const JAVASCRIPT = 'app.theme.javascript'; public const JAVASCRIPT = 'app.theme.javascript';
public const STYLESHEET = 'app.theme.css'; public const STYLESHEET = 'app.theme.css';

View File

@@ -0,0 +1,34 @@
<?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\Event;
use App\Entity\Timesheet;
use Symfony\Component\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta fields to timesheets
*/
final class TimesheetMetaDefinitionEvent extends Event
{
/**
* @var Timesheet
*/
protected $entity;
public function __construct(Timesheet $entity)
{
$this->entity = $entity;
}
public function getEntity(): Timesheet
{
return $this->entity;
}
}

View File

@@ -16,7 +16,7 @@ use Symfony\Component\EventDispatcher\Event;
/** /**
* This event should be used, if further user preferences should added dynamically * This event should be used, if further user preferences should added dynamically
*/ */
class UserPreferenceEvent extends Event final class UserPreferenceEvent extends Event
{ {
public const CONFIGURE = 'app.user_preferences'; public const CONFIGURE = 'app.user_preferences';

View File

@@ -26,12 +26,10 @@ abstract class AbstractSpreadsheetRenderer
* @var DateExtensions * @var DateExtensions
*/ */
protected $dateExtension; protected $dateExtension;
/** /**
* @var Extensions * @var Extensions
*/ */
protected $extension; protected $extension;
/** /**
* @var TranslatorInterface * @var TranslatorInterface
*/ */
@@ -42,8 +40,11 @@ abstract class AbstractSpreadsheetRenderer
* @param DateExtensions $dateExtension * @param DateExtensions $dateExtension
* @param Extensions $extensions * @param Extensions $extensions
*/ */
public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension, Extensions $extensions) public function __construct(
{ TranslatorInterface $translator,
DateExtensions $dateExtension,
Extensions $extensions
) {
$this->translator = $translator; $this->translator = $translator;
$this->dateExtension = $dateExtension; $this->dateExtension = $dateExtension;
$this->extension = $extensions; $this->extension = $extensions;
@@ -97,6 +98,15 @@ abstract class AbstractSpreadsheetRenderer
*/ */
protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet
{ {
$publicMetaFields = [];
foreach ($timesheets as $timesheet) {
foreach ($timesheet->getVisibleMetaFields() as $metaField) {
$publicMetaFields[] = $metaField->getName();
}
}
$publicMetaFields = array_unique($publicMetaFields);
$spreadsheet = new Spreadsheet(); $spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet(); $sheet = $spreadsheet->getActiveSheet();
@@ -112,10 +122,13 @@ abstract class AbstractSpreadsheetRenderer
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.description')); $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.description'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.exported')); $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.exported'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.tags')); $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.tags'));
foreach ($publicMetaFields as $metaFieldName) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaFieldName));
}
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.hourlyRate')); $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.hourlyRate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.fixedRate')); $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.fixedRate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration')); $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.rate')); $sheet->setCellValueByColumnAndRow($recordsHeaderColumn, $recordsHeaderRow, $this->translator->trans('label.rate'));
$entryHeaderRow = $recordsHeaderRow + 1; $entryHeaderRow = $recordsHeaderRow + 1;
@@ -148,21 +161,32 @@ abstract class AbstractSpreadsheetRenderer
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getDescription()); $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getDescription());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->translator->trans($exported)); $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->translator->trans($exported));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, implode(',', $timesheet->getTagsAsArray())); $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, implode(',', $timesheet->getTagsAsArray()));
foreach ($publicMetaFields as $metaFieldName) {
$metaField = $timesheet->getMetaField($metaFieldName);
$metaFieldValue = '';
if (null !== $metaField && $metaField->isVisible()) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getHourlyRate(), $customerCurrency)); $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getHourlyRate(), $customerCurrency));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getFixedRate(), $customerCurrency)); $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getFixedRate(), $customerCurrency));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDuration($timesheet->getDuration())); $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDuration($timesheet->getDuration()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getRate(), $customerCurrency)); $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $this->getFormattedMoney($timesheet->getRate(), $customerCurrency));
$entryHeaderRow++; $entryHeaderRow++;
} }
$sheet->setCellValueByColumnAndRow(12, $entryHeaderRow, $this->getFormattedDuration($durationTotal)); $cellDurationTotal = $recordsHeaderColumn - 1;
$sheet->setCellValueByColumnAndRow(13, $entryHeaderRow, $this->getFormattedMoney($rateTotal, $currency)); $cellRateTotal = $recordsHeaderColumn;
$sheet->getCellByColumnAndRow(12, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$sheet->getCellByColumnAndRow(12, $entryHeaderRow)->getStyle()->getFont()->setBold(true);
$sheet->getCellByColumnAndRow(13, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN); $sheet->setCellValueByColumnAndRow($cellDurationTotal, $entryHeaderRow, $this->getFormattedDuration($durationTotal));
$sheet->getCellByColumnAndRow(13, $entryHeaderRow)->getStyle()->getFont()->setBold(true); $sheet->setCellValueByColumnAndRow($cellRateTotal, $entryHeaderRow, $this->getFormattedMoney($rateTotal, $currency));
$sheet->getCellByColumnAndRow($cellDurationTotal, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$sheet->getCellByColumnAndRow($cellDurationTotal, $entryHeaderRow)->getStyle()->getFont()->setBold(true);
$sheet->getCellByColumnAndRow($cellRateTotal, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$sheet->getCellByColumnAndRow($cellRateTotal, $entryHeaderRow)->getStyle()->getFont()->setBold(true);
return $spreadsheet; return $spreadsheet;
} }

View File

@@ -13,7 +13,7 @@ use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface final class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{ {
/** /**
* @return string * @return string

View File

@@ -15,7 +15,7 @@ use App\Repository\Query\TimesheetQuery;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Twig\Environment; use Twig\Environment;
class HtmlRenderer implements RendererInterface final class HtmlRenderer implements RendererInterface
{ {
use RendererTrait; use RendererTrait;
@@ -24,9 +24,6 @@ class HtmlRenderer implements RendererInterface
*/ */
protected $twig; protected $twig;
/**
* @param Environment $twig
*/
public function __construct(Environment $twig) public function __construct(Environment $twig)
{ {
$this->twig = $twig; $this->twig = $twig;
@@ -42,9 +39,17 @@ class HtmlRenderer implements RendererInterface
*/ */
public function render(array $timesheets, TimesheetQuery $query): Response public function render(array $timesheets, TimesheetQuery $query): Response
{ {
$publicMetaFields = [];
foreach ($timesheets as $timesheet) {
foreach ($timesheet->getVisibleMetaFields() as $metaField) {
$publicMetaFields[] = $metaField->getName();
}
}
$content = $this->twig->render('export/renderer/default.html.twig', [ $content = $this->twig->render('export/renderer/default.html.twig', [
'entries' => $timesheets, 'entries' => $timesheets,
'query' => $query, 'query' => $query,
'metaFields' => array_unique($publicMetaFields),
'summaries' => $this->calculateSummary($timesheets), 'summaries' => $this->calculateSummary($timesheets),
]); ]);

View File

@@ -13,7 +13,7 @@ use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface final class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{ {
/** /**
* @return string * @return string

View File

@@ -18,7 +18,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Twig\Environment; use Twig\Environment;
class PDFRenderer implements RendererInterface final class PDFRenderer implements RendererInterface
{ {
use RendererTrait; use RendererTrait;

View File

@@ -13,7 +13,7 @@ use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface final class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{ {
/** /**
* @return string * @return string

View File

@@ -0,0 +1,40 @@
<?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\API;
use App\Form\ActivityEditForm;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ActivityApiEditForm extends ActivityEditForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder->remove('metaFields');
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'csrf_protection' => false,
'create_more' => false,
]);
}
}

View File

@@ -0,0 +1,39 @@
<?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\API;
use App\Form\CustomerEditForm;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CustomerApiEditForm extends CustomerEditForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder->remove('metaFields');
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'csrf_protection' => false,
]);
}
}

View File

@@ -0,0 +1,40 @@
<?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\API;
use App\Form\ProjectEditForm;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectApiEditForm extends ProjectEditForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder->remove('metaFields');
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'csrf_protection' => false,
'create_more' => false,
]);
}
}

View File

@@ -0,0 +1,37 @@
<?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\API;
use App\Form\TimesheetEditForm;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TimesheetApiEditForm extends TimesheetEditForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder->remove('metaFields');
}
public function configureOptions(OptionsResolver $resolver)
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'csrf_protection' => false,
'allow_duration' => false,
]);
}
}

View File

@@ -13,6 +13,7 @@ use App\Form\Type\ColorPickerType;
use App\Form\Type\DurationType; use App\Form\Type\DurationType;
use App\Form\Type\FixedRateType; use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType; use App\Form\Type\HourlyRateType;
use App\Form\Type\MetaFieldsCollectionType;
use App\Form\Type\YesNoType; use App\Form\Type\YesNoType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType; use Symfony\Component\Form\Extension\Core\Type\MoneyType;
@@ -47,6 +48,8 @@ trait EntityFormTrait
; ;
} }
$builder->add('metaFields', MetaFieldsCollectionType::class);
$builder $builder
->add('visible', YesNoType::class, [ ->add('visible', YesNoType::class, [
'label' => 'label.visible', 'label' => 'label.visible',

View File

@@ -19,6 +19,7 @@ use App\Form\Type\DateTimePickerType;
use App\Form\Type\DurationType; use App\Form\Type\DurationType;
use App\Form\Type\FixedRateType; use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType; use App\Form\Type\HourlyRateType;
use App\Form\Type\MetaFieldsCollectionType;
use App\Form\Type\ProjectType; use App\Form\Type\ProjectType;
use App\Form\Type\TagsInputType; use App\Form\Type\TagsInputType;
use App\Form\Type\UserType; use App\Form\Type\UserType;
@@ -134,6 +135,8 @@ class TimesheetEditForm extends AbstractType
$this->addTags($builder); $this->addTags($builder);
$this->addRates($builder, $currency, $options); $this->addRates($builder, $currency, $options);
$this->addUser($builder, $options); $this->addUser($builder, $options);
$builder->add('metaFields', MetaFieldsCollectionType::class);
$this->addExported($builder, $options); $this->addExported($builder, $options);
} }

View File

@@ -0,0 +1,60 @@
<?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 Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to edit entity meta field.
*/
class EntityMetaDefinitionType extends AbstractType
{
/**
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) {
/** @var MetaTableTypeInterface $definition */
$definition = $event->getData();
if (!($definition instanceof MetaTableTypeInterface)) {
return;
}
// prevents unconfigured values from showing up in the form
if (null === $definition->getType()) {
return;
}
$event->getForm()->add('value', $definition->getType(), [
'label' => $definition->getName(),
'constraints' => $definition->getConstraints(),
'required' => $definition->isRequired(),
]);
}
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => MetaTableTypeInterface::class,
]);
}
}

View File

@@ -0,0 +1,39 @@
<?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\CollectionType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to edit entity meta fields.
*/
class MetaFieldsCollectionType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'entry_type' => EntityMetaDefinitionType::class,
'entry_options' => ['label' => false],
'allow_add' => false,
'allow_delete' => false,
'label' => false,
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return CollectionType::class;
}
}

View File

@@ -82,6 +82,7 @@ trait RendererTrait
{ {
$customer = $model->getCustomer(); $customer = $model->getCustomer();
$project = $model->getQuery()->getProject(); $project = $model->getQuery()->getProject();
$activity = $model->getQuery()->getActivity();
$currency = $model->getCalculator()->getCurrency(); $currency = $model->getCalculator()->getCurrency();
$values = [ $values = [
@@ -108,6 +109,20 @@ trait RendererTrait
'query.year' => $model->getQuery()->getBegin()->format('Y'), 'query.year' => $model->getQuery()->getBegin()->format('Y'),
]; ];
if (null !== $activity) {
$values = array_merge($values, [
'activity.id' => $activity->getId(),
'activity.name' => $activity->getName(),
'activity.comment' => $activity->getComment(),
]);
foreach ($activity->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'activity.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
}
if (null !== $project) { if (null !== $project) {
$values = array_merge($values, [ $values = array_merge($values, [
'project.id' => $project->getId(), 'project.id' => $project->getId(),
@@ -115,6 +130,12 @@ trait RendererTrait
'project.comment' => $project->getComment(), 'project.comment' => $project->getComment(),
'project.order_number' => $project->getOrderNumber(), 'project.order_number' => $project->getOrderNumber(),
]); ]);
foreach ($project->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'project.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
} }
if (null !== $customer) { if (null !== $customer) {
@@ -129,6 +150,12 @@ trait RendererTrait
'customer.homepage' => $customer->getHomepage(), 'customer.homepage' => $customer->getHomepage(),
'customer.comment' => $customer->getComment(), 'customer.comment' => $customer->getComment(),
]); ]);
foreach ($customer->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'customer.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
} }
return $values; return $values;
@@ -169,7 +196,7 @@ trait RendererTrait
$begin = $timesheet->getBegin(); $begin = $timesheet->getBegin();
$end = $timesheet->getEnd(); $end = $timesheet->getEnd();
return [ $values = [
'entry.row' => '', 'entry.row' => '',
'entry.description' => $description, 'entry.description' => $description,
'entry.amount' => $amount, 'entry.amount' => $amount,
@@ -196,6 +223,14 @@ trait RendererTrait
'entry.customer' => $customer->getName(), 'entry.customer' => $customer->getName(),
'entry.customer_id' => $customer->getId(), 'entry.customer_id' => $customer->getId(),
]; ];
foreach ($timesheet->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'entry.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
return $values;
} }
/** /**

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Creates meta tables to store custom fields for entities
*
* @version 1.0
*/
final class Version20190617100845 extends AbstractMigration
{
public function getDescription(): string
{
return 'Creates meta tables to store custom fields for entities';
}
public function up(Schema $schema): void
{
$timesheetMeta = $schema->createTable('kimai2_timesheet_meta');
$timesheetMeta->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$timesheetMeta->addColumn('timesheet_id', 'integer', ['notnull' => true]);
$timesheetMeta->addColumn('name', 'string', ['notnull' => true, 'length' => 50]);
$timesheetMeta->addColumn('value', 'string', ['notnull' => false, 'length' => 255]);
$timesheetMeta->addColumn('visible', 'boolean', ['notnull' => false, 'default' => false]);
$timesheetMeta->setPrimaryKey(['id']);
$timesheetMeta->addIndex(['timesheet_id'], 'IDX_CB606CBAABDD46BE');
$timesheetMeta->addUniqueIndex(['timesheet_id', 'name'], 'UNIQ_CB606CBAABDD46BE5E237E06');
$timesheetMeta->addForeignKeyConstraint('kimai2_timesheet', ['timesheet_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_CB606CBAABDD46BE');
$customerMeta = $schema->createTable('kimai2_customers_meta');
$customerMeta->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$customerMeta->addColumn('customer_id', 'integer', ['notnull' => true]);
$customerMeta->addColumn('name', 'string', ['notnull' => true, 'length' => 50]);
$customerMeta->addColumn('value', 'string', ['notnull' => false, 'length' => 255]);
$customerMeta->addColumn('visible', 'boolean', ['notnull' => false, 'default' => false]);
$customerMeta->setPrimaryKey(['id']);
$customerMeta->addIndex(['customer_id'], 'IDX_A48A760F9395C3F3');
$customerMeta->addUniqueIndex(['customer_id', 'name'], 'UNIQ_A48A760F9395C3F35E237E06');
$customerMeta->addForeignKeyConstraint('kimai2_customers', ['customer_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_A48A760F9395C3F3');
$projectMeta = $schema->createTable('kimai2_projects_meta');
$projectMeta->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$projectMeta->addColumn('project_id', 'integer', ['notnull' => true]);
$projectMeta->addColumn('name', 'string', ['notnull' => true, 'length' => 50]);
$projectMeta->addColumn('value', 'string', ['notnull' => false, 'length' => 255]);
$projectMeta->addColumn('visible', 'boolean', ['notnull' => false, 'default' => false]);
$projectMeta->setPrimaryKey(['id']);
$projectMeta->addIndex(['project_id'], 'IDX_50536EF2166D1F9C');
$projectMeta->addUniqueIndex(['project_id', 'name'], 'UNIQ_50536EF2166D1F9C5E237E06');
$projectMeta->addForeignKeyConstraint('kimai2_projects', ['project_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_50536EF2166D1F9C');
$activityMeta = $schema->createTable('kimai2_activities_meta');
$activityMeta->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$activityMeta->addColumn('activity_id', 'integer', ['notnull' => true]);
$activityMeta->addColumn('name', 'string', ['notnull' => true, 'length' => 50]);
$activityMeta->addColumn('value', 'string', ['notnull' => false, 'length' => 255]);
$activityMeta->addColumn('visible', 'boolean', ['notnull' => false, 'default' => false]);
$activityMeta->setPrimaryKey(['id']);
$activityMeta->addIndex(['activity_id'], 'IDX_A7C0A43D81C06096');
$activityMeta->addUniqueIndex(['activity_id', 'name'], 'UNIQ_A7C0A43D81C060965E237E06');
$activityMeta->addForeignKeyConstraint('kimai2_activities', ['activity_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_A7C0A43D81C06096');
}
public function down(Schema $schema): void
{
$schema->dropTable('kimai2_timesheet_meta');
$schema->dropTable('kimai2_customers_meta');
$schema->dropTable('kimai2_projects_meta');
$schema->dropTable('kimai2_activities_meta');
}
}

View File

@@ -21,6 +21,18 @@ use Pagerfanta\Pagerfanta;
class ActivityRepository extends AbstractRepository class ActivityRepository extends AbstractRepository
{ {
/**
* @param Activity $activity
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveActivity(Activity $activity)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($activity);
$entityManager->flush();
}
/** /**
* @param int $id * @param int $id
* @return null|Activity * @return null|Activity

View File

@@ -22,6 +22,18 @@ use Pagerfanta\Pagerfanta;
class CustomerRepository extends AbstractRepository class CustomerRepository extends AbstractRepository
{ {
/**
* @param Customer $customer
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveCustomer(Customer $customer)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($customer);
$entityManager->flush();
}
/** /**
* @param int $id * @param int $id
* @return null|Customer * @return null|Customer

View File

@@ -82,7 +82,6 @@ final class TimesheetIdLoader implements LoaderInterface
->getQuery() ->getQuery()
->execute(); ->execute();
/*
$qb = $em->createQueryBuilder(); $qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'meta') $qb->select('PARTIAL t.{id}', 'meta')
->from(Timesheet::class, 't') ->from(Timesheet::class, 't')
@@ -90,6 +89,5 @@ final class TimesheetIdLoader implements LoaderInterface
->andWhere($qb->expr()->in('t.id', $ids)) ->andWhere($qb->expr()->in('t.id', $ids))
->getQuery() ->getQuery()
->execute(); ->execute();
*/
} }
} }

View File

@@ -25,6 +25,18 @@ use Pagerfanta\Pagerfanta;
*/ */
class ProjectRepository extends AbstractRepository class ProjectRepository extends AbstractRepository
{ {
/**
* @param Project $project
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveProject(Project $project)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($project);
$entityManager->flush();
}
/** /**
* @param int $id * @param int $id
* @return null|Project * @return null|Project

View File

@@ -14,7 +14,7 @@ use App\Entity\Timesheet;
use App\Timesheet\UserDateTimeFactory; use App\Timesheet\UserDateTimeFactory;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class DurationFixedStartMode implements TrackingModeInterface class DurationFixedBeginMode implements TrackingModeInterface
{ {
/** /**
* @var UserDateTimeFactory * @var UserDateTimeFactory
@@ -62,7 +62,7 @@ class DurationFixedStartMode implements TrackingModeInterface
public function getId(): string public function getId(): string
{ {
return 'duration_fixed_start'; return 'duration_fixed_begin';
} }
public function canSeeBeginAndEndTimes(): bool public function canSeeBeginAndEndTimes(): bool

View File

@@ -11,7 +11,7 @@ namespace App\Timesheet;
use App\Configuration\TimesheetConfiguration; use App\Configuration\TimesheetConfiguration;
use App\Timesheet\TrackingMode\DefaultMode; use App\Timesheet\TrackingMode\DefaultMode;
use App\Timesheet\TrackingMode\DurationFixedStartMode; use App\Timesheet\TrackingMode\DurationFixedBeginMode;
use App\Timesheet\TrackingMode\DurationOnlyMode; use App\Timesheet\TrackingMode\DurationOnlyMode;
use App\Timesheet\TrackingMode\PunchInOutMode; use App\Timesheet\TrackingMode\PunchInOutMode;
use App\Timesheet\TrackingMode\TrackingModeInterface; use App\Timesheet\TrackingMode\TrackingModeInterface;
@@ -43,7 +43,7 @@ class TrackingModeService
new DefaultMode($this->dateTime, $this->configuration), new DefaultMode($this->dateTime, $this->configuration),
new PunchInOutMode(), new PunchInOutMode(),
new DurationOnlyMode($this->dateTime, $this->configuration), new DurationOnlyMode($this->dateTime, $this->configuration),
new DurationFixedStartMode($this->dateTime, $this->configuration), new DurationFixedBeginMode($this->dateTime, $this->configuration),
]; ];
} }

View File

@@ -1,4 +1,5 @@
{% embed 'embeds/modal.html.twig' %} {% embed 'embeds/modal.html.twig' %}
{% set used = used|default(false) %}
{% block modal_id %}form_modal{% endblock %} {% block modal_id %}form_modal{% endblock %}
{% block modal_class %}{% if used %}modal-danger{% endif %}{% endblock %} {% block modal_class %}{% if used %}modal-danger{% endif %}{% endblock %}
{% block modal_before %}{{ form_start(form) }}{% endblock %} {% block modal_before %}{{ form_start(form) }}{% endblock %}

View File

@@ -1,6 +1,7 @@
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% extends 'export/layout.html.twig' %} {% extends 'export/layout.html.twig' %}
{% set columnTitles = {} %}
{% set columns = { {% set columns = {
'date': true, 'date': true,
'username': false, 'username': false,
@@ -8,13 +9,21 @@
'project': true, 'project': true,
'activity': true, 'activity': true,
'description': false, 'description': false,
'tags': false,
'exported': false, 'exported': false,
'tags': false,
} %}
{% for id, metaField in metaFields %}
{% set columns = columns|merge({ (metaField): true}) %}
{% set columnTitles = columnTitles|merge({ (metaField): (metaField)}) %}
{% endfor %}
{% set columns = columns|merge({
'hourlyRate': false, 'hourlyRate': false,
'fixedRate': false, 'fixedRate': false,
'duration': 'label.duration', 'duration': 'label.duration',
'rate': 'label.rate', 'rate': 'label.rate',
} %} }) %}
{% block javascripts %} {% block javascripts %}
<script type="text/javascript"> <script type="text/javascript">
@@ -97,7 +106,7 @@
<div class="form-group"> <div class="form-group">
<label class="control-label" for="records-column-{{ columnId }}"> <label class="control-label" for="records-column-{{ columnId }}">
<input type="checkbox" class="column-visibility-changer" id="records-column-{{ columnId }}" name="{{ columnId }}" {% if visibility %}checked="checked"{% endif %}> <input type="checkbox" class="column-visibility-changer" id="records-column-{{ columnId }}" name="{{ columnId }}" {% if visibility %}checked="checked"{% endif %}>
{{ ('label.'~columnId)|trans }} {{ columnTitles[columnId]|default('label.'~columnId)|trans }}
</label> </label>
</div> </div>
{% endfor %} {% endfor %}
@@ -229,7 +238,9 @@
<thead> <thead>
<tr> <tr>
{% for columnId, visibility in columns %} {% for columnId, visibility in columns %}
<th class="column column-{{ columnId }}" {% if not visibility %}style="display: none"{% endif %}>{{ ('label.'~columnId)|trans }}</th> <th class="column column-{{ columnId }}" {% if not visibility %}style="display: none"{% endif %}>
{{ columnTitles[columnId]|default('label.'~columnId)|trans }}
</th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
@@ -268,11 +279,6 @@
{{ entry.description|desc2html }} {{ entry.description|desc2html }}
{% endif %} {% endif %}
</td> </td>
<td class="column-tags" {% if not columns.tags %}style="display: none"{% endif %}>
{% if entry.tags is not empty %}
{{ entry.tagsAsArray|join(', ') }}
{% endif %}
</td>
<td class="column-exported" {% if not columns.exported %}style="display: none"{% endif %}> <td class="column-exported" {% if not columns.exported %}style="display: none"{% endif %}>
{% if entry.exported %} {% if entry.exported %}
{{ 'entryState.exported'|trans }} {{ 'entryState.exported'|trans }}
@@ -280,6 +286,19 @@
{{ 'entryState.not_exported'|trans }} {{ 'entryState.not_exported'|trans }}
{% endif %} {% endif %}
</td> </td>
<td class="column-tags" {% if not columns.tags %}style="display: none"{% endif %}>
{% if entry.tags is not empty %}
{{ entry.tagsAsArray|join(', ') }}
{% endif %}
</td>
{% for id, metaFieldName in metaFields %}
<td class="column-{{ metaFieldName }} text-nowrap" {% if not columns[metaFieldName] %}style="display: none"{% endif %}>
{% set metaField = entry.metaField(metaFieldName) %}
{% if not metaField is null and metaField.visible %}
{{ metaField.value }}
{% endif %}
</td>
{% endfor %}
<td class="column-hourlyRate text-nowrap" {% if not columns.hourlyRate %}style="display: none"{% endif %}> <td class="column-hourlyRate text-nowrap" {% if not columns.hourlyRate %}style="display: none"{% endif %}>
{{ entry.hourlyRate|money(entry.project.customer.currency) }} {{ entry.hourlyRate|money(entry.project.customer.currency) }}
</td> </td>

View File

@@ -220,10 +220,12 @@ class ActivityControllerTest extends APIControllerBaseTest
protected function assertStructure(array $result, $full = true) protected function assertStructure(array $result, $full = true)
{ {
$expectedKeys = ['id', 'name', 'visible', 'project', 'hourlyRate', 'fixedRate', 'color']; $expectedKeys = ['id', 'name', 'visible', 'project', 'hourlyRate', 'fixedRate', 'color', 'metaFields'];
if ($full) { if ($full) {
$expectedKeys = array_merge($expectedKeys, ['comment', 'budget', 'timeBudget']); $expectedKeys = array_merge($expectedKeys, [
'comment', 'budget', 'timeBudget'
]);
} }
$actual = array_keys($result); $actual = array_keys($result);

View File

@@ -163,7 +163,7 @@ class CustomerControllerTest extends APIControllerBaseTest
protected function assertStructure(array $result, $full = true) protected function assertStructure(array $result, $full = true)
{ {
$expectedKeys = ['id', 'name', 'visible', 'hourlyRate', 'fixedRate', 'color']; $expectedKeys = ['id', 'name', 'visible', 'hourlyRate', 'fixedRate', 'color', 'metaFields'];
if ($full) { if ($full) {
$expectedKeys = array_merge($expectedKeys, [ $expectedKeys = array_merge($expectedKeys, [

View File

@@ -212,14 +212,13 @@ class ProjectControllerTest extends APIControllerBaseTest
protected function assertStructure(array $result, $full = true) protected function assertStructure(array $result, $full = true)
{ {
$expectedKeys = [ $expectedKeys = [
'id', 'name', 'visible', 'customer', 'hourlyRate', 'fixedRate', 'color' 'id', 'name', 'visible', 'customer', 'hourlyRate', 'fixedRate', 'color', 'metaFields'
]; ];
if ($full) { if ($full) {
$expectedKeys = array_merge( $expectedKeys = array_merge($expectedKeys, [
$expectedKeys, 'comment', 'budget', 'timeBudget', 'orderNumber'
['comment', 'budget', 'timeBudget', 'orderNumber'] ]);
);
} }
$actual = array_keys($result); $actual = array_keys($result);

View File

@@ -819,7 +819,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
protected function assertDefaultStructure(array $result, $full = true) protected function assertDefaultStructure(array $result, $full = true)
{ {
$expectedKeys = [ $expectedKeys = [
'id', 'begin', 'end', 'duration', 'description', 'rate', 'activity', 'project', 'tags', 'user' 'id', 'begin', 'end', 'duration', 'description', 'rate', 'activity', 'project', 'tags', 'user', 'metaFields'
]; ];
if ($full) { if ($full) {

View File

@@ -15,6 +15,7 @@ use App\Entity\User;
use App\Tests\DataFixtures\ActivityFixtures; use App\Tests\DataFixtures\ActivityFixtures;
use App\Tests\DataFixtures\ProjectFixtures; use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\ActivityTestMetaFieldSubscriberMock;
use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManager;
/** /**
@@ -77,6 +78,18 @@ class ActivityControllerTest extends ControllerBaseTest
$this->assertEquals('1', $editForm->get('activity_edit_form[customer]')->getValue()); $this->assertEquals('1', $editForm->get('activity_edit_form[customer]')->getValue());
} }
public function testCreateActionShowsMetaFields()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$client->getContainer()->get('event_dispatcher')->addSubscriber(new ActivityTestMetaFieldSubscriberMock());
$this->assertAccessIsGranted($client, '/admin/activity/create');
$this->assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('form[name=activity_edit_form]')->form();
$this->assertTrue($form->has('activity_edit_form[metaFields][0][value]'));
$this->assertFalse($form->has('activity_edit_form[metaFields][1][value]'));
}
public function testCreateActionWithCreateMore() public function testCreateActionWithCreateMore()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -14,6 +14,7 @@ use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Tests\DataFixtures\CustomerFixtures; use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\CustomerTestMetaFieldSubscriberMock;
use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManager;
/** /**
@@ -77,6 +78,18 @@ class CustomerControllerTest extends ControllerBaseTest
$this->assertHasFlashSuccess($client); $this->assertHasFlashSuccess($client);
} }
public function testCreateActionShowsMetaFields()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$client->getContainer()->get('event_dispatcher')->addSubscriber(new CustomerTestMetaFieldSubscriberMock());
$this->assertAccessIsGranted($client, '/admin/customer/create');
$this->assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('form[name=customer_edit_form]')->form();
$this->assertTrue($form->has('customer_edit_form[metaFields][0][value]'));
$this->assertFalse($form->has('customer_edit_form[metaFields][1][value]'));
}
public function testEditAction() public function testEditAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -15,6 +15,7 @@ use App\Entity\User;
use App\Tests\DataFixtures\CustomerFixtures; use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\ProjectFixtures; use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\ProjectTestMetaFieldSubscriberMock;
use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManager;
/** /**
@@ -70,6 +71,18 @@ class ProjectControllerTest extends ControllerBaseTest
$this->assertHasFlashSuccess($client); $this->assertHasFlashSuccess($client);
} }
public function testCreateActionShowsMetaFields()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$client->getContainer()->get('event_dispatcher')->addSubscriber(new ProjectTestMetaFieldSubscriberMock());
$this->assertAccessIsGranted($client, '/admin/project/create');
$this->assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('form[name=project_edit_form]')->form();
$this->assertTrue($form->has('project_edit_form[metaFields][0][value]'));
$this->assertFalse($form->has('project_edit_form[metaFields][1][value]'));
}
public function testCreateActionWithCreateMore() public function testCreateActionWithCreateMore()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -13,6 +13,7 @@ use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Form\Type\DateRangeType; use App\Form\Type\DateRangeType;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\TimesheetTestMetaFieldSubscriberMock;
/** /**
* @group integration * @group integration
@@ -144,6 +145,18 @@ class TimesheetControllerTest extends ControllerBaseTest
$this->assertNull($timesheet->getFixedRate()); $this->assertNull($timesheet->getFixedRate());
} }
public function testCreateActionShowsMetaFields()
{
$client = $this->getClientForAuthenticatedUser();
$client->getContainer()->get('event_dispatcher')->addSubscriber(new TimesheetTestMetaFieldSubscriberMock());
$this->request($client, '/timesheet/create');
$this->assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('form[name=timesheet_edit_form]')->form();
$this->assertTrue($form->has('timesheet_edit_form[metaFields][0][value]'));
$this->assertFalse($form->has('timesheet_edit_form[metaFields][1][value]'));
}
public function testCreateActionDoesNotShowRateFieldsForUser() public function testCreateActionDoesNotShowRateFieldsForUser()
{ {
$client = $this->getClientForAuthenticatedUser(); $client = $this->getClientForAuthenticatedUser();

View File

@@ -0,0 +1,114 @@
<?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\Tests\Entity;
use App\Entity\EntityWithMetaFields;
use App\Entity\MetaTableTypeInterface;
use App\Form\Type\DateTimePickerType;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
abstract class AbstractMetaEntityTest extends TestCase
{
abstract protected function getEntity(): EntityWithMetaFields;
abstract protected function getMetaEntity(): MetaTableTypeInterface;
public function testDefaultValues()
{
$sut = $this->getMetaEntity();
self::assertNull($sut->getName());
self::assertNull($sut->getType());
self::assertNull($sut->getValue());
self::assertNull($sut->getEntity());
self::assertIsArray($sut->getConstraints());
self::assertEmpty($sut->getConstraints());
self::assertFalse($sut->isVisible());
self::assertFalse($sut->isRequired());
}
public function testSetterAndGetter()
{
$sut = $this->getMetaEntity();
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->setName('foo-bar'));
self::assertEquals('foo-bar', $sut->getName());
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->setisVisible(true));
self::assertTrue($sut->isVisible());
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->setIsRequired(true));
self::assertTrue($sut->isRequired());
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->setValue('hello world'));
self::assertEquals('hello world', $sut->getValue());
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->setValue(956.32));
self::assertEquals(956.32, $sut->getValue());
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->setType(DateTimePickerType::class));
self::assertEquals(DateTimePickerType::class, $sut->getType());
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->addConstraint(new Length(['max' => 10])));
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->addConstraint(new NotNull([])));
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->addConstraint(new NotBlank([])));
self::assertCount(3, $sut->getConstraints());
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->setConstraints([new Length(['min' => 2])]));
self::assertCount(1, $sut->getConstraints());
$entity = $this->getEntity();
self::assertInstanceOf(MetaTableTypeInterface::class, $sut->setEntity($entity));
self::assertSame($entity, $sut->getEntity());
}
public function testMerge()
{
$entity1 = $this->getEntity();
$entity2 = $this->getEntity();
$meta1 = $this->getMetaEntity();
$meta1
->setName('foo')
->setValue('bar')
->setType('blub')
->setEntity($entity1)
->setConstraints([new NotNull()])
;
self::assertEquals('foo', $meta1->getName());
self::assertEquals('bar', $meta1->getValue());
self::assertEquals('blub', $meta1->getType());
self::assertFalse($meta1->isRequired());
self::assertFalse($meta1->isVisible());
self::assertSame($entity1, $meta1->getEntity());
self::assertCount(1, $meta1->getConstraints());
$meta2 = $this->getMetaEntity();
$meta2
->setName('foo2')
->setValue('bar2')
->setType('blub2')
->setEntity($entity2)
->setIsRequired(true)
->setisVisible(true)
->setConstraints([new NotBlank(), new Length(['min' => 1])])
;
self::assertInstanceOf(MetaTableTypeInterface::class, $meta1->merge($meta2));
self::assertEquals('foo', $meta1->getName());
self::assertEquals('bar', $meta1->getValue());
self::assertEquals('blub2', $meta1->getType());
self::assertTrue($meta1->isRequired());
self::assertTrue($meta1->isVisible());
self::assertSame($entity1, $meta1->getEntity());
self::assertCount(2, $meta1->getConstraints());
}
}

View File

@@ -0,0 +1,42 @@
<?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\Tests\Entity;
use App\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Entity\EntityWithMetaFields;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Timesheet;
/**
* @covers \App\Entity\ActivityMeta
*/
class ActivityMetaTest extends AbstractMetaEntityTest
{
protected function getEntity(): EntityWithMetaFields
{
return new Activity();
}
protected function getMetaEntity(): MetaTableTypeInterface
{
return new ActivityMeta();
}
/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage Expected instanceof Activity, received "App\Entity\Timesheet"
*/
public function testSetEntityThrowsException()
{
$sut = new ActivityMeta();
$sut->setEntity(new Timesheet());
}
}

View File

@@ -10,6 +10,8 @@
namespace App\Tests\Entity; namespace App\Tests\Entity;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\ActivityMeta;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -31,6 +33,9 @@ class ActivityTest extends TestCase
$this->assertNull($sut->getColor()); $this->assertNull($sut->getColor());
$this->assertEquals(0.0, $sut->getBudget()); $this->assertEquals(0.0, $sut->getBudget());
$this->assertEquals(0, $sut->getTimeBudget()); $this->assertEquals(0, $sut->getTimeBudget());
$this->assertInstanceOf(Collection::class, $sut->getMetaFields());
$this->assertEquals(0, $sut->getMetaFields()->count());
$this->assertNull($sut->getMetaField('foo'));
} }
public function testSetterAndGetter() public function testSetterAndGetter()
@@ -61,4 +66,31 @@ class ActivityTest extends TestCase
$this->assertInstanceOf(Activity::class, $sut->setTimeBudget(937321)); $this->assertInstanceOf(Activity::class, $sut->setTimeBudget(937321));
$this->assertEquals(937321, $sut->getTimeBudget()); $this->assertEquals(937321, $sut->getTimeBudget());
} }
public function testMetaFields()
{
$sut = new Activity();
$meta = new ActivityMeta();
$meta->setName('foo')->setValue('bar')->setType('test');
$this->assertInstanceOf(Activity::class, $sut->setMetaField($meta));
self::assertEquals(1, $sut->getMetaFields()->count());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
self::assertEquals('test', $result->getType());
$meta2 = new ActivityMeta();
$meta2->setName('foo')->setValue('bar')->setType('test2');
$this->assertInstanceOf(Activity::class, $sut->setMetaField($meta2));
self::assertEquals(1, $sut->getMetaFields()->count());
self::assertCount(0, $sut->getVisibleMetaFields());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
self::assertEquals('test2', $result->getType());
$sut->setMetaField((new ActivityMeta())->setName('blub')->setIsVisible(true));
$sut->setMetaField((new ActivityMeta())->setName('blab')->setIsVisible(true));
self::assertEquals(3, $sut->getMetaFields()->count());
self::assertCount(2, $sut->getVisibleMetaFields());
}
} }

View File

@@ -0,0 +1,42 @@
<?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\Tests\Entity;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\EntityWithMetaFields;
use App\Entity\MetaTableTypeInterface;
/**
* @covers \App\Entity\CustomerMeta
*/
class CustomerMetaTest extends AbstractMetaEntityTest
{
protected function getEntity(): EntityWithMetaFields
{
return new Customer();
}
protected function getMetaEntity(): MetaTableTypeInterface
{
return new CustomerMeta();
}
/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage Expected instanceof Customer, received "App\Entity\Activity"
*/
public function testSetEntityThrowsException()
{
$sut = new CustomerMeta();
$sut->setEntity(new Activity());
}
}

View File

@@ -10,6 +10,8 @@
namespace App\Tests\Entity; namespace App\Tests\Entity;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerMeta;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -46,6 +48,9 @@ class CustomerTest extends TestCase
$this->assertNull($sut->getColor()); $this->assertNull($sut->getColor());
$this->assertEquals(0.0, $sut->getBudget()); $this->assertEquals(0.0, $sut->getBudget());
$this->assertEquals(0, $sut->getTimeBudget()); $this->assertEquals(0, $sut->getTimeBudget());
$this->assertInstanceOf(Collection::class, $sut->getMetaFields());
$this->assertEquals(0, $sut->getMetaFields()->count());
$this->assertNull($sut->getMetaField('foo'));
} }
public function testSetterAndGetter() public function testSetterAndGetter()
@@ -97,4 +102,31 @@ class CustomerTest extends TestCase
$this->assertInstanceOf(Customer::class, $sut->setTimeBudget(937321)); $this->assertInstanceOf(Customer::class, $sut->setTimeBudget(937321));
$this->assertEquals(937321, $sut->getTimeBudget()); $this->assertEquals(937321, $sut->getTimeBudget());
} }
public function testMetaFields()
{
$sut = new Customer();
$meta = new CustomerMeta();
$meta->setName('foo')->setValue('bar')->setType('test');
$this->assertInstanceOf(Customer::class, $sut->setMetaField($meta));
self::assertEquals(1, $sut->getMetaFields()->count());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
self::assertEquals('test', $result->getType());
$meta2 = new CustomerMeta();
$meta2->setName('foo')->setValue('bar')->setType('test2');
$this->assertInstanceOf(Customer::class, $sut->setMetaField($meta2));
self::assertEquals(1, $sut->getMetaFields()->count());
self::assertCount(0, $sut->getVisibleMetaFields());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
self::assertEquals('test2', $result->getType());
$sut->setMetaField((new CustomerMeta())->setName('blub')->setIsVisible(true));
$sut->setMetaField((new CustomerMeta())->setName('blab')->setIsVisible(true));
self::assertEquals(3, $sut->getMetaFields()->count());
self::assertCount(2, $sut->getVisibleMetaFields());
}
} }

View File

@@ -0,0 +1,42 @@
<?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\Tests\Entity;
use App\Entity\Customer;
use App\Entity\EntityWithMetaFields;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Entity\ProjectMeta;
/**
* @covers \App\Entity\ProjectMeta
*/
class ProjectMetaTest extends AbstractMetaEntityTest
{
protected function getEntity(): EntityWithMetaFields
{
return new Project();
}
protected function getMetaEntity(): MetaTableTypeInterface
{
return new ProjectMeta();
}
/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage Expected instanceof Project, received "App\Entity\Customer"
*/
public function testSetEntityThrowsException()
{
$sut = new ProjectMeta();
$sut->setEntity(new Customer());
}
}

View File

@@ -11,6 +11,8 @@ namespace App\Tests\Entity;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\ProjectMeta;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -36,6 +38,9 @@ class ProjectTest extends TestCase
$this->assertNull($sut->getColor()); $this->assertNull($sut->getColor());
$this->assertEquals(0.0, $sut->getBudget()); $this->assertEquals(0.0, $sut->getBudget());
$this->assertEquals(0, $sut->getTimeBudget()); $this->assertEquals(0, $sut->getTimeBudget());
$this->assertInstanceOf(Collection::class, $sut->getMetaFields());
$this->assertEquals(0, $sut->getMetaFields()->count());
$this->assertNull($sut->getMetaField('foo'));
} }
public function testSetterAndGetter() public function testSetterAndGetter()
@@ -73,4 +78,31 @@ class ProjectTest extends TestCase
$this->assertInstanceOf(Project::class, $sut->setTimeBudget(937321)); $this->assertInstanceOf(Project::class, $sut->setTimeBudget(937321));
$this->assertEquals(937321, $sut->getTimeBudget()); $this->assertEquals(937321, $sut->getTimeBudget());
} }
public function testMetaFields()
{
$sut = new Project();
$meta = new ProjectMeta();
$meta->setName('foo')->setValue('bar')->setType('test');
$this->assertInstanceOf(Project::class, $sut->setMetaField($meta));
self::assertEquals(1, $sut->getMetaFields()->count());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
self::assertEquals('test', $result->getType());
$meta2 = new ProjectMeta();
$meta2->setName('foo')->setValue('bar')->setType('test2');
$this->assertInstanceOf(Project::class, $sut->setMetaField($meta2));
self::assertEquals(1, $sut->getMetaFields()->count());
self::assertCount(0, $sut->getVisibleMetaFields());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
self::assertEquals('test2', $result->getType());
$sut->setMetaField((new ProjectMeta())->setName('blub')->setIsVisible(true));
$sut->setMetaField((new ProjectMeta())->setName('blab')->setIsVisible(true));
self::assertEquals(3, $sut->getMetaFields()->count());
self::assertCount(2, $sut->getVisibleMetaFields());
}
} }

View File

@@ -0,0 +1,42 @@
<?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\Tests\Entity;
use App\Entity\EntityWithMetaFields;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
/**
* @covers \App\Entity\TimesheetMeta
*/
class TimesheetMetaTest extends AbstractMetaEntityTest
{
protected function getEntity(): EntityWithMetaFields
{
return new Timesheet();
}
protected function getMetaEntity(): MetaTableTypeInterface
{
return new TimesheetMeta();
}
/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage Expected instanceof Timesheet, received "App\Entity\Project"
*/
public function testSetEntityThrowsException()
{
$sut = new TimesheetMeta();
$sut->setEntity(new Project());
}
}

View File

@@ -14,8 +14,10 @@ use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Tag; use App\Entity\Tag;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User; use App\Entity\User;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -39,11 +41,13 @@ class TimesheetTest extends TestCase
$this->assertNull($sut->getHourlyRate()); $this->assertNull($sut->getHourlyRate());
$this->assertEquals(new ArrayCollection(), $sut->getTags()); $this->assertEquals(new ArrayCollection(), $sut->getTags());
$this->assertEquals([], $sut->getTagsAsArray()); $this->assertEquals([], $sut->getTagsAsArray());
$this->assertInstanceOf(Timesheet::class, $sut->setFixedRate(13.47)); $this->assertInstanceOf(Timesheet::class, $sut->setFixedRate(13.47));
$this->assertEquals(13.47, $sut->getFixedRate()); $this->assertEquals(13.47, $sut->getFixedRate());
$this->assertInstanceOf(Timesheet::class, $sut->setHourlyRate(99)); $this->assertInstanceOf(Timesheet::class, $sut->setHourlyRate(99));
$this->assertEquals(99, $sut->getHourlyRate()); $this->assertEquals(99, $sut->getHourlyRate());
$this->assertInstanceOf(Collection::class, $sut->getMetaFields());
$this->assertEquals(0, $sut->getMetaFields()->count());
$this->assertNull($sut->getMetaField('foo'));
} }
protected function getEntity() protected function getEntity()
@@ -89,4 +93,31 @@ class TimesheetTest extends TestCase
$sut->removeTag($tag1); $sut->removeTag($tag1);
$this->assertEmpty($sut->getTags()); $this->assertEmpty($sut->getTags());
} }
public function testMetaFields()
{
$sut = new Timesheet();
$meta = new TimesheetMeta();
$meta->setName('foo')->setValue('bar')->setType('test');
$this->assertInstanceOf(Timesheet::class, $sut->setMetaField($meta));
self::assertEquals(1, $sut->getMetaFields()->count());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
self::assertEquals('test', $result->getType());
$meta2 = new TimesheetMeta();
$meta2->setName('foo')->setValue('bar')->setType('test2');
$this->assertInstanceOf(Timesheet::class, $sut->setMetaField($meta2));
self::assertEquals(1, $sut->getMetaFields()->count());
self::assertCount(0, $sut->getVisibleMetaFields());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
self::assertEquals('test2', $result->getType());
$sut->setMetaField((new TimesheetMeta())->setName('blub')->setIsVisible(true));
$sut->setMetaField((new TimesheetMeta())->setName('blab')->setIsVisible(true));
self::assertEquals(3, $sut->getMetaFields()->count());
self::assertCount(2, $sut->getVisibleMetaFields());
}
} }

View File

@@ -0,0 +1,27 @@
<?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\Tests\Event;
use App\Entity\Activity;
use App\Event\ActivityMetaDefinitionEvent;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\ActivityMetaDefinitionEvent
*/
class ActivityMetaDefinitionEventTest extends TestCase
{
public function testGetterAndSetter()
{
$activity = new Activity();
$sut = new ActivityMetaDefinitionEvent($activity);
$this->assertSame($activity, $sut->getEntity());
}
}

View File

@@ -0,0 +1,27 @@
<?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\Tests\Event;
use App\Entity\Customer;
use App\Event\CustomerMetaDefinitionEvent;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\CustomerMetaDefinitionEvent
*/
class CustomerMetaDefinitionEventTest extends TestCase
{
public function testGetterAndSetter()
{
$customer = new Customer();
$sut = new CustomerMetaDefinitionEvent($customer);
$this->assertSame($customer, $sut->getEntity());
}
}

View File

@@ -0,0 +1,28 @@
<?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\Tests\Event;
use App\Entity\User;
use App\Event\PrepareUserEvent;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\PrepareUserEvent
*/
class PrepareUserEventTest extends TestCase
{
public function testGetterAndSetter()
{
$user = new User();
$sut = new PrepareUserEvent($user);
$this->assertEquals('app.prepare_user', PrepareUserEvent::PREPARE);
$this->assertSame($user, $sut->getUser());
}
}

View File

@@ -0,0 +1,27 @@
<?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\Tests\Event;
use App\Entity\Project;
use App\Event\ProjectMetaDefinitionEvent;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\ProjectMetaDefinitionEvent
*/
class ProjectMetaDefinitionEventTest extends TestCase
{
public function testGetterAndSetter()
{
$project = new Project();
$sut = new ProjectMetaDefinitionEvent($project);
$this->assertSame($project, $sut->getEntity());
}
}

View File

@@ -0,0 +1,27 @@
<?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\Tests\Event;
use App\Entity\Timesheet;
use App\Event\TimesheetMetaDefinitionEvent;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\TimesheetMetaDefinitionEvent
*/
class TimesheetMetaDefinitionEventTest extends TestCase
{
public function testGetterAndSetter()
{
$timesheet = new Timesheet();
$sut = new TimesheetMetaDefinitionEvent($timesheet);
$this->assertSame($timesheet, $sut->getEntity());
}
}

View File

@@ -15,6 +15,7 @@ use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Tag; use App\Entity\Tag;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User; use App\Entity\User;
use App\Export\RendererInterface; use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
@@ -139,6 +140,8 @@ abstract class AbstractRendererTest extends KernelTestCase
->setEnd(new \DateTime('2019-06-16 12:06:40')) ->setEnd(new \DateTime('2019-06-16 12:06:40'))
->addTag((new Tag())->setName('foo')) ->addTag((new Tag())->setName('foo'))
->addTag((new Tag())->setName('bar')) ->addTag((new Tag())->setName('bar'))
->setMetaField((new TimesheetMeta())->setName('foo')->setValue('meta-bar')->setIsVisible(true))
->setMetaField((new TimesheetMeta())->setName('foo2')->setValue('meta-bar2')->setIsVisible(true))
; ;
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5]; $entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5];

View File

@@ -74,9 +74,6 @@ class CsvRendererTest extends AbstractRendererTest
foreach ($rows as $row) { foreach ($rows as $row) {
$all[] = str_getcsv($row); $all[] = str_getcsv($row);
} }
self::assertEquals(7, count($all));
self::assertEquals(13, count($all[0]));
self::assertEquals('foo', $all[4][8]);
$expected = [ $expected = [
0 => '2019.06.16 12:00', 0 => '2019.06.16 12:00',
@@ -88,12 +85,18 @@ class CsvRendererTest extends AbstractRendererTest
6 => '', 6 => '',
7 => '', 7 => '',
8 => 'foo,bar', 8 => 'foo,bar',
9 => '€0.00', 9 => 'meta-bar',
10 => '€84.00', 10 => 'meta-bar2',
11 => '00:06 h', 11 => '€0.00',
12 => '€0.00', 12 => '€84.00',
13 => '00:06 h',
14 => '€0.00',
]; ];
self::assertEquals(7, count($all));
self::assertEquals(count($expected), count($all[0]));
self::assertEquals('foo', $all[4][8]);
self::assertEquals($expected, $all[5]); self::assertEquals($expected, $all[5]);
} }
} }

View File

@@ -20,14 +20,14 @@ class DebugRendererTest extends TestCase
public function getTestModel() public function getTestModel()
{ {
yield [$this->getInvoiceModel(), '1,947.99', 5, 5, 1, 2, 2, true]; yield [$this->getInvoiceModel(), '1,947.99', 5, 5, 1, 2, 2, true, [['entry.meta.foo-timesheet'], ['entry.meta.foo-timesheet2'], ['entry.meta.foo-timesheet'], ['entry.meta.foo-timesheet3']]];
yield [$this->getInvoiceModelOneEntry(), '293.27', 1, 1, 0, 1, 0, false]; yield [$this->getInvoiceModelOneEntry(), '293.27', 1, 1, 0, 1, 0, false, []];
} }
/** /**
* @dataProvider getTestModel * @dataProvider getTestModel
*/ */
public function testRender(InvoiceModel $model, $expectedRate, $expectedRows, $expectedDescriptions, $expectedUser1, $expectedUser2, $expectedUser3, $hasProject) public function testRender(InvoiceModel $model, $expectedRate, $expectedRows, $expectedDescriptions, $expectedUser1, $expectedUser2, $expectedUser3, $hasProject, $metaFields = [])
{ {
$document = new InvoiceDocument(new \SplFileInfo(__DIR__ . '/DebugRenderer.php')); $document = new InvoiceDocument(new \SplFileInfo(__DIR__ . '/DebugRenderer.php'));
$sut = new DebugRenderer(); $sut = new DebugRenderer();
@@ -39,14 +39,16 @@ class DebugRendererTest extends TestCase
$rows = $data['entries']; $rows = $data['entries'];
$this->assertEquals($expectedRows, count($rows)); $this->assertEquals($expectedRows, count($rows));
$i = 0;
foreach ($rows as $row) { foreach ($rows as $row) {
$this->assertEntryStructure($row); $meta = isset($metaFields[$i]) ? $metaFields[$i++] : [];
$this->assertEntryStructure($row, $meta);
} }
// TODO check values or formats? // TODO check values or formats?
} }
protected function assertModelStructure(array $model, $hasProject = true) protected function assertModelStructure(array $model, $hasProject = true, $hasActivity = false)
{ {
$keys = [ $keys = [
'invoice.due_date', 'invoice.due_date',
@@ -77,6 +79,11 @@ class DebugRendererTest extends TestCase
'customer.number', 'customer.number',
'customer.homepage', 'customer.homepage',
'customer.comment', 'customer.comment',
'customer.meta.foo-customer',
'activity.id',
'activity.name',
'activity.comment',
'activity.meta.foo-activity',
]; ];
if ($hasProject) { if ($hasProject) {
@@ -85,6 +92,15 @@ class DebugRendererTest extends TestCase
'project.name', 'project.name',
'project.comment', 'project.comment',
'project.order_number', 'project.order_number',
'project.meta.foo-project',
]);
}
if ($hasActivity) {
$keys = array_merge($keys, [
'activity.id',
'activity.name',
'activity.comment',
]); ]);
} }
@@ -95,7 +111,7 @@ class DebugRendererTest extends TestCase
$this->assertEquals($keys, $givenKeys); $this->assertEquals($keys, $givenKeys);
} }
protected function assertEntryStructure(array $model) protected function assertEntryStructure(array $model, array $metaFields)
{ {
$keys = [ $keys = [
'entry.row', 'entry.row',
@@ -125,6 +141,8 @@ class DebugRendererTest extends TestCase
'entry.customer_id', 'entry.customer_id',
]; ];
$keys = array_merge($keys, $metaFields);
foreach ($keys as $key) { foreach ($keys as $key) {
$this->assertArrayHasKey($key, $model); $this->assertArrayHasKey($key, $model);
} }
@@ -134,7 +152,7 @@ class DebugRendererTest extends TestCase
$givenKeys = array_keys($model); $givenKeys = array_keys($model);
sort($givenKeys); sort($givenKeys);
$this->assertEquals(count($keys), count($givenKeys));
$this->assertEquals($expectedKeys, $givenKeys); $this->assertEquals($expectedKeys, $givenKeys);
$this->assertEquals(count($keys), count($givenKeys));
} }
} }

View File

@@ -11,11 +11,15 @@ namespace App\Tests\Invoice\Renderer;
use App\Configuration\LanguageFormattings; use App\Configuration\LanguageFormattings;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\InvoiceDocument; use App\Entity\InvoiceDocument;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\ProjectMeta;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User; use App\Entity\User;
use App\Invoice\Calculator\DefaultCalculator; use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\NumberGenerator\DateNumberGenerator; use App\Invoice\NumberGenerator\DateNumberGenerator;
@@ -85,6 +89,8 @@ trait RendererTestTrait
{ {
$customer = new Customer(); $customer = new Customer();
$customer->setCurrency('EUR'); $customer->setCurrency('EUR');
$customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true));
$template = new InvoiceTemplate(); $template = new InvoiceTemplate();
$template->setTitle('a test invoice template title'); $template->setTitle('a test invoice template title');
$template->setVat(19); $template->setVat(19);
@@ -92,10 +98,12 @@ trait RendererTestTrait
$project = new Project(); $project = new Project();
$project->setName('project name'); $project->setName('project name');
$project->setCustomer($customer); $project->setCustomer($customer);
$project->setMetaField((new ProjectMeta())->setName('foo-project')->setValue('bar-project')->setIsVisible(true));
$activity = new Activity(); $activity = new Activity();
$activity->setName('activity description'); $activity->setName('activity description');
$activity->setProject($project); $activity->setProject($project);
$activity->setMetaField((new ActivityMeta())->setName('foo-activity')->setValue('bar-activity')->setIsVisible(true));
$userMethods = ['getId', 'getPreferenceValue', 'getUsername']; $userMethods = ['getId', 'getPreferenceValue', 'getUsername'];
$user1 = $this->getMockBuilder(User::class)->setMethods($userMethods)->disableOriginalConstructor()->getMock(); $user1 = $this->getMockBuilder(User::class)->setMethods($userMethods)->disableOriginalConstructor()->getMock();
@@ -116,7 +124,7 @@ trait RendererTestTrait
->setProject($project) ->setProject($project)
->setBegin(new \DateTime()) ->setBegin(new \DateTime())
->setEnd(new \DateTime()) ->setEnd(new \DateTime())
; ->setMetaField((new TimesheetMeta())->setName('foo-timesheet')->setValue('bar-timesheet')->setIsVisible(true));
$timesheet2 = new Timesheet(); $timesheet2 = new Timesheet();
$timesheet2 $timesheet2
@@ -127,6 +135,8 @@ trait RendererTestTrait
->setProject($project) ->setProject($project)
->setBegin(new \DateTime()) ->setBegin(new \DateTime())
->setEnd(new \DateTime()) ->setEnd(new \DateTime())
->setMetaField((new TimesheetMeta())->setName('foo-timesheet')->setValue('bar-timesheet'))
->setMetaField((new TimesheetMeta())->setName('foo-timesheet2')->setValue('bar-timesheet2')->setIsVisible(true))
; ;
$timesheet3 = new Timesheet(); $timesheet3 = new Timesheet();
@@ -138,6 +148,7 @@ trait RendererTestTrait
->setProject($project) ->setProject($project)
->setBegin(new \DateTime()) ->setBegin(new \DateTime())
->setEnd(new \DateTime()) ->setEnd(new \DateTime())
->setMetaField((new TimesheetMeta())->setName('foo-timesheet')->setValue('bar-timesheet1')->setIsVisible(true))
; ;
$timesheet4 = new Timesheet(); $timesheet4 = new Timesheet();
@@ -149,6 +160,7 @@ trait RendererTestTrait
->setProject($project) ->setProject($project)
->setBegin(new \DateTime()) ->setBegin(new \DateTime())
->setEnd(new \DateTime()) ->setEnd(new \DateTime())
->setMetaField((new TimesheetMeta())->setName('foo-timesheet3')->setValue('bluuuub')->setIsVisible(true))
; ;
$timesheet5 = new Timesheet(); $timesheet5 = new Timesheet();
@@ -193,6 +205,8 @@ trait RendererTestTrait
{ {
$customer = new Customer(); $customer = new Customer();
$customer->setCurrency('USD'); $customer->setCurrency('USD');
$customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true));
$template = new InvoiceTemplate(); $template = new InvoiceTemplate();
$template->setTitle('a test invoice template title'); $template->setTitle('a test invoice template title');
$template->setVat(19); $template->setVat(19);
@@ -200,10 +214,12 @@ trait RendererTestTrait
$project = new Project(); $project = new Project();
$project->setName('project name'); $project->setName('project name');
$project->setCustomer($customer); $project->setCustomer($customer);
$project->setMetaField((new ProjectMeta())->setName('foo-project')->setValue('bar-project')->setIsVisible(true));
$activity = new Activity(); $activity = new Activity();
$activity->setName('activity description'); $activity->setName('activity description');
$activity->setProject($project); $activity->setProject($project);
$activity->setMetaField((new ActivityMeta())->setName('foo-activity')->setValue('bar-activity')->setIsVisible(true));
$userMethods = ['getId', 'getPreferenceValue', 'getUsername']; $userMethods = ['getId', 'getPreferenceValue', 'getUsername'];
$user1 = $this->getMockBuilder(User::class)->setMethods($userMethods)->disableOriginalConstructor()->getMock(); $user1 = $this->getMockBuilder(User::class)->setMethods($userMethods)->disableOriginalConstructor()->getMock();

View File

@@ -0,0 +1,37 @@
<?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\Tests\Mocks;
use App\Entity\ActivityMeta;
use App\Event\ActivityMetaDefinitionEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Validator\Constraints\Length;
class ActivityTestMetaFieldSubscriberMock implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
ActivityMetaDefinitionEvent::class => ['loadMeta', 200],
];
}
public function loadMeta(ActivityMetaDefinitionEvent $event)
{
$definition = (new ActivityMeta())
->setName('metatestmock')
->setType(TextType::class)
->addConstraint(new Length(['max' => 200]))
->setIsVisible(true);
$event->getEntity()->setMetaField($definition);
}
}

View File

@@ -0,0 +1,37 @@
<?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\Tests\Mocks;
use App\Entity\CustomerMeta;
use App\Event\CustomerMetaDefinitionEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Validator\Constraints\Length;
class CustomerTestMetaFieldSubscriberMock implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
CustomerMetaDefinitionEvent::class => ['loadMeta', 200],
];
}
public function loadMeta(CustomerMetaDefinitionEvent $event)
{
$definition = (new CustomerMeta())
->setName('metatestmock')
->setType(TextType::class)
->addConstraint(new Length(['max' => 200]))
->setIsVisible(true);
$event->getEntity()->setMetaField($definition);
}
}

View File

@@ -0,0 +1,37 @@
<?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\Tests\Mocks;
use App\Entity\ProjectMeta;
use App\Event\ProjectMetaDefinitionEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Validator\Constraints\Length;
class ProjectTestMetaFieldSubscriberMock implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
ProjectMetaDefinitionEvent::class => ['loadMeta', 200],
];
}
public function loadMeta(ProjectMetaDefinitionEvent $event)
{
$definition = (new ProjectMeta())
->setName('metatestmock')
->setType(TextType::class)
->addConstraint(new Length(['max' => 200]))
->setIsVisible(true);
$event->getEntity()->setMetaField($definition);
}
}

View File

@@ -0,0 +1,37 @@
<?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\Tests\Mocks;
use App\Entity\TimesheetMeta;
use App\Event\TimesheetMetaDefinitionEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Validator\Constraints\Length;
class TimesheetTestMetaFieldSubscriberMock implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
TimesheetMetaDefinitionEvent::class => ['loadMeta', 200],
];
}
public function loadMeta(TimesheetMetaDefinitionEvent $event)
{
$definition = (new TimesheetMeta())
->setName('metatestmock')
->setType(TextType::class)
->addConstraint(new Length(['max' => 200]))
->setIsVisible(true);
$event->getEntity()->setMetaField($definition);
}
}

View File

@@ -13,14 +13,14 @@ use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Tests\Configuration\TestConfigLoader; use App\Tests\Configuration\TestConfigLoader;
use App\Tests\Mocks\Security\UserDateTimeFactoryFactory; use App\Tests\Mocks\Security\UserDateTimeFactoryFactory;
use App\Timesheet\TrackingMode\DurationFixedStartMode; use App\Timesheet\TrackingMode\DurationFixedBeginMode;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
/** /**
* @covers \App\Timesheet\TrackingMode\DurationFixedStartMode * @covers \App\Timesheet\TrackingMode\DurationFixedBeginMode
*/ */
class DurationFixedStartModeTest extends TestCase class DurationFixedBeginModeTest extends TestCase
{ {
protected function createSut() protected function createSut()
{ {
@@ -28,7 +28,7 @@ class DurationFixedStartModeTest extends TestCase
$dateTime = (new UserDateTimeFactoryFactory($this))->create(); $dateTime = (new UserDateTimeFactoryFactory($this))->create();
$configuration = new TimesheetConfiguration($loader, ['default_begin' => '13:47']); $configuration = new TimesheetConfiguration($loader, ['default_begin' => '13:47']);
return new DurationFixedStartMode($dateTime, $configuration); return new DurationFixedBeginMode($dateTime, $configuration);
} }
public function testDefaultValues() public function testDefaultValues()
@@ -40,7 +40,7 @@ class DurationFixedStartModeTest extends TestCase
self::assertTrue($sut->canEditDuration()); self::assertTrue($sut->canEditDuration());
self::assertFalse($sut->canUpdateTimesWithAPI()); self::assertFalse($sut->canUpdateTimesWithAPI());
self::assertFalse($sut->canSeeBeginAndEndTimes()); self::assertFalse($sut->canSeeBeginAndEndTimes());
self::assertEquals('duration_fixed_start', $sut->getId()); self::assertEquals('duration_fixed_begin', $sut->getId());
} }
public function testCreate() public function testCreate()
@@ -54,4 +54,14 @@ class DurationFixedStartModeTest extends TestCase
$sut->create($timesheet, $request); $sut->create($timesheet, $request);
self::assertEquals('13:47', $timesheet->getBegin()->format('H:i')); self::assertEquals('13:47', $timesheet->getBegin()->format('H:i'));
} }
public function testCreateWithoutBeginInjectsBegin()
{
$timesheet = new Timesheet();
$request = new Request();
$sut = $this->createSut();
$sut->create($timesheet, $request);
self::assertEquals('13:47', $timesheet->getBegin()->format('H:i'));
}
} }

View File

@@ -40,7 +40,7 @@ class TrackingModeServiceTest extends TestCase
self::assertContains('default', $ids); self::assertContains('default', $ids);
self::assertContains('punch', $ids); self::assertContains('punch', $ids);
self::assertContains('duration_only', $ids); self::assertContains('duration_only', $ids);
self::assertContains('duration_fixed_start', $ids); self::assertContains('duration_fixed_begin', $ids);
} }
public function testGetActiveMode() public function testGetActiveMode()

Some files were not shown because too many files have changed in this diff Show More