diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b53c01f2..84a26f3a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,15 +1,16 @@ # Contributing 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 -- 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. -- 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`. -- If you contribute new files, please add them with the file-header template from below (our chode-style fixer can do that for you). -- 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 one of the PR checks fails, please fix them before asking us for a review. +- 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 with `composer kimai:tests-unit` and `composer kimai:tests-integration` +- If you contribute new files, add them with the file-header template from below (the code-style fixer can do that for you) +- 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). diff --git a/README.md b/README.md index 493ad02d..a5b4f95b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![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. -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 diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index 6e4a9407..8e5e9565 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -29,7 +29,7 @@ kimai: # The time-tracking mode that should be used. # 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). # This setting is only respected by some timetracking modes and not in all situations. diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index c606ef99..1e4ec35a 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -2,17 +2,21 @@ nelmio_api_doc: models: use_jms: true 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: CustomerMetaField, type: App\Entity\CustomerMeta, groups: [Default, 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: ProjectMetaField, type: App\Entity\ProjectMeta, groups: [Default, 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: ActivityMetaField, type: App\Entity\ActivityMeta, groups: [Default, 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: TimesheetMeta, type: App\Entity\TimesheetMeta, groups: [Default, Timesheet] } - { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] } - { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] } - { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] } diff --git a/config/serializer/App/Entity.Activity.yml b/config/serializer/App/Entity.Activity.yml index f7c730df..445f7ce6 100644 --- a/config/serializer/App/Entity.Activity.yml +++ b/config/serializer/App/Entity.Activity.yml @@ -1,6 +1,6 @@ App\Entity\Activity: 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: id: include: true @@ -32,9 +32,16 @@ App\Entity\Activity: groups: [Default] color: include: true + metaFields: + exclude: true virtual_properties: getProject: serialized_name: project exp: "object.getProject() === null ? null : object.getProject().getId()" type: integer groups: [Default] + getMetaFields: + serialized_name: metaFields + exp: "object.getVisibleMetaFields()" + type: array + groups: [Default] diff --git a/config/serializer/App/Entity.ActivityMeta.yml b/config/serializer/App/Entity.ActivityMeta.yml new file mode 100644 index 00000000..517a1aab --- /dev/null +++ b/config/serializer/App/Entity.ActivityMeta.yml @@ -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" diff --git a/config/serializer/App/Entity.Customer.yml b/config/serializer/App/Entity.Customer.yml index 84a0801b..fff757e6 100644 --- a/config/serializer/App/Entity.Customer.yml +++ b/config/serializer/App/Entity.Customer.yml @@ -1,6 +1,6 @@ App\Entity\Customer: 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: id: include: true @@ -61,3 +61,11 @@ App\Entity\Customer: groups: [Customer] color: include: true + metaFields: + exclude: true + virtual_properties: + getMetaFields: + serialized_name: metaFields + exp: "object.getVisibleMetaFields()" + type: array + groups: [Default] diff --git a/config/serializer/App/Entity.CustomerMeta.yml b/config/serializer/App/Entity.CustomerMeta.yml new file mode 100644 index 00000000..c612ea10 --- /dev/null +++ b/config/serializer/App/Entity.CustomerMeta.yml @@ -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" diff --git a/config/serializer/App/Entity.Project.yml b/config/serializer/App/Entity.Project.yml index 56801a0d..25a17e30 100644 --- a/config/serializer/App/Entity.Project.yml +++ b/config/serializer/App/Entity.Project.yml @@ -1,6 +1,6 @@ App\Entity\Project: 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: id: include: true @@ -30,9 +30,16 @@ App\Entity\Project: groups: [Subresource] color: include: true + metaFields: + exclude: true virtual_properties: getCustomer: serialized_name: customer exp: "object.getCustomer() === null ? null : object.getCustomer().getId()" type: integer groups: [Entity, Collection] + getMetaFields: + serialized_name: metaFields + exp: "object.getVisibleMetaFields()" + type: array + groups: [Default] diff --git a/config/serializer/App/Entity.ProjectMeta.yml b/config/serializer/App/Entity.ProjectMeta.yml new file mode 100644 index 00000000..d7d08823 --- /dev/null +++ b/config/serializer/App/Entity.ProjectMeta.yml @@ -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" diff --git a/config/serializer/App/Entity.Timesheet.yml b/config/serializer/App/Entity.Timesheet.yml index cbfde928..2c58dbbe 100644 --- a/config/serializer/App/Entity.Timesheet.yml +++ b/config/serializer/App/Entity.Timesheet.yml @@ -1,6 +1,6 @@ App\Entity\Timesheet: 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: id: include: true @@ -29,6 +29,8 @@ App\Entity\Timesheet: groups: [Subresource] user: exclude: true + metaFields: + exclude: true virtual_properties: getBegin: serialized_name: begin @@ -53,6 +55,11 @@ App\Entity\Timesheet: exp: "object.getUser().getId()" type: integer getTags: - serialized_name: tags - exp: "object.getTagsAsArray()" - type: array + serialized_name: tags + exp: "object.getTagsAsArray()" + type: array + getMetaFields: + serialized_name: metaFields + exp: "object.getVisibleMetaFields()" + type: array + groups: [Default] diff --git a/config/serializer/App/Entity.TimesheetMeta.yml b/config/serializer/App/Entity.TimesheetMeta.yml new file mode 100644 index 00000000..2908f238 --- /dev/null +++ b/config/serializer/App/Entity.TimesheetMeta.yml @@ -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" diff --git a/src/API/ActivityController.php b/src/API/ActivityController.php index 614c4d31..f46f03c4 100644 --- a/src/API/ActivityController.php +++ b/src/API/ActivityController.php @@ -12,7 +12,8 @@ declare(strict_types=1); namespace App\API; use App\Entity\Activity; -use App\Form\ActivityEditForm; +use App\Event\ActivityMetaDefinitionEvent; +use App\Form\API\ActivityApiEditForm; use App\Repository\ActivityRepository; use App\Repository\Query\ActivityQuery; use FOS\RestBundle\Controller\Annotations as Rest; @@ -22,6 +23,7 @@ use FOS\RestBundle\View\View; use FOS\RestBundle\View\ViewHandlerInterface; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Swagger\Annotations as SWG; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -37,20 +39,20 @@ class ActivityController extends BaseApiController * @var ActivityRepository */ protected $repository; - /** * @var ViewHandlerInterface */ protected $viewHandler; - /** - * @param ViewHandlerInterface $viewHandler - * @param ActivityRepository $repository + * @var EventDispatcherInterface */ - public function __construct(ViewHandlerInterface $viewHandler, ActivityRepository $repository) + protected $dispatcher; + + public function __construct(ViewHandlerInterface $viewHandler, ActivityRepository $repository, EventDispatcherInterface $dispatcher) { $this->viewHandler = $viewHandler; $this->repository = $repository; + $this->dispatcher = $dispatcher; } /** @@ -133,12 +135,18 @@ class ActivityController extends BaseApiController */ public function getAction($id) { + /** @var Activity $data */ $data = $this->repository->find($id); if (null === $data) { 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->getContext()->setGroups(['Default', 'Entity', 'Activity']); @@ -177,9 +185,7 @@ class ActivityController extends BaseApiController $activity = new Activity(); - $form = $this->createForm(ActivityEditForm::class, $activity, [ - 'csrf_protection' => false, - ]); + $form = $this->createForm(ActivityApiEditForm::class, $activity); $form->submit($request->request->all()); @@ -241,9 +247,7 @@ class ActivityController extends BaseApiController throw new AccessDeniedHttpException('User cannot update activity'); } - $form = $this->createForm(ActivityEditForm::class, $activity, [ - 'csrf_protection' => false, - ]); + $form = $this->createForm(ActivityApiEditForm::class, $activity); $form->setData($activity); $form->submit($request->request->all(), false); diff --git a/src/API/CustomerController.php b/src/API/CustomerController.php index ae767396..5e101b4c 100644 --- a/src/API/CustomerController.php +++ b/src/API/CustomerController.php @@ -12,7 +12,8 @@ declare(strict_types=1); namespace App\API; use App\Entity\Customer; -use App\Form\CustomerEditForm; +use App\Event\CustomerMetaDefinitionEvent; +use App\Form\API\CustomerApiEditForm; use App\Repository\CustomerRepository; use App\Repository\Query\CustomerQuery; use FOS\RestBundle\Controller\Annotations as Rest; @@ -22,6 +23,7 @@ use FOS\RestBundle\View\View; use FOS\RestBundle\View\ViewHandlerInterface; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Swagger\Annotations as SWG; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -37,20 +39,20 @@ class CustomerController extends BaseApiController * @var CustomerRepository */ protected $repository; - /** * @var ViewHandlerInterface */ protected $viewHandler; - /** - * @param ViewHandlerInterface $viewHandler - * @param CustomerRepository $repository + * @var EventDispatcherInterface */ - public function __construct(ViewHandlerInterface $viewHandler, CustomerRepository $repository) + protected $dispatcher; + + public function __construct(ViewHandlerInterface $viewHandler, CustomerRepository $repository, EventDispatcherInterface $dispatcher) { $this->viewHandler = $viewHandler; $this->repository = $repository; + $this->dispatcher = $dispatcher; } /** @@ -111,12 +113,18 @@ class CustomerController extends BaseApiController */ public function getAction($id) { + /** @var Customer $data */ $data = $this->repository->find($id); if (null === $data) { 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->getContext()->setGroups(['Default', 'Entity', 'Customer']); @@ -155,9 +163,7 @@ class CustomerController extends BaseApiController $customer = new Customer(); - $form = $this->createForm(CustomerEditForm::class, $customer, [ - 'csrf_protection' => false, - ]); + $form = $this->createForm(CustomerApiEditForm::class, $customer); $form->submit($request->request->all()); @@ -219,9 +225,7 @@ class CustomerController extends BaseApiController throw new AccessDeniedHttpException('User cannot update customer'); } - $form = $this->createForm(CustomerEditForm::class, $customer, [ - 'csrf_protection' => false, - ]); + $form = $this->createForm(CustomerApiEditForm::class, $customer); $form->setData($customer); $form->submit($request->request->all(), false); diff --git a/src/API/ProjectController.php b/src/API/ProjectController.php index 222b76fb..6375152e 100644 --- a/src/API/ProjectController.php +++ b/src/API/ProjectController.php @@ -12,7 +12,8 @@ declare(strict_types=1); namespace App\API; use App\Entity\Project; -use App\Form\ProjectEditForm; +use App\Event\ProjectMetaDefinitionEvent; +use App\Form\API\ProjectApiEditForm; use App\Repository\ProjectRepository; use App\Repository\Query\ProjectQuery; use FOS\RestBundle\Controller\Annotations as Rest; @@ -22,6 +23,7 @@ use FOS\RestBundle\View\View; use FOS\RestBundle\View\ViewHandlerInterface; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Swagger\Annotations as SWG; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -37,20 +39,20 @@ class ProjectController extends BaseApiController * @var ProjectRepository */ protected $repository; - /** * @var ViewHandlerInterface */ protected $viewHandler; - /** - * @param ViewHandlerInterface $viewHandler - * @param ProjectRepository $repository + * @var EventDispatcherInterface */ - public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository) + protected $dispatcher; + + public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository, EventDispatcherInterface $dispatcher) { $this->viewHandler = $viewHandler; $this->repository = $repository; + $this->dispatcher = $dispatcher; } /** @@ -117,10 +119,18 @@ class ProjectController extends BaseApiController */ public function getAction($id) { + /** @var Project $data */ $data = $this->repository->find($id); + if (null === $data) { 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->getContext()->setGroups(['Default', 'Entity', 'Project']); @@ -159,9 +169,7 @@ class ProjectController extends BaseApiController $project = new Project(); - $form = $this->createForm(ProjectEditForm::class, $project, [ - 'csrf_protection' => false, - ]); + $form = $this->createForm(ProjectApiEditForm::class, $project); $form->submit($request->request->all()); @@ -223,9 +231,7 @@ class ProjectController extends BaseApiController throw new AccessDeniedHttpException('User cannot update project'); } - $form = $this->createForm(ProjectEditForm::class, $project, [ - 'csrf_protection' => false, - ]); + $form = $this->createForm(ProjectApiEditForm::class, $project); $form->setData($project); $form->submit($request->request->all(), false); diff --git a/src/API/TimesheetController.php b/src/API/TimesheetController.php index 715224b6..864acf46 100644 --- a/src/API/TimesheetController.php +++ b/src/API/TimesheetController.php @@ -14,7 +14,7 @@ namespace App\API; use App\Configuration\TimesheetConfiguration; use App\Entity\Timesheet; use App\Entity\User; -use App\Form\TimesheetEditForm; +use App\Form\API\TimesheetApiEditForm; use App\Repository\Query\TimesheetQuery; use App\Repository\TagRepository; use App\Repository\TimesheetRepository; @@ -234,17 +234,18 @@ class TimesheetController extends BaseApiController */ 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(); } - if (!$this->isGranted('view', $timesheet)) { + if (!$this->isGranted('view', $data)) { 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']); return $this->viewHandler->handle($view); @@ -284,13 +285,11 @@ class TimesheetController extends BaseApiController $mode = $this->getTrackingMode(); - $form = $this->createForm(TimesheetEditForm::class, $timesheet, [ - 'csrf_protection' => false, + $form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [ 'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_exported' => $this->isGranted('edit_export', $timesheet), 'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(), 'allow_end_datetime' => $mode->canUpdateTimesWithAPI(), - 'allow_duration' => false, 'date_format' => self::DATE_FORMAT, ]); @@ -366,13 +365,11 @@ class TimesheetController extends BaseApiController $mode = $this->getTrackingMode(); - $form = $this->createForm(TimesheetEditForm::class, $timesheet, [ - 'csrf_protection' => false, + $form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [ 'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_exported' => $this->isGranted('edit_export', $timesheet), 'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(), 'allow_end_datetime' => $mode->canUpdateTimesWithAPI(), - 'allow_duration' => false, 'date_format' => self::DATE_FORMAT, ]); diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php index e865ebc2..8c8f9c54 100644 --- a/src/Controller/ActivityController.php +++ b/src/Controller/ActivityController.php @@ -11,6 +11,7 @@ namespace App\Controller; use App\Entity\Activity; use App\Entity\Project; +use App\Event\ActivityMetaDefinitionEvent; use App\Form\ActivityEditForm; use App\Form\Toolbar\ActivityToolbarForm; use App\Form\Type\ActivityType; @@ -19,7 +20,11 @@ use App\Repository\Query\ActivityQuery; use Doctrine\ORM\ORMException; use Pagerfanta\Pagerfanta; 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\Response; use Symfony\Component\Routing\Annotation\Route; /** @@ -34,10 +39,15 @@ class ActivityController extends AbstractController * @var ActivityRepository */ private $repository; + /** + * @var EventDispatcherInterface + */ + protected $dispatcher; - public function __construct(ActivityRepository $repository) + public function __construct(ActivityRepository $repository, EventDispatcherInterface $dispatcher) { $this->repository = $repository; + $this->dispatcher = $dispatcher; } protected function getRepository(): ActivityRepository @@ -52,7 +62,7 @@ class ActivityController extends AbstractController * * @param int $page * @param Request $request - * @return \Symfony\Component\HttpFoundation\Response + * @return Response */ public function indexAction($page, Request $request) { @@ -88,7 +98,7 @@ class ActivityController extends AbstractController * * @param Request $request * @param Project|null $project - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + * @return RedirectResponse|Response */ public function createAction(Request $request, ?Project $project = null) { @@ -105,7 +115,7 @@ class ActivityController extends AbstractController * @Security("is_granted('budget', activity)") * * @param Activity $activity - * @return \Symfony\Component\HttpFoundation\Response + * @return Response */ public function budgetAction(Activity $activity) { @@ -121,7 +131,7 @@ class ActivityController extends AbstractController * * @param Activity $activity * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + * @return RedirectResponse|Response */ public function editAction(Activity $activity, Request $request) { @@ -134,7 +144,7 @@ class ActivityController extends AbstractController * * @param Activity $activity * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + * @return RedirectResponse|Response */ public function deleteAction(Activity $activity, Request $request) { @@ -193,29 +203,32 @@ class ActivityController extends AbstractController /** * @param Activity $activity * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + * @return RedirectResponse|Response */ 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); if ($editForm->isSubmitted() && $editForm->isValid()) { - $entityManager = $this->getDoctrine()->getManager(); - $entityManager->persist($activity); - $entityManager->flush(); + try { + $this->getRepository()->saveActivity($activity); + $this->flashSuccess('action.update.success'); - $this->flashSuccess('action.update.success'); - - if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) { - $newActivity = new Activity(); - $newActivity->setProject($activity->getProject()); - $editForm = $this->createEditForm($newActivity); - $editForm->get('create_more')->setData(true); - $activity = $newActivity; - } else { - return $this->redirectToRoute('admin_activity'); + if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) { + $newActivity = new Activity(); + $newActivity->setProject($activity->getProject()); + $editForm = $this->createEditForm($newActivity); + $editForm->get('create_more')->setData(true); + $activity = $newActivity; + } 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 - * @return \Symfony\Component\Form\FormInterface + * @return FormInterface */ protected function getToolbarForm(ActivityQuery $query) { @@ -244,7 +257,7 @@ class ActivityController extends AbstractController /** * @param Activity $activity - * @return \Symfony\Component\Form\FormInterface + * @return FormInterface */ private function createEditForm(Activity $activity) { diff --git a/src/Controller/CustomerController.php b/src/Controller/CustomerController.php index 9f28777b..4b61fe1b 100644 --- a/src/Controller/CustomerController.php +++ b/src/Controller/CustomerController.php @@ -11,6 +11,7 @@ namespace App\Controller; use App\Configuration\FormConfiguration; use App\Entity\Customer; +use App\Event\CustomerMetaDefinitionEvent; use App\Form\CustomerEditForm; use App\Form\Toolbar\CustomerToolbarForm; use App\Form\Type\CustomerType; @@ -19,7 +20,11 @@ use App\Repository\Query\CustomerQuery; use Doctrine\ORM\ORMException; use Pagerfanta\Pagerfanta; 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\Response; use Symfony\Component\Routing\Annotation\Route; /** @@ -38,15 +43,20 @@ class CustomerController extends AbstractController * @var FormConfiguration */ private $configuration; + /** + * @var EventDispatcherInterface + */ + protected $dispatcher; /** * @param CustomerRepository $repository * @param FormConfiguration $configuration */ - public function __construct(CustomerRepository $repository, FormConfiguration $configuration) + public function __construct(CustomerRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher) { $this->repository = $repository; $this->configuration = $configuration; + $this->dispatcher = $dispatcher; } /** @@ -64,7 +74,7 @@ class CustomerController extends AbstractController * * @param int $page * @param Request $request - * @return \Symfony\Component\HttpFoundation\Response + * @return Response */ public function indexAction($page, Request $request) { @@ -97,7 +107,7 @@ class CustomerController extends AbstractController * @Security("is_granted('create_customer')") * * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + * @return RedirectResponse|Response */ public function createAction(Request $request) { @@ -114,7 +124,7 @@ class CustomerController extends AbstractController * @Security("is_granted('budget', customer)") * * @param Customer $customer - * @return \Symfony\Component\HttpFoundation\Response + * @return Response */ public function budgetAction(Customer $customer) { @@ -130,7 +140,7 @@ class CustomerController extends AbstractController * * @param Customer $customer * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + * @return RedirectResponse|Response */ public function editAction(Customer $customer, Request $request) { @@ -143,7 +153,7 @@ class CustomerController extends AbstractController * * @param Customer $customer * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + * @return RedirectResponse|Response */ public function deleteAction(Customer $customer, Request $request) { @@ -195,22 +205,26 @@ class CustomerController extends AbstractController /** * @param Customer $customer * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + * @return RedirectResponse|Response */ protected function renderCustomerForm(Customer $customer, Request $request) { + $event = new CustomerMetaDefinitionEvent($customer); + $this->dispatcher->dispatch(CustomerMetaDefinitionEvent::class, $event); + $editForm = $this->createEditForm($customer); $editForm->handleRequest($request); if ($editForm->isSubmitted() && $editForm->isValid()) { - $entityManager = $this->getDoctrine()->getManager(); - $entityManager->persist($customer); - $entityManager->flush(); + try { + $this->getRepository()->saveCustomer($customer); + $this->flashSuccess('action.update.success'); - $this->flashSuccess('action.update.success'); - - return $this->redirectToRoute('admin_customer'); + return $this->redirectToRoute('admin_customer'); + } catch (ORMException $ex) { + $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); + } } return $this->render('customer/edit.html.twig', [ @@ -221,7 +235,7 @@ class CustomerController extends AbstractController /** * @param CustomerQuery $query - * @return \Symfony\Component\Form\FormInterface + * @return FormInterface */ protected function getToolbarForm(CustomerQuery $query) { @@ -235,7 +249,7 @@ class CustomerController extends AbstractController /** * @param Customer $customer - * @return \Symfony\Component\Form\FormInterface + * @return FormInterface */ private function createEditForm(Customer $customer) { diff --git a/src/Controller/ExportController.php b/src/Controller/ExportController.php index 1e1844f1..8c5af83b 100644 --- a/src/Controller/ExportController.php +++ b/src/Controller/ExportController.php @@ -16,7 +16,9 @@ use App\Repository\Query\ExportQuery; use App\Repository\TimesheetRepository; use App\Timesheet\UserDateTimeFactory; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; /** @@ -76,7 +78,7 @@ class ExportController extends AbstractController * @Security("is_granted('view_export')") * * @param Request $request - * @return \Symfony\Component\HttpFoundation\Response + * @return Response * @throws \Exception */ public function indexAction(Request $request) @@ -105,7 +107,7 @@ class ExportController extends AbstractController * @Security("is_granted('create_export')") * * @param Request $request - * @return \Symfony\Component\HttpFoundation\Response + * @return Response * @throws \Exception */ public function export(Request $request) @@ -126,8 +128,6 @@ class ExportController extends AbstractController $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) { throw $this->createNotFoundException('Unknown export renderer'); } @@ -141,7 +141,7 @@ class ExportController extends AbstractController * @param ExportQuery $query * @return Timesheet[] */ - protected function getEntries(ExportQuery $query) + protected function getEntries(ExportQuery $query): array { $query->getBegin()->setTime(0, 0, 0); $query->getEnd()->setTime(23, 59, 59); @@ -151,9 +151,9 @@ class ExportController extends AbstractController /** * @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, [ 'action' => $this->generateUrl('export', []), diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 43edde30..e8a012b8 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -11,6 +11,7 @@ namespace App\Controller; use App\Entity\Customer; use App\Entity\Project; +use App\Event\ProjectMetaDefinitionEvent; use App\Form\ProjectEditForm; use App\Form\Toolbar\ProjectToolbarForm; use App\Form\Type\ProjectType; @@ -19,7 +20,11 @@ use App\Repository\Query\ProjectQuery; use Doctrine\ORM\ORMException; use Pagerfanta\Pagerfanta; 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\Response; use Symfony\Component\Routing\Annotation\Route; /** @@ -34,10 +39,15 @@ class ProjectController extends AbstractController * @var ProjectRepository */ private $repository; + /** + * @var EventDispatcherInterface + */ + protected $dispatcher; - public function __construct(ProjectRepository $repository) + public function __construct(ProjectRepository $repository, EventDispatcherInterface $dispatcher) { $this->repository = $repository; + $this->dispatcher = $dispatcher; } protected function getRepository(): ProjectRepository @@ -52,7 +62,7 @@ class ProjectController extends AbstractController * * @param int $page * @param Request $request - * @return \Symfony\Component\HttpFoundation\Response + * @return Response */ public function indexAction($page, Request $request) { @@ -88,7 +98,7 @@ class ProjectController extends AbstractController * * @param Request $request * @param Customer|null $customer - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + * @return RedirectResponse|Response */ public function createAction(Request $request, ?Customer $customer = null) { @@ -106,7 +116,7 @@ class ProjectController extends AbstractController * @Security("is_granted('budget', project)") * * @param Project $project - * @return \Symfony\Component\HttpFoundation\Response + * @return Response */ public function budgetAction(Project $project) { @@ -122,7 +132,7 @@ class ProjectController extends AbstractController * * @param Project $project * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + * @return RedirectResponse|Response */ public function editAction(Project $project, Request $request) { @@ -135,7 +145,7 @@ class ProjectController extends AbstractController * * @param Project $project * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + * @return RedirectResponse|Response */ public function deleteAction(Project $project, Request $request) { @@ -188,29 +198,32 @@ class ProjectController extends AbstractController /** * @param Project $project * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + * @return RedirectResponse|Response */ 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); if ($editForm->isSubmitted() && $editForm->isValid()) { - $entityManager = $this->getDoctrine()->getManager(); - $entityManager->persist($project); - $entityManager->flush(); + try { + $this->getRepository()->saveProject($project); + $this->flashSuccess('action.update.success'); - $this->flashSuccess('action.update.success'); - - if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) { - $newProject = new Project(); - $newProject->setCustomer($project->getCustomer()); - $editForm = $this->createEditForm($newProject); - $editForm->get('create_more')->setData(true); - $project = $newProject; - } else { - return $this->redirectToRoute('admin_project'); + if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) { + $newProject = new Project(); + $newProject->setCustomer($project->getCustomer()); + $editForm = $this->createEditForm($newProject); + $editForm->get('create_more')->setData(true); + $project = $newProject; + } 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 - * @return \Symfony\Component\Form\FormInterface + * @return FormInterface */ protected function getToolbarForm(ProjectQuery $query) { @@ -236,7 +249,7 @@ class ProjectController extends AbstractController /** * @param Project $project - * @return \Symfony\Component\Form\FormInterface + * @return FormInterface */ private function createEditForm(Project $project) { diff --git a/src/Controller/TimesheetAbstractController.php b/src/Controller/TimesheetAbstractController.php index 8e7eaa3c..653ab381 100644 --- a/src/Controller/TimesheetAbstractController.php +++ b/src/Controller/TimesheetAbstractController.php @@ -12,6 +12,7 @@ namespace App\Controller; use App\Configuration\TimesheetConfiguration; use App\Entity\Tag; use App\Entity\Timesheet; +use App\Event\TimesheetMetaDefinitionEvent; use App\Form\TimesheetEditForm; use App\Form\Toolbar\TimesheetToolbarForm; use App\Repository\ActivityRepository; @@ -23,6 +24,7 @@ use App\Timesheet\TrackingMode\TrackingModeInterface; use App\Timesheet\TrackingModeService; use App\Timesheet\UserDateTimeFactory; use Doctrine\Common\Collections\ArrayCollection; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -45,17 +47,23 @@ abstract class TimesheetAbstractController extends AbstractController * @var TrackingModeService */ protected $trackingModeService; + /** + * @var EventDispatcherInterface + */ + protected $dispatcher; public function __construct( UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration, TimesheetRepository $repository, - TrackingModeService $service + TrackingModeService $service, + EventDispatcherInterface $dispatcher ) { $this->dateTime = $dateTime; $this->configuration = $configuration; $this->repository = $repository; $this->trackingModeService = $service; + $this->dispatcher = $dispatcher; } protected function getTrackingMode(): TrackingModeInterface @@ -127,17 +135,21 @@ abstract class TimesheetAbstractController extends AbstractController */ 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->handleRequest($request); if ($editForm->isSubmitted() && $editForm->isValid()) { - $entityManager = $this->getDoctrine()->getManager(); - $entityManager->persist($entry); - $entityManager->flush(); + try { + $this->getRepository()->save($entry); + $this->flashSuccess('action.update.success'); - $this->flashSuccess('action.update.success'); - - return $this->redirectToRoute($this->getTimesheetRoute(), ['page' => $request->get('page', 1)]); + return $this->redirectToRoute($this->getTimesheetRoute(), ['page' => $request->get('page', 1)]); + } catch (\Exception $ex) { + $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); + } } return $this->render($renderTemplate, [ @@ -169,6 +181,9 @@ abstract class TimesheetAbstractController extends AbstractController $entry->setActivity($activity); } + $event = new TimesheetMetaDefinitionEvent($entry); + $this->dispatcher->dispatch(TimesheetMetaDefinitionEvent::class, $event); + $mode = $this->getTrackingMode(); $mode->create($entry, $request); @@ -176,8 +191,6 @@ abstract class TimesheetAbstractController extends AbstractController $createForm->handleRequest($request); if ($createForm->isSubmitted() && $createForm->isValid()) { - $entityManager = $this->getDoctrine()->getManager(); - try { if (null === $entry->getEnd()) { $this->getRepository()->stopActiveEntries( @@ -185,15 +198,13 @@ abstract class TimesheetAbstractController extends AbstractController $this->configuration->getActiveEntriesHardLimit() ); } - $entityManager->persist($entry); - $entityManager->flush(); - + $this->getRepository()->save($entry); $this->flashSuccess('action.update.success'); + + return $this->redirectToRoute($this->getTimesheetRoute()); } catch (\Exception $ex) { $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); } - - return $this->redirectToRoute($this->getTimesheetRoute()); } return $this->render($renderTemplate, [ diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index f42c8272..5d9f3feb 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -83,12 +83,6 @@ class Configuration implements ConfigurationInterface ->end() ->scalarNode('mode') ->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() ->booleanNode('markdown_content') ->defaultValue(false) diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index e339818a..01c10bc9 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -18,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert; * @ORM\Table(name="kimai2_activities") * @ORM\Entity(repositoryClass="App\Repository\ActivityRepository") */ -class Activity +class Activity implements EntityWithMetaFields { /** * @var int @@ -73,9 +73,17 @@ class Activity use ColorTrait; use BudgetTrait; + /** + * @var ActivityMeta[]|Collection + * + * @ORM\OneToMany(targetEntity="App\Entity\ActivityMeta", mappedBy="activity", cascade={"persist"}) + */ + private $meta; + public function __construct() { $this->timesheets = new ArrayCollection(); + $this->meta = new ArrayCollection(); } public function getId(): ?int @@ -139,6 +147,55 @@ class Activity 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 */ diff --git a/src/Entity/ActivityMeta.php b/src/Entity/ActivityMeta.php new file mode 100644 index 00000000..fde3c407 --- /dev/null +++ b/src/Entity/ActivityMeta.php @@ -0,0 +1,54 @@ +activity = $entity; + + return $this; + } + + public function getEntity(): ?EntityWithMetaFields + { + return $this->activity; + } +} diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index 37c37e70..79a40611 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -18,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert; * @ORM\Table(name="kimai2_customers") * @ORM\Entity(repositoryClass="App\Repository\CustomerRepository") */ -class Customer +class Customer implements EntityWithMetaFields { public const DEFAULT_CURRENCY = 'EUR'; @@ -154,9 +154,17 @@ class Customer use ColorTrait; use BudgetTrait; + /** + * @var CustomerMeta[]|Collection + * + * @ORM\OneToMany(targetEntity="App\Entity\CustomerMeta", mappedBy="customer", cascade={"persist"}) + */ + private $meta; + public function __construct() { $this->projects = new ArrayCollection(); + $this->meta = new ArrayCollection(); } public function getId(): ?int @@ -352,6 +360,55 @@ class Customer 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 */ diff --git a/src/Entity/CustomerMeta.php b/src/Entity/CustomerMeta.php new file mode 100644 index 00000000..8334d193 --- /dev/null +++ b/src/Entity/CustomerMeta.php @@ -0,0 +1,54 @@ +customer = $entity; + + return $this; + } + + public function getEntity(): ?EntityWithMetaFields + { + return $this->customer; + } +} diff --git a/src/Entity/EntityWithMetaFields.php b/src/Entity/EntityWithMetaFields.php new file mode 100644 index 00000000..4cb5c899 --- /dev/null +++ b/src/Entity/EntityWithMetaFields.php @@ -0,0 +1,25 @@ +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; + } +} diff --git a/src/Entity/Project.php b/src/Entity/Project.php index f305be0d..2863b68f 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -18,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert; * @ORM\Table(name="kimai2_projects") * @ORM\Entity(repositoryClass="App\Repository\ProjectRepository") */ -class Project +class Project implements EntityWithMetaFields { /** * @var int @@ -89,10 +89,18 @@ class Project */ private $timesheets; + /** + * @var ProjectMeta[]|Collection + * + * @ORM\OneToMany(targetEntity="App\Entity\ProjectMeta", mappedBy="project", cascade={"persist"}) + */ + private $meta; + public function __construct() { $this->activities = new ArrayCollection(); $this->timesheets = new ArrayCollection(); + $this->meta = new ArrayCollection(); } public function getId(): ?int @@ -190,6 +198,55 @@ class Project 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 */ diff --git a/src/Entity/ProjectMeta.php b/src/Entity/ProjectMeta.php new file mode 100644 index 00000000..f70b9dca --- /dev/null +++ b/src/Entity/ProjectMeta.php @@ -0,0 +1,54 @@ +project = $entity; + + return $this; + } + + public function getEntity(): ?EntityWithMetaFields + { + return $this->project; + } +} diff --git a/src/Entity/Timesheet.php b/src/Entity/Timesheet.php index cc02ba56..81d0c706 100644 --- a/src/Entity/Timesheet.php +++ b/src/Entity/Timesheet.php @@ -25,7 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert; * @ORM\HasLifecycleCallbacks() * @App\Validator\Constraints\Timesheet */ -class Timesheet +class Timesheet implements EntityWithMetaFields { /** * @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 @@ -146,6 +153,7 @@ class Timesheet public function __construct() { $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! * + * @internal * @deprecated since it was introduced, only meant for the initial migration. Will be removed with 1.0. * @param string $timezone * @return Timesheet @@ -415,4 +424,53 @@ class Timesheet 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; + } } diff --git a/src/Entity/TimesheetMeta.php b/src/Entity/TimesheetMeta.php new file mode 100644 index 00000000..291b9e12 --- /dev/null +++ b/src/Entity/TimesheetMeta.php @@ -0,0 +1,54 @@ +timesheet = $entity; + + return $this; + } + + public function getEntity(): ?EntityWithMetaFields + { + return $this->timesheet; + } +} diff --git a/src/Event/ActivityMetaDefinitionEvent.php b/src/Event/ActivityMetaDefinitionEvent.php new file mode 100644 index 00000000..bff4929e --- /dev/null +++ b/src/Event/ActivityMetaDefinitionEvent.php @@ -0,0 +1,34 @@ +entity = $entity; + } + + public function getEntity(): Activity + { + return $this->entity; + } +} diff --git a/src/Event/ConfigureAdminMenuEvent.php b/src/Event/ConfigureAdminMenuEvent.php index b6e85d0d..65fa84b0 100644 --- a/src/Event/ConfigureAdminMenuEvent.php +++ b/src/Event/ConfigureAdminMenuEvent.php @@ -16,7 +16,7 @@ use Symfony\Component\HttpFoundation\Request; /** * 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'; diff --git a/src/Event/ConfigureMainMenuEvent.php b/src/Event/ConfigureMainMenuEvent.php index b34e56c4..b4db16aa 100644 --- a/src/Event/ConfigureMainMenuEvent.php +++ b/src/Event/ConfigureMainMenuEvent.php @@ -17,7 +17,7 @@ use Symfony\Component\HttpFoundation\Request; /** * 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'; diff --git a/src/Event/CustomerMetaDefinitionEvent.php b/src/Event/CustomerMetaDefinitionEvent.php new file mode 100644 index 00000000..0fd5776a --- /dev/null +++ b/src/Event/CustomerMetaDefinitionEvent.php @@ -0,0 +1,34 @@ +entity = $entity; + } + + public function getEntity(): Customer + { + return $this->entity; + } +} diff --git a/src/Event/DashboardEvent.php b/src/Event/DashboardEvent.php index 769de8cd..e509af6a 100644 --- a/src/Event/DashboardEvent.php +++ b/src/Event/DashboardEvent.php @@ -13,7 +13,7 @@ use App\Entity\User; use App\Widget\WidgetContainerInterface; use Symfony\Component\EventDispatcher\Event; -class DashboardEvent extends Event +final class DashboardEvent extends Event { public const DASHBOARD = 'app.dashboard'; diff --git a/src/Event/PrepareUserEvent.php b/src/Event/PrepareUserEvent.php index 11dc12a7..f0e15977 100644 --- a/src/Event/PrepareUserEvent.php +++ b/src/Event/PrepareUserEvent.php @@ -13,9 +13,9 @@ use App\Entity\User; 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'; diff --git a/src/Event/ProjectMetaDefinitionEvent.php b/src/Event/ProjectMetaDefinitionEvent.php new file mode 100644 index 00000000..f6677f75 --- /dev/null +++ b/src/Event/ProjectMetaDefinitionEvent.php @@ -0,0 +1,34 @@ +entity = $entity; + } + + public function getEntity(): Project + { + return $this->entity; + } +} diff --git a/src/Event/SystemConfigurationEvent.php b/src/Event/SystemConfigurationEvent.php index 8dfd2118..bd62da03 100644 --- a/src/Event/SystemConfigurationEvent.php +++ b/src/Event/SystemConfigurationEvent.php @@ -15,7 +15,7 @@ use Symfony\Component\EventDispatcher\Event; /** * 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'; diff --git a/src/Event/ThemeEvent.php b/src/Event/ThemeEvent.php index 834d2ad1..3be8e41e 100644 --- a/src/Event/ThemeEvent.php +++ b/src/Event/ThemeEvent.php @@ -12,7 +12,7 @@ namespace App\Event; use App\Entity\User; use Symfony\Component\EventDispatcher\Event; -class ThemeEvent extends Event +final class ThemeEvent extends Event { public const JAVASCRIPT = 'app.theme.javascript'; public const STYLESHEET = 'app.theme.css'; diff --git a/src/Event/TimesheetMetaDefinitionEvent.php b/src/Event/TimesheetMetaDefinitionEvent.php new file mode 100644 index 00000000..41d0c096 --- /dev/null +++ b/src/Event/TimesheetMetaDefinitionEvent.php @@ -0,0 +1,34 @@ +entity = $entity; + } + + public function getEntity(): Timesheet + { + return $this->entity; + } +} diff --git a/src/Event/UserPreferenceEvent.php b/src/Event/UserPreferenceEvent.php index 33bd2bb8..c1e480e3 100644 --- a/src/Event/UserPreferenceEvent.php +++ b/src/Event/UserPreferenceEvent.php @@ -16,7 +16,7 @@ use Symfony\Component\EventDispatcher\Event; /** * 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'; diff --git a/src/Export/Renderer/AbstractSpreadsheetRenderer.php b/src/Export/Renderer/AbstractSpreadsheetRenderer.php index 270d04a0..168433b4 100644 --- a/src/Export/Renderer/AbstractSpreadsheetRenderer.php +++ b/src/Export/Renderer/AbstractSpreadsheetRenderer.php @@ -26,12 +26,10 @@ abstract class AbstractSpreadsheetRenderer * @var DateExtensions */ protected $dateExtension; - /** * @var Extensions */ protected $extension; - /** * @var TranslatorInterface */ @@ -42,8 +40,11 @@ abstract class AbstractSpreadsheetRenderer * @param DateExtensions $dateExtension * @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->dateExtension = $dateExtension; $this->extension = $extensions; @@ -97,6 +98,15 @@ abstract class AbstractSpreadsheetRenderer */ 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(); $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.exported')); $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.fixedRate')); $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; @@ -148,21 +161,32 @@ abstract class AbstractSpreadsheetRenderer $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getDescription()); $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->translator->trans($exported)); $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->getFixedRate(), $customerCurrency)); $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++; } - $sheet->setCellValueByColumnAndRow(12, $entryHeaderRow, $this->getFormattedDuration($durationTotal)); - $sheet->setCellValueByColumnAndRow(13, $entryHeaderRow, $this->getFormattedMoney($rateTotal, $currency)); - $sheet->getCellByColumnAndRow(12, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN); - $sheet->getCellByColumnAndRow(12, $entryHeaderRow)->getStyle()->getFont()->setBold(true); + $cellDurationTotal = $recordsHeaderColumn - 1; + $cellRateTotal = $recordsHeaderColumn; - $sheet->getCellByColumnAndRow(13, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN); - $sheet->getCellByColumnAndRow(13, $entryHeaderRow)->getStyle()->getFont()->setBold(true); + $sheet->setCellValueByColumnAndRow($cellDurationTotal, $entryHeaderRow, $this->getFormattedDuration($durationTotal)); + $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; } diff --git a/src/Export/Renderer/CsvRenderer.php b/src/Export/Renderer/CsvRenderer.php index a6156c97..875c2b67 100644 --- a/src/Export/Renderer/CsvRenderer.php +++ b/src/Export/Renderer/CsvRenderer.php @@ -13,7 +13,7 @@ use App\Export\RendererInterface; use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Spreadsheet; -class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface +final class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface { /** * @return string diff --git a/src/Export/Renderer/HtmlRenderer.php b/src/Export/Renderer/HtmlRenderer.php index ec55c707..17324fb1 100644 --- a/src/Export/Renderer/HtmlRenderer.php +++ b/src/Export/Renderer/HtmlRenderer.php @@ -15,7 +15,7 @@ use App\Repository\Query\TimesheetQuery; use Symfony\Component\HttpFoundation\Response; use Twig\Environment; -class HtmlRenderer implements RendererInterface +final class HtmlRenderer implements RendererInterface { use RendererTrait; @@ -24,9 +24,6 @@ class HtmlRenderer implements RendererInterface */ protected $twig; - /** - * @param Environment $twig - */ public function __construct(Environment $twig) { $this->twig = $twig; @@ -42,9 +39,17 @@ class HtmlRenderer implements RendererInterface */ 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', [ 'entries' => $timesheets, 'query' => $query, + 'metaFields' => array_unique($publicMetaFields), 'summaries' => $this->calculateSummary($timesheets), ]); diff --git a/src/Export/Renderer/OdsRenderer.php b/src/Export/Renderer/OdsRenderer.php index 798aadcd..816d97c7 100644 --- a/src/Export/Renderer/OdsRenderer.php +++ b/src/Export/Renderer/OdsRenderer.php @@ -13,7 +13,7 @@ use App\Export\RendererInterface; use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Spreadsheet; -class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface +final class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface { /** * @return string diff --git a/src/Export/Renderer/PDFRenderer.php b/src/Export/Renderer/PDFRenderer.php index c401e85e..7a937b9c 100644 --- a/src/Export/Renderer/PDFRenderer.php +++ b/src/Export/Renderer/PDFRenderer.php @@ -18,7 +18,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Twig\Environment; -class PDFRenderer implements RendererInterface +final class PDFRenderer implements RendererInterface { use RendererTrait; diff --git a/src/Export/Renderer/XlsxRenderer.php b/src/Export/Renderer/XlsxRenderer.php index 1228d83b..8bea1312 100644 --- a/src/Export/Renderer/XlsxRenderer.php +++ b/src/Export/Renderer/XlsxRenderer.php @@ -13,7 +13,7 @@ use App\Export\RendererInterface; use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Spreadsheet; -class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface +final class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface { /** * @return string diff --git a/src/Form/API/ActivityApiEditForm.php b/src/Form/API/ActivityApiEditForm.php new file mode 100644 index 00000000..1662b2f2 --- /dev/null +++ b/src/Form/API/ActivityApiEditForm.php @@ -0,0 +1,40 @@ +remove('metaFields'); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'csrf_protection' => false, + 'create_more' => false, + ]); + } +} diff --git a/src/Form/API/CustomerApiEditForm.php b/src/Form/API/CustomerApiEditForm.php new file mode 100644 index 00000000..c615c788 --- /dev/null +++ b/src/Form/API/CustomerApiEditForm.php @@ -0,0 +1,39 @@ +remove('metaFields'); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'csrf_protection' => false, + ]); + } +} diff --git a/src/Form/API/ProjectApiEditForm.php b/src/Form/API/ProjectApiEditForm.php new file mode 100644 index 00000000..5b90eb88 --- /dev/null +++ b/src/Form/API/ProjectApiEditForm.php @@ -0,0 +1,40 @@ +remove('metaFields'); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'csrf_protection' => false, + 'create_more' => false, + ]); + } +} diff --git a/src/Form/API/TimesheetApiEditForm.php b/src/Form/API/TimesheetApiEditForm.php new file mode 100644 index 00000000..80a19cb8 --- /dev/null +++ b/src/Form/API/TimesheetApiEditForm.php @@ -0,0 +1,37 @@ +remove('metaFields'); + } + + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver->setDefaults([ + 'csrf_protection' => false, + 'allow_duration' => false, + ]); + } +} diff --git a/src/Form/EntityFormTrait.php b/src/Form/EntityFormTrait.php index a94ed926..0c39f74b 100644 --- a/src/Form/EntityFormTrait.php +++ b/src/Form/EntityFormTrait.php @@ -13,6 +13,7 @@ use App\Form\Type\ColorPickerType; use App\Form\Type\DurationType; use App\Form\Type\FixedRateType; use App\Form\Type\HourlyRateType; +use App\Form\Type\MetaFieldsCollectionType; use App\Form\Type\YesNoType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\MoneyType; @@ -47,6 +48,8 @@ trait EntityFormTrait ; } + $builder->add('metaFields', MetaFieldsCollectionType::class); + $builder ->add('visible', YesNoType::class, [ 'label' => 'label.visible', diff --git a/src/Form/TimesheetEditForm.php b/src/Form/TimesheetEditForm.php index 12e57e01..22aec533 100644 --- a/src/Form/TimesheetEditForm.php +++ b/src/Form/TimesheetEditForm.php @@ -19,6 +19,7 @@ use App\Form\Type\DateTimePickerType; use App\Form\Type\DurationType; use App\Form\Type\FixedRateType; use App\Form\Type\HourlyRateType; +use App\Form\Type\MetaFieldsCollectionType; use App\Form\Type\ProjectType; use App\Form\Type\TagsInputType; use App\Form\Type\UserType; @@ -134,6 +135,8 @@ class TimesheetEditForm extends AbstractType $this->addTags($builder); $this->addRates($builder, $currency, $options); $this->addUser($builder, $options); + $builder->add('metaFields', MetaFieldsCollectionType::class); + $this->addExported($builder, $options); } diff --git a/src/Form/Type/EntityMetaDefinitionType.php b/src/Form/Type/EntityMetaDefinitionType.php new file mode 100644 index 00000000..474eec23 --- /dev/null +++ b/src/Form/Type/EntityMetaDefinitionType.php @@ -0,0 +1,60 @@ +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, + ]); + } +} diff --git a/src/Form/Type/MetaFieldsCollectionType.php b/src/Form/Type/MetaFieldsCollectionType.php new file mode 100644 index 00000000..0396aa2c --- /dev/null +++ b/src/Form/Type/MetaFieldsCollectionType.php @@ -0,0 +1,39 @@ +setDefaults([ + 'entry_type' => EntityMetaDefinitionType::class, + 'entry_options' => ['label' => false], + 'allow_add' => false, + 'allow_delete' => false, + 'label' => false, + ]); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return CollectionType::class; + } +} diff --git a/src/Invoice/Renderer/RendererTrait.php b/src/Invoice/Renderer/RendererTrait.php index bfeee5fe..5c13ca3c 100644 --- a/src/Invoice/Renderer/RendererTrait.php +++ b/src/Invoice/Renderer/RendererTrait.php @@ -82,6 +82,7 @@ trait RendererTrait { $customer = $model->getCustomer(); $project = $model->getQuery()->getProject(); + $activity = $model->getQuery()->getActivity(); $currency = $model->getCalculator()->getCurrency(); $values = [ @@ -108,6 +109,20 @@ trait RendererTrait '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) { $values = array_merge($values, [ 'project.id' => $project->getId(), @@ -115,6 +130,12 @@ trait RendererTrait 'project.comment' => $project->getComment(), 'project.order_number' => $project->getOrderNumber(), ]); + + foreach ($project->getVisibleMetaFields() as $metaField) { + $values = array_merge($values, [ + 'project.meta.' . $metaField->getName() => $metaField->getValue(), + ]); + } } if (null !== $customer) { @@ -129,6 +150,12 @@ trait RendererTrait 'customer.homepage' => $customer->getHomepage(), 'customer.comment' => $customer->getComment(), ]); + + foreach ($customer->getVisibleMetaFields() as $metaField) { + $values = array_merge($values, [ + 'customer.meta.' . $metaField->getName() => $metaField->getValue(), + ]); + } } return $values; @@ -169,7 +196,7 @@ trait RendererTrait $begin = $timesheet->getBegin(); $end = $timesheet->getEnd(); - return [ + $values = [ 'entry.row' => '', 'entry.description' => $description, 'entry.amount' => $amount, @@ -196,6 +223,14 @@ trait RendererTrait 'entry.customer' => $customer->getName(), 'entry.customer_id' => $customer->getId(), ]; + + foreach ($timesheet->getVisibleMetaFields() as $metaField) { + $values = array_merge($values, [ + 'entry.meta.' . $metaField->getName() => $metaField->getValue(), + ]); + } + + return $values; } /** diff --git a/src/Migrations/Version20190617100845.php b/src/Migrations/Version20190617100845.php new file mode 100644 index 00000000..5349e6d4 --- /dev/null +++ b/src/Migrations/Version20190617100845.php @@ -0,0 +1,83 @@ +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'); + } +} diff --git a/src/Repository/ActivityRepository.php b/src/Repository/ActivityRepository.php index 49bc9c58..e7b22cc3 100644 --- a/src/Repository/ActivityRepository.php +++ b/src/Repository/ActivityRepository.php @@ -21,6 +21,18 @@ use Pagerfanta\Pagerfanta; 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 * @return null|Activity diff --git a/src/Repository/CustomerRepository.php b/src/Repository/CustomerRepository.php index 0ede754e..60130842 100644 --- a/src/Repository/CustomerRepository.php +++ b/src/Repository/CustomerRepository.php @@ -22,6 +22,18 @@ use Pagerfanta\Pagerfanta; 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 * @return null|Customer diff --git a/src/Repository/Loader/TimesheetIdLoader.php b/src/Repository/Loader/TimesheetIdLoader.php index f59cbbc5..bde9bfca 100644 --- a/src/Repository/Loader/TimesheetIdLoader.php +++ b/src/Repository/Loader/TimesheetIdLoader.php @@ -82,7 +82,6 @@ final class TimesheetIdLoader implements LoaderInterface ->getQuery() ->execute(); - /* $qb = $em->createQueryBuilder(); $qb->select('PARTIAL t.{id}', 'meta') ->from(Timesheet::class, 't') @@ -90,6 +89,5 @@ final class TimesheetIdLoader implements LoaderInterface ->andWhere($qb->expr()->in('t.id', $ids)) ->getQuery() ->execute(); - */ } } diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index c725339a..19813b17 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -25,6 +25,18 @@ use Pagerfanta\Pagerfanta; */ 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 * @return null|Project diff --git a/src/Timesheet/TrackingMode/DurationFixedStartMode.php b/src/Timesheet/TrackingMode/DurationFixedBeginMode.php similarity index 93% rename from src/Timesheet/TrackingMode/DurationFixedStartMode.php rename to src/Timesheet/TrackingMode/DurationFixedBeginMode.php index cfb92d64..09776195 100644 --- a/src/Timesheet/TrackingMode/DurationFixedStartMode.php +++ b/src/Timesheet/TrackingMode/DurationFixedBeginMode.php @@ -14,7 +14,7 @@ use App\Entity\Timesheet; use App\Timesheet\UserDateTimeFactory; use Symfony\Component\HttpFoundation\Request; -class DurationFixedStartMode implements TrackingModeInterface +class DurationFixedBeginMode implements TrackingModeInterface { /** * @var UserDateTimeFactory @@ -62,7 +62,7 @@ class DurationFixedStartMode implements TrackingModeInterface public function getId(): string { - return 'duration_fixed_start'; + return 'duration_fixed_begin'; } public function canSeeBeginAndEndTimes(): bool diff --git a/src/Timesheet/TrackingModeService.php b/src/Timesheet/TrackingModeService.php index 2fe538fa..1cfb5f71 100644 --- a/src/Timesheet/TrackingModeService.php +++ b/src/Timesheet/TrackingModeService.php @@ -11,7 +11,7 @@ namespace App\Timesheet; use App\Configuration\TimesheetConfiguration; use App\Timesheet\TrackingMode\DefaultMode; -use App\Timesheet\TrackingMode\DurationFixedStartMode; +use App\Timesheet\TrackingMode\DurationFixedBeginMode; use App\Timesheet\TrackingMode\DurationOnlyMode; use App\Timesheet\TrackingMode\PunchInOutMode; use App\Timesheet\TrackingMode\TrackingModeInterface; @@ -43,7 +43,7 @@ class TrackingModeService new DefaultMode($this->dateTime, $this->configuration), new PunchInOutMode(), new DurationOnlyMode($this->dateTime, $this->configuration), - new DurationFixedStartMode($this->dateTime, $this->configuration), + new DurationFixedBeginMode($this->dateTime, $this->configuration), ]; } diff --git a/templates/default/_form_delete_modal.html.twig b/templates/default/_form_delete_modal.html.twig index cfe1ecf0..9573e86e 100644 --- a/templates/default/_form_delete_modal.html.twig +++ b/templates/default/_form_delete_modal.html.twig @@ -1,4 +1,5 @@ {% embed 'embeds/modal.html.twig' %} + {% set used = used|default(false) %} {% block modal_id %}form_modal{% endblock %} {% block modal_class %}{% if used %}modal-danger{% endif %}{% endblock %} {% block modal_before %}{{ form_start(form) }}{% endblock %} diff --git a/templates/export/renderer/default.html.twig b/templates/export/renderer/default.html.twig index be41fe6c..526e76d6 100644 --- a/templates/export/renderer/default.html.twig +++ b/templates/export/renderer/default.html.twig @@ -1,6 +1,7 @@ {% import "macros/widgets.html.twig" as widgets %} {% extends 'export/layout.html.twig' %} +{% set columnTitles = {} %} {% set columns = { 'date': true, 'username': false, @@ -8,13 +9,21 @@ 'project': true, 'activity': true, 'description': false, - 'tags': 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, 'fixedRate': false, 'duration': 'label.duration', 'rate': 'label.rate', -} %} +}) %} {% block javascripts %}