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
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).

View File

@@ -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

View File

@@ -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.

View File

@@ -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] }

View File

@@ -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<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:
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<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:
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<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:
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<string>
serialized_name: tags
exp: "object.getTagsAsArray()"
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;
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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,
]);

View File

@@ -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)
{

View File

@@ -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)
{

View File

@@ -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', []),

View File

@@ -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)
{

View File

@@ -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, [

View File

@@ -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)

View File

@@ -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
*/

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\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
*/

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\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
*/

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()
* @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;
}
}

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.
*/
class ConfigureAdminMenuEvent extends Event
final class ConfigureAdminMenuEvent extends Event
{
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.
*/
class ConfigureMainMenuEvent extends Event
final class ConfigureMainMenuEvent extends Event
{
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 Symfony\Component\EventDispatcher\Event;
class DashboardEvent extends Event
final class DashboardEvent extends Event
{
public const DASHBOARD = 'app.dashboard';

View File

@@ -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';

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.
*/
class SystemConfigurationEvent extends Event
final class SystemConfigurationEvent extends Event
{
public const CONFIGURE = 'app.system_configuration';

View File

@@ -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';

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
*/
class UserPreferenceEvent extends Event
final class UserPreferenceEvent extends Event
{
public const CONFIGURE = 'app.user_preferences';

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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),
]);

View File

@@ -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

View File

@@ -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;

View File

@@ -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

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\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',

View File

@@ -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);
}

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();
$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;
}
/**

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
{
/**
* @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

View File

@@ -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

View File

@@ -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();
*/
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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),
];
}

View File

@@ -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 %}

View File

@@ -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 %}
<script type="text/javascript">
@@ -97,7 +106,7 @@
<div class="form-group">
<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 %}>
{{ ('label.'~columnId)|trans }}
{{ columnTitles[columnId]|default('label.'~columnId)|trans }}
</label>
</div>
{% endfor %}
@@ -229,7 +238,9 @@
<thead>
<tr>
{% 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 %}
</tr>
</thead>
@@ -268,11 +279,6 @@
{{ entry.description|desc2html }}
{% endif %}
</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 %}>
{% if entry.exported %}
{{ 'entryState.exported'|trans }}
@@ -280,6 +286,19 @@
{{ 'entryState.not_exported'|trans }}
{% endif %}
</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 %}>
{{ entry.hourlyRate|money(entry.project.customer.currency) }}
</td>

View File

@@ -220,10 +220,12 @@ class ActivityControllerTest extends APIControllerBaseTest
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) {
$expectedKeys = array_merge($expectedKeys, ['comment', 'budget', 'timeBudget']);
$expectedKeys = array_merge($expectedKeys, [
'comment', 'budget', 'timeBudget'
]);
}
$actual = array_keys($result);

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ use App\Entity\User;
use App\Tests\DataFixtures\ActivityFixtures;
use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\ActivityTestMetaFieldSubscriberMock;
use Doctrine\ORM\EntityManager;
/**
@@ -77,6 +78,18 @@ class ActivityControllerTest extends ControllerBaseTest
$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()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -14,6 +14,7 @@ use App\Entity\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\CustomerTestMetaFieldSubscriberMock;
use Doctrine\ORM\EntityManager;
/**
@@ -77,6 +78,18 @@ class CustomerControllerTest extends ControllerBaseTest
$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()
{
$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\ProjectFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\ProjectTestMetaFieldSubscriberMock;
use Doctrine\ORM\EntityManager;
/**
@@ -70,6 +71,18 @@ class ProjectControllerTest extends ControllerBaseTest
$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()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -13,6 +13,7 @@ use App\Entity\Timesheet;
use App\Entity\User;
use App\Form\Type\DateRangeType;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\TimesheetTestMetaFieldSubscriberMock;
/**
* @group integration
@@ -144,6 +145,18 @@ class TimesheetControllerTest extends ControllerBaseTest
$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()
{
$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;
use App\Entity\Activity;
use App\Entity\ActivityMeta;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase;
/**
@@ -31,6 +33,9 @@ class ActivityTest extends TestCase
$this->assertNull($sut->getColor());
$this->assertEquals(0.0, $sut->getBudget());
$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()
@@ -61,4 +66,31 @@ class ActivityTest extends TestCase
$this->assertInstanceOf(Activity::class, $sut->setTimeBudget(937321));
$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;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase;
/**
@@ -46,6 +48,9 @@ class CustomerTest extends TestCase
$this->assertNull($sut->getColor());
$this->assertEquals(0.0, $sut->getBudget());
$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()
@@ -97,4 +102,31 @@ class CustomerTest extends TestCase
$this->assertInstanceOf(Customer::class, $sut->setTimeBudget(937321));
$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\Project;
use App\Entity\ProjectMeta;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase;
/**
@@ -36,6 +38,9 @@ class ProjectTest extends TestCase
$this->assertNull($sut->getColor());
$this->assertEquals(0.0, $sut->getBudget());
$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()
@@ -73,4 +78,31 @@ class ProjectTest extends TestCase
$this->assertInstanceOf(Project::class, $sut->setTimeBudget(937321));
$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\Tag;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase;
/**
@@ -39,11 +41,13 @@ class TimesheetTest extends TestCase
$this->assertNull($sut->getHourlyRate());
$this->assertEquals(new ArrayCollection(), $sut->getTags());
$this->assertEquals([], $sut->getTagsAsArray());
$this->assertInstanceOf(Timesheet::class, $sut->setFixedRate(13.47));
$this->assertEquals(13.47, $sut->getFixedRate());
$this->assertInstanceOf(Timesheet::class, $sut->setHourlyRate(99));
$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()
@@ -89,4 +93,31 @@ class TimesheetTest extends TestCase
$sut->removeTag($tag1);
$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\Tag;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
@@ -139,6 +140,8 @@ abstract class AbstractRendererTest extends KernelTestCase
->setEnd(new \DateTime('2019-06-16 12:06:40'))
->addTag((new Tag())->setName('foo'))
->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];

View File

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

View File

@@ -20,14 +20,14 @@ class DebugRendererTest extends TestCase
public function getTestModel()
{
yield [$this->getInvoiceModel(), '1,947.99', 5, 5, 1, 2, 2, true];
yield [$this->getInvoiceModelOneEntry(), '293.27', 1, 1, 0, 1, 0, false];
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, []];
}
/**
* @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'));
$sut = new DebugRenderer();
@@ -39,14 +39,16 @@ class DebugRendererTest extends TestCase
$rows = $data['entries'];
$this->assertEquals($expectedRows, count($rows));
$i = 0;
foreach ($rows as $row) {
$this->assertEntryStructure($row);
$meta = isset($metaFields[$i]) ? $metaFields[$i++] : [];
$this->assertEntryStructure($row, $meta);
}
// TODO check values or formats?
}
protected function assertModelStructure(array $model, $hasProject = true)
protected function assertModelStructure(array $model, $hasProject = true, $hasActivity = false)
{
$keys = [
'invoice.due_date',
@@ -77,6 +79,11 @@ class DebugRendererTest extends TestCase
'customer.number',
'customer.homepage',
'customer.comment',
'customer.meta.foo-customer',
'activity.id',
'activity.name',
'activity.comment',
'activity.meta.foo-activity',
];
if ($hasProject) {
@@ -85,6 +92,15 @@ class DebugRendererTest extends TestCase
'project.name',
'project.comment',
'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);
}
protected function assertEntryStructure(array $model)
protected function assertEntryStructure(array $model, array $metaFields)
{
$keys = [
'entry.row',
@@ -125,6 +141,8 @@ class DebugRendererTest extends TestCase
'entry.customer_id',
];
$keys = array_merge($keys, $metaFields);
foreach ($keys as $key) {
$this->assertArrayHasKey($key, $model);
}
@@ -134,7 +152,7 @@ class DebugRendererTest extends TestCase
$givenKeys = array_keys($model);
sort($givenKeys);
$this->assertEquals(count($keys), count($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\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\InvoiceDocument;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Entity\ProjectMeta;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User;
use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\NumberGenerator\DateNumberGenerator;
@@ -85,6 +89,8 @@ trait RendererTestTrait
{
$customer = new Customer();
$customer->setCurrency('EUR');
$customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true));
$template = new InvoiceTemplate();
$template->setTitle('a test invoice template title');
$template->setVat(19);
@@ -92,10 +98,12 @@ trait RendererTestTrait
$project = new Project();
$project->setName('project name');
$project->setCustomer($customer);
$project->setMetaField((new ProjectMeta())->setName('foo-project')->setValue('bar-project')->setIsVisible(true));
$activity = new Activity();
$activity->setName('activity description');
$activity->setProject($project);
$activity->setMetaField((new ActivityMeta())->setName('foo-activity')->setValue('bar-activity')->setIsVisible(true));
$userMethods = ['getId', 'getPreferenceValue', 'getUsername'];
$user1 = $this->getMockBuilder(User::class)->setMethods($userMethods)->disableOriginalConstructor()->getMock();
@@ -116,7 +124,7 @@ trait RendererTestTrait
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
->setMetaField((new TimesheetMeta())->setName('foo-timesheet')->setValue('bar-timesheet')->setIsVisible(true));
$timesheet2 = new Timesheet();
$timesheet2
@@ -127,6 +135,8 @@ trait RendererTestTrait
->setProject($project)
->setBegin(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();
@@ -138,6 +148,7 @@ trait RendererTestTrait
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
->setMetaField((new TimesheetMeta())->setName('foo-timesheet')->setValue('bar-timesheet1')->setIsVisible(true))
;
$timesheet4 = new Timesheet();
@@ -149,6 +160,7 @@ trait RendererTestTrait
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
->setMetaField((new TimesheetMeta())->setName('foo-timesheet3')->setValue('bluuuub')->setIsVisible(true))
;
$timesheet5 = new Timesheet();
@@ -193,6 +205,8 @@ trait RendererTestTrait
{
$customer = new Customer();
$customer->setCurrency('USD');
$customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true));
$template = new InvoiceTemplate();
$template->setTitle('a test invoice template title');
$template->setVat(19);
@@ -200,10 +214,12 @@ trait RendererTestTrait
$project = new Project();
$project->setName('project name');
$project->setCustomer($customer);
$project->setMetaField((new ProjectMeta())->setName('foo-project')->setValue('bar-project')->setIsVisible(true));
$activity = new Activity();
$activity->setName('activity description');
$activity->setProject($project);
$activity->setMetaField((new ActivityMeta())->setName('foo-activity')->setValue('bar-activity')->setIsVisible(true));
$userMethods = ['getId', 'getPreferenceValue', 'getUsername'];
$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\Tests\Configuration\TestConfigLoader;
use App\Tests\Mocks\Security\UserDateTimeFactoryFactory;
use App\Timesheet\TrackingMode\DurationFixedStartMode;
use App\Timesheet\TrackingMode\DurationFixedBeginMode;
use PHPUnit\Framework\TestCase;
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()
{
@@ -28,7 +28,7 @@ class DurationFixedStartModeTest extends TestCase
$dateTime = (new UserDateTimeFactoryFactory($this))->create();
$configuration = new TimesheetConfiguration($loader, ['default_begin' => '13:47']);
return new DurationFixedStartMode($dateTime, $configuration);
return new DurationFixedBeginMode($dateTime, $configuration);
}
public function testDefaultValues()
@@ -40,7 +40,7 @@ class DurationFixedStartModeTest extends TestCase
self::assertTrue($sut->canEditDuration());
self::assertFalse($sut->canUpdateTimesWithAPI());
self::assertFalse($sut->canSeeBeginAndEndTimes());
self::assertEquals('duration_fixed_start', $sut->getId());
self::assertEquals('duration_fixed_begin', $sut->getId());
}
public function testCreate()
@@ -54,4 +54,14 @@ class DurationFixedStartModeTest extends TestCase
$sut->create($timesheet, $request);
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('punch', $ids);
self::assertContains('duration_only', $ids);
self::assertContains('duration_fixed_start', $ids);
self::assertContains('duration_fixed_begin', $ids);
}
public function testGetActiveMode()

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