weekly quick-entry form (#2793)

This commit is contained in:
Kevin Papst
2021-10-18 11:12:46 +02:00
committed by GitHub
parent 64893e0a95
commit 9ab098af86
105 changed files with 3356 additions and 778 deletions

View File

@@ -6,6 +6,14 @@ coverage:
precision: 2
round: down
range: "80...100"
status:
project:
default:
threshold: 0.5%
patch:
default:
threshold: 50%
changes: no
parsers:
gcov:

View File

@@ -38,6 +38,10 @@ export default class KimaiDateUtils extends KimaiPlugin {
return this.formatMomentDuration(duration);
}
/**
* @param {moment.Duration} duration
* @returns {string|*}
*/
formatMomentDuration(duration) {
const hours = parseInt(duration.asHours()).toString();
const minutes = duration.minutes();
@@ -61,4 +65,38 @@ export default class KimaiDateUtils extends KimaiPlugin {
return format.replace('%h', hours).replace('%m', ('0' + minutes).substr(-2)).replace('%s', ('0' + seconds).substr(-2));
}
/**
* @param {string} duration
* @returns {int}
*/
getSecondsFromDurationString(duration)
{
duration = duration.trim().toUpperCase();
let momentDuration = moment.duration(NaN);
if (duration.indexOf(':') !== -1) {
momentDuration = moment.duration(duration);
} else if (duration.indexOf('.') !== -1 || duration.indexOf(',') !== -1) {
duration = duration.replace(/,/, '.');
duration = (parseFloat(duration) * 3600).toString();
momentDuration = moment.duration('PT' + duration + 'S');
} else if (duration.indexOf('H') !== -1 || duration.indexOf('M') !== -1 || duration.indexOf('S') !== -1) {
/* D for days does not work, because 'PT1H' but with days 'P1D' is used */
momentDuration = moment.duration('PT' + duration);
} else {
let c = parseInt(duration);
let d = parseInt(duration).toFixed();
if (!isNaN(c) && duration === d) {
duration = (c * 3600).toString();
momentDuration = moment.duration('PT' + duration + 'S');
}
}
if (!momentDuration.isValid()) {
return 0;
}
return momentDuration.asSeconds();
}
}

View File

@@ -23,19 +23,21 @@ export default class KimaiFormSelect extends KimaiPlugin {
return 'form-select';
}
activateSelectPicker(selector, container) {
const elementSelector = this.selector;
let options = {};
if (container !== undefined) {
options = {
dropdownParent: $(container),
};
}
init() {
// selects the original value inside select2 dropdowns, as the "reset" event (the updated option)
// is not automatically catched by select2
jQuery('body').on('reset', 'form', function(event) {
setTimeout(function() {
jQuery(event.target).find(this.selector).trigger('change');
}, 10);
});
const self = this;
// Function to match the name of the parent and not only the names of the children
// Based on the original matcher function of Select2: https://github.com/select2/select2/blob/5765090318c4d382ae56463cfa25ba8ca7bdd495/src/js/select2/defaults.js#L272
// More information: https://select2.org/searching | https://github.com/select2/docs/blob/develop/pages/11.searching/docs.md
function matcher(params, data) {
this.matcher = function (params, data) {
// Always return the object if there is nothing to compare
if (jQuery.trim(params.term) === '') {
return data;
@@ -86,7 +88,7 @@ export default class KimaiFormSelect extends KimaiPlugin {
for (let c = data.children.length - 1; c >= 0; c--) {
let child = data.children[c];
let matches = matcher(newParams, child);
let matches = self.matcher(newParams, child);
// If there wasn't a match, remove the object in the array
if (matches === null) {
@@ -103,29 +105,42 @@ export default class KimaiFormSelect extends KimaiPlugin {
// If the option or its children do not contain the term, don't return anything
return null;
}
}
activateSelectPickerByElement(node, container) {
let options = {};
if (container !== undefined) {
options = {
dropdownParent: jQuery(container),
};
}
options = {...options, ...{
language: this.getConfiguration('locale').replace('_', '-'),
theme: "bootstrap",
matcher: matcher
matcher: this.matcher
}};
if (node.dataset['renderer'] !== undefined && node.dataset['renderer'] === 'color') {
const templateResultFunc = function (state) {
return jQuery('<span><span style="background-color:'+state.id+'; width: 20px; height: 20px; display: inline-block; margin-right: 10px;">&nbsp;</span>' + state.text + '</span>');
};
let optionsColor = {...options, ...{
const colorOptions = {...options, ...{
templateSelection: templateResultFunc,
templateResult: templateResultFunc
}};
jQuery(selector + ' ' + elementSelector + ':not([data-renderer=color])').select2(options);
jQuery(selector + ' ' + elementSelector + '[data-renderer=color]').select2(optionsColor);
jQuery(node).select2(colorOptions);
} else {
jQuery(node).select2(options);
}
}
jQuery('body').on('reset', 'form', function(event){
setTimeout(function() {
jQuery(event.target).find(elementSelector).trigger('change');
}, 10);
activateSelectPicker(selector, container) {
const self = this;
jQuery(selector + ' ' + this.selector).each(function(i, el) {
self.activateSelectPickerByElement(el, container);
});
}

View File

@@ -40,10 +40,10 @@ export default class KimaiSelectDataAPI extends KimaiPlugin {
return;
}
let formPrefix = jQuery(this).parents('form').first().attr('name');
let formPrefix = this.dataset['formPrefix'];
if (formPrefix === undefined || formPrefix === null) {
formPrefix = '';
} else {
} else if (formPrefix.length > 0) {
formPrefix += '_';
}
@@ -74,11 +74,11 @@ export default class KimaiSelectDataAPI extends KimaiPlugin {
let newApiUrl = apiUrl;
apiUrl.split('?')[1].split('&').forEach(item => {
let [key, value] = item.split('=');
let decoded = decodeURIComponent(value);
let test = decoded.match(/%(.*)%/);
const [key, value] = item.split('=');
const decoded = decodeURIComponent(value);
const test = decoded.match(/%(.*)%/);
if (test !== null) {
let targetField = jQuery('#' + formPrefix + test[1]);
const targetField = jQuery('#' + formPrefix + test[1]);
let newValue = '';
if (targetField.length === 0) {
// happens for example:

View File

@@ -5,6 +5,7 @@
* file that was distributed with this source code.
*/
@import "variables";
table.dataTable.table > tbody > tr > td {
vertical-align: middle;
@@ -98,6 +99,28 @@ table.dataTable {
}
}
}
th.weekend,
td.weekend {
background-color: #f9f9f9;
}
/* order is important, "today” should overwrite "weekend" therefor later in the file */
th.today,
td.today {
background-color: $highlight-today;
}
th.total,
td.total {
font-weight: bold;
}
}
/* Quick entry form */
.form-dataTable {
table.dataTable {
.form-group {
margin-bottom: 0;
}
}
}
table.table-hover {

View File

@@ -1,8 +1,3 @@
.dataTable {
.weekend {
background-color: #f9f9f9;
}
}
.form-reporting .box-header .form-group {
margin-right: 10px;

View File

@@ -9,6 +9,7 @@
/* For highlighting rows, fieldsets and so on */
$highlight-bg: #f5f5f5;
$highlight-today: #fcf8e3;
/**
* BELOW: A simple one to one clone of /node_modules/admin-lte/build/less/variables.less

14
composer.lock generated
View File

@@ -6149,16 +6149,16 @@
},
{
"name": "symfony/flex",
"version": "v1.16.3",
"version": "v1.17.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/flex.git",
"reference": "f05406b33681409b83285f4d2ff4efbca7c55b03"
"reference": "782ef2269622b8349c4bc3dc795fc79d39e8a5b2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/flex/zipball/f05406b33681409b83285f4d2ff4efbca7c55b03",
"reference": "f05406b33681409b83285f4d2ff4efbca7c55b03",
"url": "https://api.github.com/repos/symfony/flex/zipball/782ef2269622b8349c4bc3dc795fc79d39e8a5b2",
"reference": "782ef2269622b8349c4bc3dc795fc79d39e8a5b2",
"shasum": ""
},
"require": {
@@ -6175,7 +6175,7 @@
"type": "composer-plugin",
"extra": {
"branch-alias": {
"dev-main": "1.16-dev"
"dev-main": "1.17-dev"
},
"class": "Symfony\\Flex\\Flex"
},
@@ -6197,7 +6197,7 @@
"description": "Composer plugin for Symfony",
"support": {
"issues": "https://github.com/symfony/flex/issues",
"source": "https://github.com/symfony/flex/tree/v1.16.3"
"source": "https://github.com/symfony/flex/tree/v1.17.1"
},
"funding": [
{
@@ -6213,7 +6213,7 @@
"type": "tidelift"
}
],
"time": "2021-09-28T17:00:52+00:00"
"time": "2021-10-14T06:14:48+00:00"
},
{
"name": "symfony/form",

View File

@@ -65,6 +65,9 @@ services:
App\Validator\Constraints\TimesheetValidator:
arguments: [!tagged timesheet.validator]
App\Validator\Constraints\QuickEntryTimesheetValidator:
arguments: [!tagged timesheet.validator]
App\Widget\WidgetService:
arguments:
$renderer: !tagged widget.renderer

4
public/avatars/README.md Normal file
View File

@@ -0,0 +1,4 @@
This directory was used before version 1.15 to write generated user avatar images.
This solution was replaced by CSS generated avatars and therefore this directory is not needed any longer.
We'll keep it in GIT for all the custom cache clean scripts that might exist out there.
This directory is @deprecated since 1.15 and will be removed with 2.0.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": {
"js": [
"build/runtime.b8e7bb04.js",
"build/app.e22732af.js"
"build/app.da44b7f8.js"
],
"css": [
"build/app.c57cdec8.css"
"build/app.d2b280dd.css"
]
},
"invoice": {

View File

@@ -1,6 +1,6 @@
{
"build/app.css": "build/app.c57cdec8.css",
"build/app.js": "build/app.e22732af.js",
"build/app.css": "build/app.d2b280dd.css",
"build/app.js": "build/app.da44b7f8.js",
"build/invoice.css": "build/invoice.ff32661a.css",
"build/invoice.js": "build/invoice.19f36eca.js",
"build/invoice-pdf.css": "build/invoice-pdf.9a7468ef.css",

View File

@@ -115,7 +115,8 @@ final class ReloadCommand extends Command
$io->warning(
[
'Cache could not be rebuilt.',
'Please run the cache commands manually:',
'Please run these commands to rebuild the cache manually:',
'rm -r var/cache/*' . PHP_EOL .
'bin/console cache:clear --env=' . $environment . PHP_EOL .
'bin/console cache:warmup --env=' . $environment
]

View File

@@ -29,7 +29,6 @@ use App\Form\Toolbar\ActivityToolbarForm;
use App\Form\Type\ActivityType;
use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\ActivityQuery;
use App\Repository\TeamRepository;
use Exception;
@@ -314,6 +313,13 @@ final class ActivityController extends AbstractController
{
$stats = $statisticService->getActivityStatistics($activity);
$options = [
'projects' => $activity->getProject(),
'query_builder_for_user' => true,
'ignore_activity' => $activity,
'required' => false,
];
$deleteForm = $this->createFormBuilder(null, [
'attr' => [
'data-form-event' => 'kimai.activityDelete',
@@ -321,17 +327,7 @@ final class ActivityController extends AbstractController
'data-msg-error' => 'action.delete.error',
]
])
->add('activity', ActivityType::class, [
'label' => 'label.activity',
'query_builder' => function (ActivityRepository $repo) use ($activity) {
$query = new ActivityFormTypeQuery();
$query->addProject($activity->getProject());
$query->setActivityToIgnore($activity);
return $repo->getQueryBuilderForFormType($query);
},
'required' => false,
])
->add('activity', ActivityType::class, $options)
->setAction($this->generateUrl('admin_activity_delete', ['id' => $activity->getId()]))
->setMethod('POST')
->getForm();

View File

@@ -30,7 +30,6 @@ use App\Form\Type\CustomerType;
use App\Repository\CustomerRateRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository;
@@ -384,14 +383,8 @@ final class CustomerController extends AbstractController
]
])
->add('customer', CustomerType::class, [
'label' => 'label.customer',
'query_builder' => function (CustomerRepository $repo) use ($customer) {
$query = new CustomerFormTypeQuery();
$query->setCustomerToIgnore($customer);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query);
},
'query_builder_for_user' => true,
'ignore_customer' => $customer,
'required' => false,
])
->setAction($this->generateUrl('admin_customer_delete', ['id' => $customer->getId()]))

View File

@@ -35,7 +35,6 @@ use App\Repository\ActivityRepository;
use App\Repository\ProjectRateRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository;
use Pagerfanta\Pagerfanta;
@@ -428,15 +427,9 @@ final class ProjectController extends AbstractController
]
])
->add('project', ProjectType::class, [
'label' => 'label.project',
'query_builder' => function (ProjectRepository $repo) use ($project) {
$query = new ProjectFormTypeQuery();
$query->addCustomer($project->getCustomer());
$query->setProjectToIgnore($project);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query);
},
'ignore_project' => $project,
'customers' => $project->getCustomer(),
'query_builder_for_user' => true,
'required' => false,
])
->setAction($this->generateUrl('admin_project_delete', ['id' => $project->getId()]))

View File

@@ -0,0 +1,234 @@
<?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\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\Timesheet;
use App\Form\QuickEntryForm;
use App\Model\QuickEntryModel;
use App\Model\QuickEntryWeek;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
use App\Timesheet\TimesheetService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to enter times in weekly form.
*
* @Route(path="/quick_entry")
* @Security("is_granted('view_own_timesheet')")
*/
class QuickEntryController extends AbstractController
{
private $configuration;
private $timesheetService;
private $repository;
public function __construct(SystemConfiguration $configuration, TimesheetService $timesheetService, TimesheetRepository $repository)
{
$this->configuration = $configuration;
$this->timesheetService = $timesheetService;
$this->repository = $repository;
}
/**
* @Route(path="/{begin}", name="quick_entry", methods={"GET", "POST"})
* @Security("is_granted('edit_own_timesheet')")
*/
public function quickEntry(Request $request, ?string $begin = null)
{
$mode = $this->timesheetService->getActiveTrackingMode();
if (!$mode->canEditDuration() && !$mode->canEditEnd()) {
$this->flashError('Not allowed');
return $this->redirectToRoute('homepage');
}
$factory = $this->getDateTimeFactory();
if ($begin === null) {
$begin = $factory->createDateTime();
} else {
$begin = $factory->createDateTime($begin);
}
$startWeek = $factory->getStartOfWeek($begin);
$endWeek = $factory->getEndOfWeek($begin);
$user = $this->getUser();
$tmpDay = clone $startWeek;
$week = [];
while ($tmpDay < $endWeek) {
$nextDay = clone $tmpDay;
$week[$nextDay->format('Y-m-d')] = ['day' => $nextDay];
$tmpDay = $tmpDay->modify('+1 day');
}
$query = new TimesheetQuery();
$query->setBegin($startWeek);
$query->setEnd($endWeek);
$query->setName('quickEntryForm');
$query->setUser($this->getUser());
$result = $this->repository->getTimesheetResult($query);
$rows = [];
/** @var Timesheet $timesheet */
foreach ($result->getResults(true) as $timesheet) {
$i = 0;
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
$day = $timesheet->getBegin()->format('Y-m-d');
while (\array_key_exists($id, $rows) && \array_key_exists('entry', $rows[$id]['days'][$day])) {
$i++;
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId() . '_' . $i;
}
if (!\array_key_exists($id, $rows)) {
$rows[$id] = [
'days' => $week,
'project' => $timesheet->getProject(),
'activity' => $timesheet->getActivity()
];
}
$rows[$id]['days'][$day]['entry'] = $timesheet;
}
ksort($rows);
// attach recent activities
$timesheets = $this->repository->getRecentActivities($this->getUser(), null, 5);
foreach ($timesheets as $timesheet) {
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
if (\array_key_exists($id, $rows)) {
continue;
}
$rows[$id] = [
'days' => $week,
'project' => $timesheet->getProject(),
'activity' => $timesheet->getActivity()
];
}
$beginTime = $this->configuration->getTimesheetDefaultBeginTime();
/** @var QuickEntryModel[] $models */
$models = [];
foreach ($rows as $id => $row) {
$model = new QuickEntryModel($user, $row['project'], $row['activity']);
foreach ($row['days'] as $dayId => $day) {
if (!\array_key_exists('entry', $day)) {
$tmp = new Timesheet();
$tmp->setUser($user);
$tmp->setProject($row['project']);
$tmp->setActivity($row['activity']);
$tmp->setBegin(clone $day['day']);
$tmp->getBegin()->modify($beginTime);
$model->addTimesheet($tmp);
} else {
$model->addTimesheet($day['entry']);
}
}
$models[] = $model;
}
// create prototype model
$empty = new QuickEntryModel($user);
foreach ($week as $dayId => $day) {
$tmp = new Timesheet();
$tmp->setUser($user);
$tmp->setBegin(clone $day['day']);
$tmp->getBegin()->modify($beginTime);
$empty->addTimesheet($tmp);
}
// add empty rows for simpler starting
$minRows = 3;
if (\count($models) < $minRows) {
$newRows = $minRows - \count($models);
for ($a = 0; $a < $newRows; $a++) {
$model = new QuickEntryModel();
foreach ($week as $dayId => $day) {
$tmp = new Timesheet();
$tmp->setUser($user);
$tmp->setBegin(clone $day['day']);
$tmp->getBegin()->modify($beginTime);
$model->addTimesheet($tmp);
}
$models[] = $model;
}
}
$formModel = new QuickEntryWeek($startWeek, $models);
$form = $this->createForm(QuickEntryForm::class, $formModel, [
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
'prototype_data' => $empty,
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var QuickEntryWeek $data */
$data = $form->getData();
$saveTimesheets = [];
$deleteTimesheets = [];
foreach ($data->getRows() as $tmpModel) {
foreach ($tmpModel->getTimesheets() as $timesheet) {
if ($timesheet->getId() !== null) {
if ($timesheet->getDuration(false) === null || $timesheet->getEnd() === null) {
$deleteTimesheets[] = $timesheet;
} else {
$saveTimesheets[] = $timesheet;
}
} else {
if ($timesheet->getDuration() !== null) {
$saveTimesheets[] = $timesheet;
}
}
}
}
if ($this->isGranted('delete_own_timesheet') && \count($deleteTimesheets) > 0) {
try {
$this->timesheetService->deleteMultipleTimesheets($deleteTimesheets);
return $this->redirectToRoute('quick_entry', ['begin' => $begin->format('Y-m-d')]);
} catch (\Exception $ex) {
$this->flashError('action.delete.error');
$this->logException($ex);
}
}
if (\count($saveTimesheets) > 0) {
try {
$this->timesheetService->updateMultipleTimesheets($saveTimesheets);
return $this->redirectToRoute('quick_entry', ['begin' => $begin->format('Y-m-d')]);
} catch (\Exception $ex) {
$this->flashError('action.update.error');
$this->logException($ex);
}
}
}
return $this->render('quick-entry/index.html.twig', [
'days' => $week,
'week' => $rows,
'form' => $form->createView(),
]);
}
}

View File

@@ -371,7 +371,7 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
{
$this->begin = $begin;
$this->timezone = $begin->getTimezone()->getName();
// make sure that the original date is always
// make sure that the original date is always kept in UTC
$this->date = new DateTime($begin->format('Y-m-d 00:00:00'), new DateTimeZone('UTC'));
return $this;
@@ -421,12 +421,13 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
/**
* Do not rely on the results of this method for running records.
*
* @param bool $calculate
* @return int|null
*/
public function getDuration(): ?int
public function getDuration(bool $calculate = true): ?int
{
// only auto calculate if manually set duration is null - the result is important for eg. validations
if ($this->duration === null && $this->begin !== null && $this->end !== null) {
if ($calculate && $this->duration === null && $this->begin !== null && $this->end !== null) {
return $this->end->getTimestamp() - $this->begin->getTimestamp();
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\EventSubscriber\Actions;
use App\Event\PageActionsEvent;
class QuickEntrySubscriber extends AbstractActionsSubscriber
{
public static function getActionName(): string
{
return 'weekly_times';
}
public function onActions(PageActionsEvent $event): void
{
if ($this->isGranted('view_own_timesheet')) {
$event->addBack($this->path('timesheet'));
}
$event->addHelp($this->documentationLink('weekly-times.html'));
}
}

View File

@@ -35,6 +35,7 @@ class TimesheetsSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('create_own_timesheet')) {
$event->addCreate($this->path('timesheet_create'));
$event->addAction('quick_entry', ['url' => $this->path('quick_entry'), 'class' => 'create-ts', 'icon' => 'weekly-times']);
}
$event->addHelp($this->documentationLink('timesheet.html'));

View File

@@ -12,8 +12,6 @@ namespace App\EventSubscriber;
use App\Event\ConfigureMainMenuEvent;
use App\Twig\IconExtension;
use App\Utils\MenuItemModel;
use KevinPapst\AdminLTEBundle\Event\SidebarMenuEvent;
use KevinPapst\AdminLTEBundle\Model\MenuItemInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
@@ -22,19 +20,11 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
*/
final class MenuSubscriber implements EventSubscriberInterface
{
/**
* @var AuthorizationCheckerInterface
*/
private $security;
/**
* @var IconExtension
*/
private $icons;
public function __construct(AuthorizationCheckerInterface $security)
{
$this->security = $security;
$this->icons = new IconExtension();
}
public static function getSubscribedEvents(): array
@@ -52,67 +42,62 @@ final class MenuSubscriber implements EventSubscriberInterface
return;
}
$this->configureMainMenu($event->getMenu());
$this->configureAdminMenu($event->getAdminMenu());
$this->configureSystemMenu($event->getSystemMenu());
}
$icons = new IconExtension();
private function configureMainMenu(SidebarMenuEvent $menu)
{
$auth = $this->security;
// ------------------- main menu -------------------
$menu = $event->getMenu();
if ($auth->isGranted('view_own_timesheet')) {
$timesheets = new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], $this->getIcon('timesheet'));
$timesheets->setChildRoutes(['timesheet_export', 'timesheet_edit', 'timesheet_create', 'timesheet_multi_update']);
$timesheets = new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], $icons->icon('timesheet'));
$timesheets->setChildRoutes(['timesheet_export', 'timesheet_edit', 'timesheet_create', 'timesheet_multi_update', 'quick_entry']);
$menu->addItem($timesheets);
$menu->addItem(
new MenuItemModel('calendar', 'calendar.title', 'calendar', [], $this->getIcon('calendar'))
new MenuItemModel('calendar', 'calendar.title', 'calendar', [], $icons->icon('calendar'))
);
}
if ($auth->isGranted('view_invoice')) {
$invoice = new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $this->getIcon('invoice'));
$invoice = new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $icons->icon('invoice'));
$invoice->setChildRoutes(['admin_invoice_template', 'admin_invoice_template_edit', 'admin_invoice_template_create', 'admin_invoice_template_copy', 'admin_invoice_list', 'admin_invoice_document_upload']);
$menu->addItem($invoice);
}
if ($auth->isGranted('create_export')) {
$menu->addItem(
new MenuItemModel('export', 'menu.export', 'export', [], $this->getIcon('export'))
new MenuItemModel('export', 'menu.export', 'export', [], $icons->icon('export'))
);
}
}
private function configureAdminMenu(MenuItemInterface $menu)
{
$auth = $this->security;
// ------------------- admin menu -------------------
$menu = $event->getAdminMenu();
if ($auth->isGranted('view_other_timesheet')) {
$timesheets = new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], $this->getIcon('timesheet-team'));
$timesheets = new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], $icons->icon('timesheet-team'));
$timesheets->setChildRoutes(['admin_timesheet_export', 'admin_timesheet_edit', 'admin_timesheet_create', 'admin_timesheet_multi_update']);
$menu->addChild($timesheets);
}
if ($auth->isGranted('view_reporting')) {
$reporting = new MenuItemModel('reporting', 'menu.reporting', 'reporting', [], $this->getIcon('reporting'));
$reporting = new MenuItemModel('reporting', 'menu.reporting', 'reporting', [], $icons->icon('reporting'));
$reporting->setChildRoutes(['report_user_week', 'report_user_month', 'report_weekly_users', 'report_monthly_users', 'report_project_view']);
$menu->addChild($reporting);
}
if ($auth->isGranted('view_customer') || $auth->isGranted('view_teamlead_customer') || $auth->isGranted('view_team_customer')) {
$customers = new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], $this->getIcon('customer'));
$customers = new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], $icons->icon('customer'));
$customers->setChildRoutes(['admin_customer_create', 'admin_customer_permissions', 'customer_details', 'admin_customer_edit', 'admin_customer_delete']);
$menu->addChild($customers);
}
if ($auth->isGranted('view_project') || $auth->isGranted('view_teamlead_project') || $auth->isGranted('view_team_project')) {
$projects = new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], $this->getIcon('project'));
$projects = new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], $icons->icon('project'));
$projects->setChildRoutes(['admin_project_permissions', 'admin_project_create', 'project_details', 'admin_project_edit', 'admin_project_delete']);
$menu->addChild($projects);
}
if ($auth->isGranted('view_activity') || $auth->isGranted('view_teamlead_activity') || $auth->isGranted('view_team_activity')) {
$activities = new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], $this->getIcon('activity'));
$activities = new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], $icons->icon('activity'));
$activities->setChildRoutes(['admin_activity_create', 'activity_details', 'admin_activity_edit', 'admin_activity_delete']);
$menu->addChild($activities);
}
@@ -122,50 +107,43 @@ final class MenuSubscriber implements EventSubscriberInterface
new MenuItemModel('tags', 'menu.tags', 'tags', [], 'fas fa-tags')
);
}
}
private function configureSystemMenu(MenuItemInterface $menu)
{
$auth = $this->security;
// ------------------- system menu -------------------
$menu = $event->getSystemMenu();
if ($auth->isGranted('view_user')) {
$users = new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], $this->getIcon('users'));
$users = new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], $icons->icon('users'));
$users->setChildRoutes(['admin_user_create', 'admin_user_delete', 'user_profile', 'user_profile_edit', 'user_profile_password', 'user_profile_api_token', 'user_profile_roles', 'user_profile_teams', 'user_profile_preferences']);
$menu->addChild($users);
}
if ($auth->isGranted('role_permissions')) {
$users = new MenuItemModel('admin_user_permissions', 'profile.roles', 'admin_user_permissions', [], $this->getIcon('permissions'));
$users = new MenuItemModel('admin_user_permissions', 'profile.roles', 'admin_user_permissions', [], $icons->icon('permissions'));
$menu->addChild($users);
}
if ($auth->isGranted('view_team')) {
$teams = new MenuItemModel('user_team', 'menu.admin_team', 'admin_team', [], $this->getIcon('team'));
$teams = new MenuItemModel('user_team', 'menu.admin_team', 'admin_team', [], $icons->icon('team'));
$teams->setChildRoutes(['admin_team_create', 'admin_team_edit']);
$menu->addChild($teams);
}
if ($auth->isGranted('plugins')) {
$menu->addChild(
new MenuItemModel('plugins', 'menu.plugin', 'plugins', [], $this->getIcon('plugin'))
new MenuItemModel('plugins', 'menu.plugin', 'plugins', [], $icons->icon('plugin'))
);
}
if ($auth->isGranted('system_configuration')) {
$menu->addChild(
new MenuItemModel('system_configuration', 'menu.system_configuration', 'system_configuration', [], $this->getIcon('configuration'))
new MenuItemModel('system_configuration', 'menu.system_configuration', 'system_configuration', [], $icons->icon('configuration'))
);
}
if ($auth->isGranted('system_information')) {
$menu->addChild(
new MenuItemModel('doctor', 'menu.doctor', 'doctor', [], $this->getIcon('doctor'))
new MenuItemModel('doctor', 'menu.doctor', 'doctor', [], $icons->icon('doctor'))
);
}
}
private function getIcon(string $icon)
{
return $this->icons->icon($icon, $icon);
}
}

View File

@@ -65,7 +65,15 @@ class DurationStringToSecondsTransformer implements DataTransformerInterface
}
try {
return $this->formatter->parseDurationString($formatToInt);
$seconds = $this->formatter->parseDurationString($formatToInt);
// DateTime throws if a duration with too many seconds is passed and an amount of so
// many seconds is likely not required in a time-tracking application ;-)
if ($seconds > 315360000000000) {
throw new TransformationFailedException('Maximum duration exceeded.');
}
return $seconds;
} catch (\Exception $e) {
throw new TransformationFailedException($e->getMessage());
}

View File

@@ -68,13 +68,23 @@ class SelectWithApiDataExtension extends AbstractTypeExtension
$apiData['route_params'] = [];
}
$formPrefix = $form->getParent()->getName();
if (!empty($formPrefix)) {
$formPrefix .= '_';
$formPrefixes = [];
$parent = $form->getParent();
do {
$formPrefixes[] = $parent->getName();
} while (($parent = $parent->getParent()) !== null);
$formPrefix = implode('_', array_reverse($formPrefixes));
$formField = $formPrefix;
if (!empty($formField)) {
$formField .= '_';
}
$formField .= $apiData['select'];
$view->vars['attr'] = array_merge($view->vars['attr'], [
'data-related-select' => $formPrefix . $apiData['select'],
'data-form-prefix' => $formPrefix,
'data-related-select' => $formField,
'data-api-url' => $this->router->generate($apiData['route'], $apiData['route_params']),
]);

View File

@@ -17,11 +17,7 @@ use App\Form\Type\CustomerType;
use App\Form\Type\DescriptionType;
use App\Form\Type\ProjectType;
use App\Form\Type\TagsType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
@@ -29,18 +25,16 @@ use Symfony\Component\Form\FormEvents;
/**
* Helper functions to manage dependent customer-project-activity fields.
*
* If you always want to show the list of all available projects/activities, use the form types directly.
*/
trait FormTrait
{
protected function addCustomer(FormBuilderInterface $builder, ?Customer $customer = null)
{
$builder->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'query_builder_for_user' => true,
'customers' => $customer,
'data' => $customer ? $customer : '',
'required' => false,
'placeholder' => '',
@@ -49,30 +43,29 @@ trait FormTrait
]);
}
protected function addProject(FormBuilderInterface $builder, bool $isNew, ?Project $project = null, ?Customer $customer = null)
protected function addProject(FormBuilderInterface $builder, bool $isNew, ?Project $project = null, ?Customer $customer = null, array $options = [])
{
$builder->add('project', ProjectType::class, [
$options = array_merge([
'placeholder' => '',
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
'query_builder_for_user' => true,
'join_customer' => true
], $options);
return $repo->getQueryBuilderForFormType($query);
},
]);
$builder->add('project', ProjectType::class, array_merge($options, [
'projects' => $project,
'customers' => $customer,
]));
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $project, $customer, $isNew) {
function (FormEvent $event) use ($builder, $project, $customer, $isNew, $options) {
$data = $event->getData();
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
$event->getForm()->add('project', ProjectType::class, [
'placeholder' => '',
'activity_enabled' => true,
$event->getForm()->add('project', ProjectType::class, array_merge($options, [
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) {
// is there a better wa to prevent starting a record with a hidden project ?
@@ -90,44 +83,36 @@ trait FormTrait
}
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
$query->setWithCustomer(true);
return $repo->getQueryBuilderForFormType($query);
},
]);
]));
}
);
}
protected function addActivity(FormBuilderInterface $builder, ?Activity $activity = null, ?Project $project = null)
protected function addActivity(FormBuilderInterface $builder, ?Activity $activity = null, ?Project $project = null, array $options = [])
{
$builder->add('activity', ActivityType::class, [
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($builder, $activity, $project) {
$query = new ActivityFormTypeQuery($activity, $project);
$query->setUser($builder->getOption('user'));
$options = array_merge(['placeholder' => '', 'query_builder_for_user' => true], $options);
return $repo->getQueryBuilderForFormType($query);
},
]);
$options['projects'] = $project;
$options['activities'] = $activity;
$builder->add('activity', ActivityType::class, $options);
// replaces the activity select after submission, to make sure only activities for the selected project are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $activity) {
function (FormEvent $event) use ($options) {
$data = $event->getData();
if (!isset($data['project']) || empty($data['project'])) {
return;
}
$event->getForm()->add('activity', ActivityType::class, [
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($builder, $data, $activity) {
$query = new ActivityFormTypeQuery($activity, $data['project']);
$query->setUser($builder->getOption('user'));
$options['projects'] = $data['project'];
return $repo->getQueryBuilderForFormType($query);
},
]);
$event->getForm()->add('activity', ActivityType::class, $options);
}
);
}

View File

@@ -18,12 +18,7 @@ use App\Form\Type\ProjectType;
use App\Form\Type\TagsType;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\TimesheetRepository;
use Doctrine\Common\Collections\Criteria;
use Symfony\Component\Form\AbstractType;
@@ -83,12 +78,8 @@ class TimesheetMultiUpdate extends AbstractType
$builder
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'query_builder_for_user' => true,
'customers' => $customer,
'data' => $customer ? $customer : '',
'required' => false,
'placeholder' => '',
@@ -103,28 +94,20 @@ class TimesheetMultiUpdate extends AbstractType
$projectOptions['group_by'] = null;
}
$builder
->add(
'project',
ProjectType::class,
array_merge($projectOptions, [
$builder->add('project', ProjectType::class, array_merge($projectOptions, [
'required' => false,
'placeholder' => '',
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
])
);
'customers' => $customer,
'projects' => $project,
'query_builder_for_user' => true,
]));
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
// TODO replace me with FormTrait
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $project, $customer) {
function (FormEvent $event) use ($project, $customer) {
$data = $event->getData();
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
@@ -134,45 +117,37 @@ class TimesheetMultiUpdate extends AbstractType
'placeholder' => '',
'activity_enabled' => true,
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'customers' => $customer,
'projects' => $project,
'query_builder_for_user' => true,
]);
}
);
$builder
->add('activity', ActivityType::class, [
$activityOptions = [
'required' => false,
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($activity, $project) {
// TODO respect user (team permission)
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $project));
},
])
;
'activities' => $activity,
'query_builder_for_user' => true,
];
$builder->add('activity', ActivityType::class, array_merge($activityOptions, [
'projects' => $project,
]));
// replaces the activity select after submission, to make sure only activities for the selected project are displayed
// TODO replace me with FormTrait
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($activity) {
function (FormEvent $event) use ($activityOptions) {
$data = $event->getData();
if (!isset($data['project']) || empty($data['project'])) {
return;
}
$event->getForm()->add('activity', ActivityType::class, [
'required' => false,
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
// TODO respect user (team permission)
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $data['project']));
},
]);
$event->getForm()->add('activity', ActivityType::class, array_merge($activityOptions, [
'projects' => $data['project'],
]));
}
);

View File

@@ -13,8 +13,6 @@ use App\Entity\Customer;
use App\Entity\Project;
use App\Form\Type\CustomerType;
use App\Form\Type\DateTimePickerType;
use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerFormTypeQuery;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -90,12 +88,8 @@ class ProjectEditForm extends AbstractType
]))
->add('customer', CustomerType::class, [
'placeholder' => (null === $id && null === $customer) ? '' : false,
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'customers' => $customer,
'query_builder_for_user' => true,
]);
$this->addCommonFields($builder, $options);

View File

@@ -0,0 +1,89 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Configuration\SystemConfiguration;
use App\Form\Type\QuickEntryWeekType;
use App\Form\Type\WeekPickerType;
use App\Model\QuickEntryWeek;
use App\Validator\Constraints\QuickEntryModel;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Validator\Constraints\Valid;
class QuickEntryForm extends AbstractType
{
private $configuration;
public function __construct(SystemConfiguration $configuration)
{
$this->configuration = $configuration;
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$startDate = new \DateTime();
if ($builder->getData() !== null) {
/** @var QuickEntryWeek $data */
$data = $builder->getData();
$startDate = $data->getDate();
}
$builder->add('date', WeekPickerType::class, [
'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'],
'start_date' => $options['start_date'],
'label' => false,
]);
$builder->add('rows', CollectionType::class, [
'label' => false,
'entry_type' => QuickEntryWeekType::class,
'entry_options' => [
'label' => false,
'duration_minutes' => $this->configuration->getTimesheetIncrementDuration(),
'start_date' => $startDate,
'empty_data' => function (FormInterface $form) use ($options) {
return clone $options['prototype_data'];
},
'prototype_data' => clone $options['prototype_data'],
],
'prototype_data' => $options['prototype_data'],
'allow_add' => true,
'constraints' => [
new Valid(),
new All(['constraints' => [new QuickEntryModel()]])
],
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'timesheet_quick_edit',
'data_class' => QuickEntryWeek::class,
'timezone' => date_default_timezone_get(),
'start_date' => new \DateTime(),
'prototype_data' => null,
]);
}
}

View File

@@ -14,6 +14,7 @@ use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -78,10 +79,30 @@ class ActivityType extends AbstractType
'choice_label' => [$this, 'choiceLabel'],
'group_by' => [$this, 'groupBy'],
'choice_attr' => [$this, 'choiceAttr'],
'query_builder' => function (ActivityRepository $repo) {
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery());
},
'query_builder_for_user' => true,
// @var Project|Project[]|int|int[]|null
'projects' => null,
// @var Activity|Activity[]|int|int[]|null
'activities' => null,
// @var Activity|null
'ignore_activity' => null,
]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (ActivityRepository $repo) use ($options) {
$query = new ActivityFormTypeQuery($options['activities'], $options['projects']);
if (true === $options['query_builder_for_user']) {
$query->setUser($options['user']);
}
if (null !== $options['ignore_activity']) {
$query->setActivityToIgnore($options['ignore_activity']);
}
return $repo->getQueryBuilderForFormType($query);
};
});
}
/**

View File

@@ -44,15 +44,23 @@ class CustomerType extends AbstractType
'end_date_param' => '%end%',
'ignore_date' => false,
'project_visibility' => ProjectQuery::SHOW_VISIBLE,
// @var Customer|null
'ignore_customer' => null,
// @var Customer|Customer[]|null
'customers' => null,
]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (CustomerRepository $repo) use ($options) {
$query = new CustomerFormTypeQuery();
$query = new CustomerFormTypeQuery($options['customers']);
if (true === $options['query_builder_for_user']) {
$query->setUser($options['user']);
}
if (null !== $options['ignore_customer']) {
$query->setCustomerToIgnore($options['ignore_customer']);
}
return $repo->getQueryBuilderForFormType($query);
};
});

View File

@@ -38,6 +38,12 @@ class DurationType extends AbstractType
public function buildView(FormView $view, FormInterface $form, array $options)
{
$class = 'duration-input';
if (isset($view->vars['attr']['class'])) {
$class .= ' ' . $view->vars['attr']['class'];
}
$view->vars['attr']['class'] = $class;
if ($options['preset_hours'] === null || $options['preset_minutes'] === null) {
return;
}

View File

@@ -29,6 +29,7 @@ class InitialViewType extends AbstractType
'dashboard' => 'menu.homepage',
'timesheet' => 'menu.timesheet',
'calendar' => 'calendar.title',
'quick_entry' => 'quick_entry.title',
'my_profile' => 'profile.title',
'admin_timesheet' => 'menu.admin_timesheet',
'invoice' => 'menu.invoice',
@@ -49,6 +50,7 @@ class InitialViewType extends AbstractType
'admin_customer' => 'view_customer',
'admin_project' => 'view_project',
'admin_activity' => 'view_activity',
'quick_entry' => 'view_own_timesheet',
];
private $voter;

View File

@@ -68,21 +68,44 @@ class ProjectType extends AbstractType
'activity_visibility' => ActivityQuery::SHOW_VISIBLE,
'ignore_date' => false,
'join_customer' => false,
// @var Project|null
'ignore_project' => null,
// @var Customer|Customer[]|int|int[]|null
'customers' => null,
// @var Project|Project[]|int|int[]|null
'projects' => null,
// @var DateTime|null
'project_date_start' => null,
// @var DateTime|null
'project_date_end' => null,
]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (ProjectRepository $repo) use ($options) {
$query = new ProjectFormTypeQuery();
$query = new ProjectFormTypeQuery($options['projects'], $options['customers']);
if (true === $options['query_builder_for_user']) {
$query->setUser($options['user']);
}
if (true === $options['ignore_date']) {
$query->setIgnoreDate(true);
} else {
if ($options['project_date_start'] !== null) {
$query->setProjectStart($options['project_date_start']);
}
if ($options['project_date_end'] !== null) {
$query->setProjectEnd($options['project_date_end']);
}
}
if (true === $options['join_customer']) {
$query->setWithCustomer(true);
}
if (null !== $options['ignore_project']) {
$query->setProjectToIgnore($options['ignore_project']);
}
return $repo->getQueryBuilderForFormType($query);
};
});

View File

@@ -0,0 +1,108 @@
<?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\Timesheet;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Security;
class QuickEntryTimesheetType extends AbstractType
{
private $security;
public function __construct(Security $security)
{
$this->security = $security;
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$durationOptions = [
'label' => false,
'required' => false,
'attr' => [
'placeholder' => '0:00',
],
];
$duration = $options['duration_minutes'];
if ($duration !== null && (int) $duration > 0) {
$durationOptions = array_merge($durationOptions, [
'preset_minutes' => $duration
]);
}
$duration = $options['duration_hours'];
if ($duration !== null && (int) $duration > 0) {
$durationOptions = array_merge($durationOptions, [
'preset_hours' => $duration,
]);
}
$builder->add('duration', DurationType::class, $durationOptions);
$builder->addEventListener(
FormEvents::POST_SET_DATA,
function (FormEvent $event) use ($durationOptions) {
/** @var Timesheet|null $data */
$data = $event->getData();
if (null === $data || null === $data->getEnd()) {
$event->getForm()->get('duration')->setData(null);
}
if (null !== $data && !$this->security->isGranted('edit', $data)) {
$event->getForm()->add('duration', DurationType::class, array_merge(['disabled' => true], $durationOptions));
}
}
);
// make sure that duration is mapped back to end field
$builder->addEventListener(
FormEvents::SUBMIT,
function (FormEvent $event) {
/** @var Timesheet $data */
$data = $event->getData();
$duration = $data->getDuration(false);
try {
if (null !== $duration) {
$end = clone $data->getBegin();
$end->modify('+ ' . $duration . ' seconds');
$data->setEnd($end);
} else {
$data->setDuration(null);
}
} catch (\Exception $e) {
$event->getForm()->addError(new FormError($e->getMessage()));
}
}
);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Timesheet::class,
'timezone' => date_default_timezone_get(),
'duration_minutes' => null,
'duration_hours' => 10,
]);
}
}

View File

@@ -0,0 +1,180 @@
<?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\Model\QuickEntryModel;
use App\Validator\Constraints\QuickEntryTimesheet;
use DateTime;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Validator\Constraints\Valid;
class QuickEntryWeekType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$projectOptions = [
'label' => false,
'required' => false,
'join_customer' => true,
'query_builder_for_user' => true,
'placeholder' => '',
'activity_enabled' => true
];
$builder->add('project', ProjectType::class, $projectOptions);
$projectFunction = function (FormEvent $event) use ($projectOptions) {
/** @var QuickEntryModel|null $data */
$data = $event->getData();
if ($data === null || $data->getProject() === null) {
return;
}
$begin = clone $data->getFirstEntry()->getBegin();
$begin->setTime(0, 0, 0);
$projectOptions['project_date_start'] = $begin;
$end = clone $data->getLatestEntry()->getBegin();
$end->setTime(23, 59, 59);
$projectOptions['project_date_end'] = $begin;
$projectOptions['projects'] = [$data->getProject()];
$event->getForm()->add('project', ProjectType::class, $projectOptions);
};
$builder->addEventListener(FormEvents::PRE_SET_DATA, $projectFunction);
$activityOptions = [
'label' => false,
'required' => false,
'placeholder' => '',
'query_builder_for_user' => true,
];
$builder->add('activity', ActivityType::class, $activityOptions);
$activityFunction = function (FormEvent $event) use ($activityOptions) {
/** @var QuickEntryModel|null $data */
$data = $event->getData();
if ($data === null || $data->getActivity() === null) {
return;
}
$activityOptions['activities'] = [$data->getActivity()];
$activityOptions['projects'] = [$data->getProject()];
$event->getForm()->add('activity', ActivityType::class, $activityOptions);
};
$builder->addEventListener(FormEvents::PRE_SET_DATA, $activityFunction);
$builder->add('timesheets', CollectionType::class, [
'entry_type' => QuickEntryTimesheetType::class,
'label' => false,
'entry_options' => [
'label' => false,
'compound' => true,
'timezone' => $options['timezone'],
'duration_minutes' => $options['duration_minutes'],
'duration_hours' => $options['duration_hours'],
],
'allow_add' => true,
'constraints' => [
new Valid(),
new All(['constraints' => [new QuickEntryTimesheet()]])
],
]);
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) {
if ($event->getData() === null) {
$event->setData(clone $options['prototype_data']);
}
});
$builder->addModelTransformer(new CallbackTransformer(
function ($transformValue) use ($options) {
/** @var QuickEntryModel|null $transformValue */
if ($transformValue === null || $transformValue->isPrototype()) {
return $transformValue;
}
$project = $transformValue->getProject();
$activity = $transformValue->getActivity();
// this case needs to be handled by the validator
if ($project === null || $activity === null) {
return $transformValue;
}
foreach ($transformValue->getTimesheets() as $timesheet) {
$timesheet->setUser($transformValue->getUser() ?? $options['user']);
$timesheet->setProject($project);
$timesheet->setActivity($activity);
}
return $transformValue;
},
function ($reverseTransformValue) {
return $reverseTransformValue;
}
));
// make sure that duration is mapped back to end field
$builder->addEventListener(
FormEvents::SUBMIT,
function (FormEvent $event) {
/** @var QuickEntryModel $data */
$data = $event->getData();
$newRecords = $data->getNewTimesheet();
$user = $data->getUser();
$project = $data->getProject();
$activity = $data->getActivity();
foreach ($newRecords as $record) {
if ($user !== null) {
$record->setUser($user);
}
if ($project !== null) {
$record->setProject($project);
}
if ($activity !== null) {
$record->setActivity($activity);
}
}
}
);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => QuickEntryModel::class,
'timezone' => date_default_timezone_get(),
'duration_minutes' => null,
'duration_hours' => 10,
'start_date' => new DateTime(),
'prototype_data' => null,
]);
}
}

View File

@@ -0,0 +1,195 @@
<?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\Model;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
/**
* @internal
*/
class QuickEntryModel
{
private $user;
private $project;
private $activity;
/**
* @var Timesheet[]
*/
private $timesheets = [];
public function __construct(?User $user = null, ?Project $project = null, ?Activity $activity = null)
{
$this->user = $user;
$this->project = $project;
$this->activity = $activity;
}
public function isPrototype(): bool
{
if ($this->hasExistingTimesheet()) {
return false;
}
if ($this->hasNewTimesheet()) {
return false;
}
return $this->getUser() === null && $this->getProject() === null && $this->getActivity() === null;
}
public function getUser(): ?User
{
return $this->user;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): void
{
$this->project = $project;
}
public function getActivity(): ?Activity
{
return $this->activity;
}
public function setActivity(?Activity $activity): void
{
$this->activity = $activity;
}
public function hasExistingTimesheet(): bool
{
foreach ($this->timesheets as $timesheet) {
if ($timesheet->getId() !== null) {
return true;
}
}
return false;
}
/**
* @return Timesheet[]
*/
public function getNewTimesheet(): array
{
$new = [];
foreach ($this->timesheets as $timesheet) {
if ($timesheet->getId() === null && $timesheet->getDuration(false) !== null) {
$new[] = $timesheet;
}
}
return $new;
}
public function hasNewTimesheet(): bool
{
return \count($this->getNewTimesheet()) > 0;
}
public function hasTimesheetWithDuration(): bool
{
foreach ($this->timesheets as $timesheet) {
if ($timesheet->getDuration(false) !== null) {
return true;
}
}
return false;
}
/**
* @return Timesheet[]
*/
public function getTimesheets(): array
{
return $this->timesheets;
}
public function addTimesheet(Timesheet $timesheet): void
{
$this->timesheets[] = $timesheet;
}
/**
* @param Timesheet[] $timesheets
*/
public function setTimesheets(array $timesheets): void
{
$this->timesheets = [];
foreach ($timesheets as $timesheet) {
$this->addTimesheet($timesheet);
}
}
public function getLatestEntry(): ?Timesheet
{
$latest = null;
foreach ($this->timesheets as $timesheet) {
if ($timesheet->getBegin() === null) {
continue;
}
if ($latest === null) {
$latest = $timesheet;
continue;
}
if ($latest->getBegin() < $timesheet->getBegin()) {
$latest = $timesheet;
}
}
return $latest;
}
public function getFirstEntry(): ?Timesheet
{
$first = null;
foreach ($this->timesheets as $timesheet) {
if ($timesheet->getBegin() === null) {
continue;
}
if ($first === null) {
$first = $timesheet;
continue;
}
if ($first->getBegin() > $timesheet->getBegin()) {
$first = $timesheet;
}
}
return $first;
}
public function __clone()
{
$records = $this->timesheets;
$this->timesheets = [];
foreach ($records as $record) {
$this->timesheets[] = clone $record;
}
}
}

View File

@@ -0,0 +1,50 @@
<?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\Model;
/**
* @internal
*/
class QuickEntryWeek
{
private $date;
private $rows;
/**
* @param \DateTime $startDate
* @param QuickEntryModel[] $rows
*/
public function __construct(\DateTime $startDate, array $rows)
{
$this->date = $startDate;
$this->rows = $rows;
}
public function getDate(): \DateTime
{
return $this->date;
}
/**
* @return QuickEntryModel[]
*/
public function getRows(): array
{
return $this->rows;
}
/**
* @param QuickEntryModel[] $rows
*/
public function setRows(array $rows): void
{
$this->rows = $rows;
}
}

View File

@@ -24,6 +24,7 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
@@ -160,16 +161,26 @@ class ActivityRepository extends EntityRepository
return $stats;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false)
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams, $globalsOnly);
if ($permissions->count() > 0) {
$qb->andWhere($permissions);
}
}
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): Andx
{
$andX = $qb->expr()->andX();
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {
return;
return $andX;
}
// make sure that admins see all activities
if (null !== $user && $user->canSeeAllData()) {
return;
return $andX;
}
if (null !== $user) {
@@ -177,33 +188,33 @@ class ActivityRepository extends EntityRepository
}
if (empty($teams)) {
$qb->andWhere('SIZE(a.teams) = 0');
$andX->add('SIZE(a.teams) = 0');
if (!$globalsOnly) {
$qb->andWhere('SIZE(p.teams) = 0');
$qb->andWhere('SIZE(c.teams) = 0');
$andX->add('SIZE(p.teams) = 0');
$andX->add('SIZE(c.teams) = 0');
}
return;
return $andX;
}
$orActivity = $qb->expr()->orX(
'SIZE(a.teams) = 0',
$qb->expr()->isMemberOf(':teams', 'a.teams')
);
$qb->andWhere($orActivity);
$andX->add($orActivity);
if (!$globalsOnly) {
$orProject = $qb->expr()->orX(
'SIZE(p.teams) = 0',
$qb->expr()->isMemberOf(':teams', 'p.teams')
);
$qb->andWhere($orProject);
$andX->add($orProject);
$orCustomer = $qb->expr()->orX(
'SIZE(c.teams) = 0',
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($orCustomer);
$andX->add($orCustomer);
}
$ids = array_values(array_unique(array_map(function (Team $team) {
@@ -211,6 +222,8 @@ class ActivityRepository extends EntityRepository
}, $teams)));
$qb->setParameter('teams', $ids);
return $andX;
}
/**
@@ -242,9 +255,9 @@ class ActivityRepository extends EntityRepository
->addOrderBy('a.name', 'ASC')
;
$where = $qb->expr()->andX();
$mainQuery = $qb->expr()->andX();
$where->add($qb->expr()->eq('a.visible', ':visible'));
$mainQuery->add($qb->expr()->eq('a.visible', ':visible'));
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
if (!$query->isGlobalsOnly()) {
@@ -254,7 +267,7 @@ class ActivityRepository extends EntityRepository
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c');
$where->add(
$mainQuery->add(
$qb->expr()->orX(
$qb->expr()->isNull('a.project'),
$qb->expr()->andX(
@@ -268,9 +281,9 @@ class ActivityRepository extends EntityRepository
}
if ($query->isGlobalsOnly()) {
$where->add($qb->expr()->isNull('a.project'));
$mainQuery->add($qb->expr()->isNull('a.project'));
} elseif ($query->hasProjects()) {
$where->add(
$mainQuery->add(
$qb->expr()->orX(
$qb->expr()->isNull('a.project'),
$qb->expr()->in('a.project', ':project')
@@ -279,28 +292,30 @@ class ActivityRepository extends EntityRepository
$qb->setParameter('project', $query->getProjects());
}
if (null !== $query->getActivityToIgnore()) {
$qb->andWhere($qb->expr()->neq('a.id', ':ignored'));
$qb->setParameter('ignored', $query->getActivityToIgnore());
$permissions = $this->getPermissionCriteria($qb, $query->getUser(), $query->getTeams(), $query->isGlobalsOnly());
if ($permissions->count() > 0) {
$mainQuery->add($permissions);
}
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams(), $query->isGlobalsOnly());
$outerQuery = $qb->expr()->orX();
$or = $qb->expr()->orX();
// this must always be the last part before the or
$or->add($where);
// this must always be the last part of the query
if ($query->hasActivities()) {
$or->add($qb->expr()->in('a.id', ':activity'));
$outerQuery->add($qb->expr()->in('a.id', ':activity'));
$qb->setParameter('activity', $query->getActivities());
}
if ($or->count() > 0) {
$qb->andWhere($or);
if (null !== $query->getActivityToIgnore()) {
$mainQuery = $qb->expr()->andX(
$mainQuery,
$qb->expr()->neq('a.id', ':ignored')
);
$qb->setParameter('ignored', $query->getActivityToIgnore());
}
$outerQuery->add($mainQuery);
$qb->andWhere($outerQuery);
return $qb;
}

View File

@@ -25,6 +25,7 @@ use App\Repository\Query\CustomerQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
@@ -157,14 +158,24 @@ class CustomerRepository extends EntityRepository
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
if ($permissions->count() > 0) {
$qb->andWhere($permissions);
}
}
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
{
$andX = $qb->expr()->andX();
// make sure that all queries without a user see all customers
if (null === $user && empty($teams)) {
return;
return $andX;
}
// make sure that admins see all customers
if (null !== $user && $user->canSeeAllData()) {
return;
return $andX;
}
if (null !== $user) {
@@ -172,22 +183,24 @@ class CustomerRepository extends EntityRepository
}
if (empty($teams)) {
$qb->andWhere('SIZE(c.teams) = 0');
$andX->add('SIZE(c.teams) = 0');
return;
return $andX;
}
$or = $qb->expr()->orX(
'SIZE(c.teams) = 0',
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($or);
$andX->add($or);
$ids = array_values(array_unique(array_map(function (Team $team) {
return $team->getId();
}, $teams)));
$qb->setParameter('teams', $ids);
return $andX;
}
/**
@@ -216,21 +229,33 @@ class CustomerRepository extends EntityRepository
->from(Customer::class, 'c')
->orderBy('c.name', 'ASC');
// TODO this where and the next if($query->hasCustomers()) should go into their own $qb->expr()->orX()
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'));
$mainQuery = $qb->expr()->andX();
$mainQuery->add($qb->expr()->eq('c.visible', ':visible'));
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
$permissions = $this->getPermissionCriteria($qb, $query->getUser(), $query->getTeams());
if ($permissions->count() > 0) {
$mainQuery->add($permissions);
}
$outerQuery = $qb->expr()->orX();
if ($query->hasCustomers()) {
$qb->orWhere($qb->expr()->in('c.id', ':customer'))
->setParameter('customer', $query->getCustomers());
$outerQuery->add($qb->expr()->in('c.id', ':customer'));
$qb->setParameter('customer', $query->getCustomers());
}
if (null !== $query->getCustomerToIgnore()) {
$qb->andWhere($qb->expr()->neq('c.id', ':ignored'));
$mainQuery = $qb->expr()->andX(
$mainQuery,
$qb->expr()->neq('c.id', ':ignored')
);
$qb->setParameter('ignored', $query->getCustomerToIgnore());
}
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
$outerQuery->add($mainQuery);
$qb->andWhere($outerQuery);
return $qb;
}

View File

@@ -26,6 +26,7 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
@@ -182,16 +183,26 @@ class ProjectRepository extends EntityRepository
return $stats;
}
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
if ($permissions->count() > 0) {
$qb->andWhere($permissions);
}
}
public function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
{
$andX = $qb->expr()->andX();
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {
return;
return $andX;
}
// make sure that admins see all projects
if (null !== $user && $user->canSeeAllData()) {
return;
return $andX;
}
if (null !== $user) {
@@ -199,29 +210,31 @@ class ProjectRepository extends EntityRepository
}
if (empty($teams)) {
$qb->andWhere('SIZE(c.teams) = 0');
$qb->andWhere('SIZE(p.teams) = 0');
$andX->add('SIZE(c.teams) = 0');
$andX->add('SIZE(p.teams) = 0');
return;
return $andX;
}
$orProject = $qb->expr()->orX(
'SIZE(p.teams) = 0',
$qb->expr()->isMemberOf(':teams', 'p.teams')
);
$qb->andWhere($orProject);
$andX->add($orProject);
$orCustomer = $qb->expr()->orX(
'SIZE(c.teams) = 0',
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($orCustomer);
$andX->add($orCustomer);
$ids = array_values(array_unique(array_map(function (Team $team) {
return $team->getId();
}, $teams)));
$qb->setParameter('teams', $ids);
return $andX;
}
/**
@@ -259,57 +272,46 @@ class ProjectRepository extends EntityRepository
$qb->addSelect('c');
}
$qb->andWhere($qb->expr()->eq('p.visible', ':visible'));
$qb->andWhere($qb->expr()->eq('c.visible', ':customer_visible'));
if (!$query->isIgnoreDate()) {
$now = new DateTime();
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':start'),
$qb->expr()->isNull('p.start')
),
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':start'),
$qb->expr()->isNull('p.end')
)
)
)->setParameter('start', $now);
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':end'),
$qb->expr()->isNull('p.end')
),
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':end'),
$qb->expr()->isNull('p.start')
)
)
)->setParameter('end', $now);
}
$mainQuery = $qb->expr()->andX();
$mainQuery->add($qb->expr()->eq('p.visible', ':visible'));
$qb->setParameter('visible', true, \PDO::PARAM_BOOL);
$mainQuery->add($qb->expr()->eq('c.visible', ':customer_visible'));
$qb->setParameter('customer_visible', true, \PDO::PARAM_BOOL);
if ($query->hasProjects()) {
$qb->orWhere($qb->expr()->in('p.id', ':project'))
->setParameter('project', $query->getProjects());
if (!$query->isIgnoreDate()) {
$andx = $this->addProjectStartAndEndDate($qb, $query->getProjectStart(), $query->getProjectEnd());
$mainQuery->add($andx);
}
if ($query->hasCustomers()) {
$qb->andWhere($qb->expr()->in('p.customer', ':customer'))
->setParameter('customer', $query->getCustomers());
$mainQuery->add($qb->expr()->in('p.customer', ':customer'));
$qb->setParameter('customer', $query->getCustomers());
}
$permissions = $this->getPermissionCriteria($qb, $query->getUser(), $query->getTeams());
if ($permissions->count() > 0) {
$mainQuery->add($permissions);
}
$outerQuery = $qb->expr()->orX();
if ($query->hasProjects()) {
$outerQuery->add($qb->expr()->in('p.id', ':project'));
$qb->setParameter('project', $query->getProjects());
}
if (null !== $query->getProjectToIgnore()) {
$qb->andWhere($qb->expr()->neq('p.id', ':ignored'));
$mainQuery = $qb->expr()->andX(
$mainQuery,
$qb->expr()->neq('p.id', ':ignored')
);
$qb->setParameter('ignored', $query->getProjectToIgnore());
}
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
$outerQuery->add($mainQuery);
$qb->andWhere($outerQuery);
return $qb;
}
@@ -369,39 +371,8 @@ class ProjectRepository extends EntityRepository
// begin < to and end = null
// begin > from and end < to
// ... and more ...
$begin = $query->getProjectStart();
$end = $query->getProjectEnd();
if (null !== $begin) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':start'),
$qb->expr()->isNull('p.start')
),
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':start'),
$qb->expr()->isNull('p.end')
)
)
)->setParameter('start', $query->getProjectStart());
}
if (null !== $end) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':end'),
$qb->expr()->isNull('p.end')
),
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':end'),
$qb->expr()->isNull('p.start')
)
)
)->setParameter('end', $query->getProjectEnd());
}
$times = $this->addProjectStartAndEndDate($qb, $query->getProjectStart(), $query->getProjectEnd());
$qb->andWhere($times);
$this->addPermissionCriteria($qb, $query->getCurrentUser());
@@ -440,6 +411,45 @@ class ProjectRepository extends EntityRepository
return $qb;
}
private function addProjectStartAndEndDate(QueryBuilder $qb, ?DateTime $begin, ?DateTime $end): Andx
{
$and = $qb->expr()->andX();
if (null !== $begin) {
$and->add(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':start'),
$qb->expr()->isNull('p.start')
),
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':start'),
$qb->expr()->isNull('p.end')
)
)
);
$qb->setParameter('start', $begin);
}
if (null !== $end) {
$and->add(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':end'),
$qb->expr()->isNull('p.end')
),
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':end'),
$qb->expr()->isNull('p.start')
)
)
);
$qb->setParameter('end', $end);
}
return $and;
}
public function countProjectsForQuery(ProjectQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);

View File

@@ -14,6 +14,14 @@ use App\Entity\Project;
final class ProjectFormTypeQuery extends BaseFormTypeQuery
{
/**
* @var \DateTime|null
*/
private $projectStart;
/**
* @var \DateTime|null
*/
private $projectEnd;
/**
* @var Project|null
*/
@@ -22,8 +30,8 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
private $withCustomer = false;
/**
* @param Project|int|null $project
* @param Customer|int|null $customer
* @param Project|int|null|array<int>|array<Project> $project
* @param Customer|int|null|array<int>|array<Customer> $customer
*/
public function __construct($project = null, $customer = null)
{
@@ -40,13 +48,25 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
}
$this->setCustomers($customer);
}
$this->projectStart = $this->projectEnd = new \DateTime();
}
/**
* Whether customers should be joined
*
* @return bool
*/
public function withCustomer(): bool
{
return $this->withCustomer;
}
/**
* Directly join the customer
*
* @param bool $withCustomer
*/
public function setWithCustomer(bool $withCustomer): void
{
$this->withCustomer = $withCustomer;
@@ -60,11 +80,9 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
return $this->projectToIgnore;
}
public function setProjectToIgnore(Project $projectToIgnore): ProjectFormTypeQuery
public function setProjectToIgnore(Project $projectToIgnore): void
{
$this->projectToIgnore = $projectToIgnore;
return $this;
}
public function isIgnoreDate(): bool
@@ -72,10 +90,28 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
return $this->ignoreDate;
}
public function setIgnoreDate(bool $ignoreDate): ProjectFormTypeQuery
public function setIgnoreDate(bool $ignoreDate): void
{
$this->ignoreDate = $ignoreDate;
}
return $this;
public function getProjectStart(): ?\DateTime
{
return $this->projectStart;
}
public function setProjectStart(?\DateTime $projectStart): void
{
$this->projectStart = $projectStart;
}
public function getProjectEnd(): ?\DateTime
{
return $this->projectEnd;
}
public function setProjectEnd(?\DateTime $projectEnd): void
{
$this->projectEnd = $projectEnd;
}
}

View File

@@ -1052,7 +1052,7 @@ class TimesheetRepository extends EntityRepository
* @param User|null $user
* @param DateTime|null $startFrom
* @param int $limit
* @return array|mixed
* @return Timesheet[]
* @throws \Doctrine\ORM\Query\QueryException
*/
public function getRecentActivities(User $user = null, DateTime $startFrom = null, int $limit = 10)

View File

@@ -99,6 +99,7 @@ final class IconExtension extends AbstractExtension
'users' => 'fas fa-user-friends',
'visibility' => 'far fa-eye',
'warning' => 'fas fa-exclamation-triangle',
'weekly-times' => 'fas fa-th',
'xlsx' => 'fas fa-file-excel',
];

View File

@@ -80,6 +80,14 @@ final class LocaleFormatExtensions extends AbstractExtension
return ($day === 0 || $day === 6);
}),
new TwigTest('today', function ($dateTime) {
if (!$dateTime instanceof \DateTime) {
return false;
}
$compare = new \DateTime('now', $dateTime->getTimezone());
return $compare->format('Y-m-d') === $dateTime->format('Y-m-d');
}),
];
}

View File

@@ -25,7 +25,7 @@ class ProjectValidator extends ConstraintValidator
public function validate($value, Constraint $constraint)
{
if (!($constraint instanceof ProjectConstraint)) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\Project');
throw new UnexpectedTypeException($constraint, ProjectConstraint::class);
}
if (!\is_object($value) || !($value instanceof Project)) {

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\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
* @Target({"CLASS"})
*/
class QuickEntryModel extends Constraint
{
public const ACTIVITY_REQUIRED = 'quick-entry-model-01';
public const PROJECT_REQUIRED = 'quick-entry-model-02';
public $messageActivityRequired = 'An activity needs to be selected.';
public $messageProjectRequired = 'A project needs to be selected.';
}

View File

@@ -0,0 +1,56 @@
<?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\Validator\Constraints;
use App\Model\QuickEntryModel;
use App\Validator\Constraints\QuickEntryModel as QuickEntryModelConstraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class QuickEntryModelValidator extends ConstraintValidator
{
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof QuickEntryModelConstraint) {
throw new UnexpectedTypeException($constraint, QuickEntryModelConstraint::class);
}
if (!\is_object($value) || !($value instanceof QuickEntryModel)) {
throw new UnexpectedTypeException($value, QuickEntryModel::class);
}
/** @var QuickEntryModel $model */
$model = $value;
if ($model->isPrototype()) {
return;
}
if ($model->hasExistingTimesheet() || $model->hasNewTimesheet()) {
if ($model->getActivity() === null) {
$this->context->buildViolation($constraint->messageActivityRequired)
->atPath('activity')
->setCode(QuickEntryModelConstraint::ACTIVITY_REQUIRED)
->addViolation();
}
if ($model->getProject() === null) {
$this->context->buildViolation($constraint->messageProjectRequired)
->atPath('project')
->setCode(QuickEntryModelConstraint::PROJECT_REQUIRED)
->addViolation();
}
}
}
}

View File

@@ -0,0 +1,20 @@
<?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\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
* @Target({"CLASS"})
*/
class QuickEntryTimesheet extends Constraint
{
}

View File

@@ -0,0 +1,61 @@
<?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\Validator\Constraints;
use App\Entity\Timesheet as TimesheetEntity;
use App\Validator\Constraints\QuickEntryTimesheet as QuickEntryTimesheetConstraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class QuickEntryTimesheetValidator extends ConstraintValidator
{
/**
* @var Constraint[]
*/
private $constraints;
/**
* @param Constraint[] $constraints
*/
public function __construct(iterable $constraints)
{
$this->constraints = $constraints;
}
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof QuickEntryTimesheetConstraint) {
throw new UnexpectedTypeException($constraint, QuickEntryTimesheetConstraint::class);
}
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
/** @var TimesheetEntity $timesheet */
$timesheet = $value;
if ($timesheet->getId() === null && $timesheet->getDuration(false) === null) {
return;
}
foreach ($this->constraints as $constraint) {
$this->context
->getValidator()
->inContext($this->context)
->atPath('duration')
->validate($timesheet, $constraint, [Constraint::DEFAULT_GROUP]);
}
}
}

View File

@@ -18,22 +18,32 @@ use Symfony\Component\Validator\Constraint;
*/
class Timesheet extends Constraint
{
public const MISSING_BEGIN_ERROR = 'kimai-timesheet-81';
public const END_BEFORE_BEGIN_ERROR = 'kimai-timesheet-82';
public const MISSING_ACTIVITY_ERROR = 'kimai-timesheet-84';
public const MISSING_PROJECT_ERROR = 'kimai-timesheet-85';
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'kimai-timesheet-86';
public const DISABLED_ACTIVITY_ERROR = 'kimai-timesheet-87';
public const DISABLED_PROJECT_ERROR = 'kimai-timesheet-88';
public const DISABLED_CUSTOMER_ERROR = 'kimai-timesheet-89';
public const PROJECT_NOT_STARTED = 'kimai-timesheet-91';
public const PROJECT_ALREADY_ENDED = 'kimai-timesheet-92';
/** @deprecated since 1.15.3 - use TimesheetBasic::MISSING_BEGIN_ERROR instead */
public const MISSING_BEGIN_ERROR = TimesheetBasic::MISSING_BEGIN_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::END_BEFORE_BEGIN_ERROR instead */
public const END_BEFORE_BEGIN_ERROR = TimesheetBasic::END_BEFORE_BEGIN_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::MISSING_ACTIVITY_ERROR instead */
public const MISSING_ACTIVITY_ERROR = TimesheetBasic::MISSING_ACTIVITY_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::MISSING_PROJECT_ERROR instead */
public const MISSING_PROJECT_ERROR = TimesheetBasic::MISSING_PROJECT_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::ACTIVITY_PROJECT_MISMATCH_ERROR instead */
public const ACTIVITY_PROJECT_MISMATCH_ERROR = TimesheetBasic::ACTIVITY_PROJECT_MISMATCH_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::DISABLED_ACTIVITY_ERROR instead */
public const DISABLED_ACTIVITY_ERROR = TimesheetBasic::DISABLED_ACTIVITY_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::DISABLED_PROJECT_ERROR instead */
public const DISABLED_PROJECT_ERROR = TimesheetBasic::DISABLED_PROJECT_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::DISABLED_CUSTOMER_ERROR instead */
public const DISABLED_CUSTOMER_ERROR = TimesheetBasic::DISABLED_CUSTOMER_ERROR;
/** @deprecated since 1.15.3 - use TimesheetBasic::PROJECT_NOT_STARTED instead */
public const PROJECT_NOT_STARTED = TimesheetBasic::PROJECT_NOT_STARTED;
/** @deprecated since 1.15.3 - use TimesheetBasic::PROJECT_ALREADY_ENDED instead */
public const PROJECT_ALREADY_ENDED = TimesheetBasic::PROJECT_ALREADY_ENDED;
protected static $errorNames = [
self::MISSING_BEGIN_ERROR => 'You must submit a begin date.',
self::END_BEFORE_BEGIN_ERROR => 'End date must not be earlier then start date.',
self::MISSING_ACTIVITY_ERROR => 'A timesheet must have an activity.',
self::MISSING_PROJECT_ERROR => 'A timesheet must have a project.',
self::MISSING_ACTIVITY_ERROR => 'An activity needs to be selected.',
self::MISSING_PROJECT_ERROR => 'A project needs to be selected.',
self::ACTIVITY_PROJECT_MISMATCH_ERROR => 'Project mismatch, project specific activity and timesheet project are different.',
self::DISABLED_ACTIVITY_ERROR => 'Cannot start a disabled activity.',
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',

View File

@@ -0,0 +1,50 @@
<?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\Validator\Constraints;
use Doctrine\Common\Annotations\Annotation\Target;
/**
* @Annotation
* @Target({"CLASS", "PROPERTY", "METHOD", "ANNOTATION"})
*/
class TimesheetBasic extends TimesheetConstraint
{
public const MISSING_BEGIN_ERROR = 'kimai-timesheet-81';
public const END_BEFORE_BEGIN_ERROR = 'kimai-timesheet-82';
public const MISSING_ACTIVITY_ERROR = 'kimai-timesheet-84';
public const MISSING_PROJECT_ERROR = 'kimai-timesheet-85';
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'kimai-timesheet-86';
public const DISABLED_ACTIVITY_ERROR = 'kimai-timesheet-87';
public const DISABLED_PROJECT_ERROR = 'kimai-timesheet-88';
public const DISABLED_CUSTOMER_ERROR = 'kimai-timesheet-89';
public const PROJECT_NOT_STARTED = 'kimai-timesheet-91';
public const PROJECT_ALREADY_ENDED = 'kimai-timesheet-92';
protected static $errorNames = [
self::MISSING_BEGIN_ERROR => 'You must submit a begin date.',
self::END_BEFORE_BEGIN_ERROR => 'End date must not be earlier then start date.',
self::MISSING_ACTIVITY_ERROR => 'An activity needs to be selected.',
self::MISSING_PROJECT_ERROR => 'A project needs to be selected.',
self::ACTIVITY_PROJECT_MISMATCH_ERROR => 'Project mismatch, project specific activity and timesheet project are different.',
self::DISABLED_ACTIVITY_ERROR => 'Cannot start a disabled activity.',
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',
self::DISABLED_CUSTOMER_ERROR => 'Cannot start a disabled customer.',
self::PROJECT_NOT_STARTED => 'The project has not started at that time.',
self::PROJECT_ALREADY_ENDED => 'The project is finished at that time.',
];
public $message = 'This timesheet has invalid settings.';
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -0,0 +1,172 @@
<?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\Validator\Constraints;
use App\Entity\Timesheet as TimesheetEntity;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetBasicValidator extends ConstraintValidator
{
/**
* @param TimesheetEntity $timesheet
* @param Constraint $constraint
*/
public function validate($timesheet, Constraint $constraint)
{
if (!($constraint instanceof TimesheetBasic)) {
throw new UnexpectedTypeException($constraint, TimesheetBasic::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
}
$this->validateBeginAndEnd($timesheet, $this->context);
$this->validateActivityAndProject($timesheet, $this->context);
}
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateBeginAndEnd(TimesheetEntity $timesheet, ExecutionContextInterface $context)
{
$begin = $timesheet->getBegin();
$end = $timesheet->getEnd();
if (null === $begin) {
$context->buildViolation('You must submit a begin date.')
->atPath('begin')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::MISSING_BEGIN_ERROR)
->addViolation();
return;
}
if (null !== $end && $begin > $end) {
$context->buildViolation('End date must not be earlier then start date.')
->atPath('end')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::END_BEFORE_BEGIN_ERROR)
->addViolation();
}
}
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateActivityAndProject(TimesheetEntity $timesheet, ExecutionContextInterface $context)
{
if (null === ($activity = $timesheet->getActivity())) {
$context->buildViolation('An activity needs to be selected.')
->atPath('activity')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::MISSING_ACTIVITY_ERROR)
->addViolation();
}
if (null === ($project = $timesheet->getProject())) {
$context->buildViolation('A project needs to be selected.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::MISSING_PROJECT_ERROR)
->addViolation();
}
if (null === $activity || null === $project) {
return;
}
if (null !== $activity->getProject() && $activity->getProject() !== $project) {
$context->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::ACTIVITY_PROJECT_MISMATCH_ERROR)
->addViolation();
}
$timesheetEnd = $timesheet->getEnd();
$newOrStarted = null === $timesheetEnd || $timesheet->getId() === null;
if ($newOrStarted && !$activity->isVisible()) {
$context->buildViolation('Cannot start a disabled activity.')
->atPath('activity')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::DISABLED_ACTIVITY_ERROR)
->addViolation();
}
if ($newOrStarted && !$project->isVisible()) {
$context->buildViolation('Cannot start a disabled project.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::DISABLED_PROJECT_ERROR)
->addViolation();
}
if ($newOrStarted && !$project->getCustomer()->isVisible()) {
$context->buildViolation('Cannot start a disabled customer.')
->atPath('customer')
->setTranslationDomain('validators')
->setCode(TimesheetBasic::DISABLED_CUSTOMER_ERROR)
->addViolation();
}
$pathStart = 'begin';
$pathEnd = 'end';
$projectBegin = $project->getStart();
$projectEnd = $project->getEnd();
if (null === $projectBegin && null === $projectEnd) {
return;
}
$timesheetStart = $timesheet->getBegin();
$timesheetEnd = $timesheet->getEnd();
if (null !== $timesheetStart && $pathStart !== null) {
if (null !== $projectBegin && $timesheetStart->getTimestamp() < $projectBegin->getTimestamp()) {
$context->buildViolation('The project has not started at that time.')
->atPath($pathStart)
->setTranslationDomain('validators')
->setCode(TimesheetBasic::PROJECT_NOT_STARTED)
->addViolation();
} elseif (null !== $projectEnd && $timesheetStart->getTimestamp() > $projectEnd->getTimestamp()) {
$context->buildViolation('The project is finished at that time.')
->atPath($pathStart)
->setTranslationDomain('validators')
->setCode(TimesheetBasic::PROJECT_ALREADY_ENDED)
->addViolation();
}
}
if (null !== $timesheetEnd && $pathEnd !== null) {
if (null !== $projectEnd && $timesheetEnd->getTimestamp() > $projectEnd->getTimestamp()) {
$context->buildViolation('The project is finished at that time.')
->atPath($pathEnd)
->setTranslationDomain('validators')
->setCode(TimesheetBasic::PROJECT_ALREADY_ENDED)
->addViolation();
} elseif (null !== $projectBegin && $timesheetEnd->getTimestamp() < $projectBegin->getTimestamp()) {
$context->buildViolation('The project has not started at that time.')
->atPath($pathEnd)
->setTranslationDomain('validators')
->setCode(TimesheetBasic::PROJECT_NOT_STARTED)
->addViolation();
}
}
}
}

View File

@@ -76,6 +76,11 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
$duration = $timesheet->getEnd()->getTimestamp() - $timesheet->getBegin()->getTimestamp();
}
// this validator needs a project to calculate the rates
if ($timesheet->getProject() === null) {
return;
}
$timeRate = $this->rateService->calculate($timesheet);
$rate = $timeRate->getRate();

View File

@@ -0,0 +1,30 @@
<?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\Validator\Constraints;
final class TimesheetExported extends TimesheetConstraint
{
public const TIMESHEET_EXPORTED = 'kimai-timesheet-exported-01';
protected static $errorNames = [
self::TIMESHEET_EXPORTED => 'This timesheet is already exported.',
];
public $message = 'This timesheet is already exported.';
/**
* @var \DateTime|string|null
*/
public $now;
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -0,0 +1,55 @@
<?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\Validator\Constraints;
use App\Entity\Timesheet as TimesheetEntity;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetExportedValidator extends ConstraintValidator
{
private $security;
public function __construct(Security $security)
{
$this->security = $security;
}
/**
* @param TimesheetEntity $timesheet
* @param Constraint $constraint
*/
public function validate($timesheet, Constraint $constraint)
{
if (!($constraint instanceof TimesheetExported)) {
throw new UnexpectedTypeException($constraint, TimesheetExported::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
}
if (!$timesheet->isExported()) {
return;
}
if (null !== $this->security->getUser() && $this->security->isGranted('edit_exported_timesheet')) {
return;
}
$this->context->buildViolation(TimesheetExported::getErrorName(TimesheetExported::TIMESHEET_EXPORTED))
->atPath('exported')
->setTranslationDomain('validators')
->setCode(TimesheetExported::TIMESHEET_EXPORTED)
->addViolation();
}
}

View File

@@ -12,12 +12,15 @@ namespace App\Validator\Constraints;
final class TimesheetLongRunning extends TimesheetConstraint
{
public const LONG_RUNNING = 'kimai-timesheet-long-running-01';
public const MAXIMUM = 'kimai-timesheet-long-running-02';
protected static $errorNames = [
self::LONG_RUNNING => 'TIMESHEET_LONG_RUNNING',
self::MAXIMUM => 'MAXIMUM',
];
public $message = 'Maximum duration of {{ value }} hours exceeded.';
public $maximumMessage = 'Maximum duration exceeded.';
public function getTargets()
{

View File

@@ -42,6 +42,18 @@ final class TimesheetLongRunningValidator extends ConstraintValidator
return;
}
// one year is currently the maximum that can be logged (which is already not logically)
// the database column could hold more data, but let's limit it here
if ($timesheet->getDuration() > 31536000) {
$this->context->buildViolation($constraint->maximumMessage)
->setTranslationDomain('validators')
->atPath('duration')
->setCode(TimesheetLongRunning::MAXIMUM)
->addViolation();
return;
}
$maxMinutes = $this->systemConfiguration->getTimesheetLongRunningDuration();
if ($maxMinutes <= 0) {

View File

@@ -28,7 +28,7 @@ class TimesheetMultiUpdate extends Constraint
protected static $errorNames = [
self::MISSING_ACTIVITY_ERROR => 'You need to choose an activity, if the project should be changed.',
self::MISSING_PROJECT_ERROR => 'A timesheet must have a project.',
self::MISSING_PROJECT_ERROR => 'A project needs to be selected.',
self::ACTIVITY_PROJECT_MISMATCH_ERROR => 'Project mismatch: chosen project does not match the activity project.',
self::DISABLED_ACTIVITY_ERROR => 'Cannot start a disabled activity.',
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',

View File

@@ -13,7 +13,6 @@ use App\Entity\Timesheet as TimesheetEntity;
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetValidator extends ConstraintValidator
@@ -45,10 +44,6 @@ final class TimesheetValidator extends ConstraintValidator
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
}
$this->validateBeginAndEnd($timesheet, $this->context);
$this->validateActivityAndProject($timesheet, $this->context);
$this->validateActiveLimit($timesheet, $this->context);
foreach ($this->constraints as $constraint) {
$this->context
->getValidator()
@@ -56,142 +51,4 @@ final class TimesheetValidator extends ConstraintValidator
->validate($timesheet, $constraint, [Constraint::DEFAULT_GROUP]);
}
}
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateActiveLimit(TimesheetEntity $timesheet, ExecutionContextInterface $context)
{
// TODO check active entries against hard_limit
}
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateBeginAndEnd(TimesheetEntity $timesheet, ExecutionContextInterface $context)
{
$begin = $timesheet->getBegin();
$end = $timesheet->getEnd();
if (null === $begin) {
$context->buildViolation('You must submit a begin date.')
->atPath('begin')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::MISSING_BEGIN_ERROR)
->addViolation();
return;
}
if (null !== $end && $begin > $end) {
$context->buildViolation('End date must not be earlier then start date.')
->atPath('end')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::END_BEFORE_BEGIN_ERROR)
->addViolation();
}
}
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateActivityAndProject(TimesheetEntity $timesheet, ExecutionContextInterface $context)
{
if (null === ($activity = $timesheet->getActivity())) {
$context->buildViolation('A timesheet must have an activity.')
->atPath('activity')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
->addViolation();
}
if (null === ($project = $timesheet->getProject())) {
$context->buildViolation('A timesheet must have a project.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
->addViolation();
}
if (null === $activity || null === $project) {
return;
}
if (null !== $activity->getProject() && $activity->getProject() !== $project) {
$context->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::ACTIVITY_PROJECT_MISMATCH_ERROR)
->addViolation();
}
$timesheetEnd = $timesheet->getEnd();
if (null === $timesheetEnd && !$activity->isVisible()) {
$context->buildViolation('Cannot start a disabled activity.')
->atPath('activity')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::DISABLED_ACTIVITY_ERROR)
->addViolation();
}
if (null === $timesheetEnd && !$project->isVisible()) {
$context->buildViolation('Cannot start a disabled project.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::DISABLED_PROJECT_ERROR)
->addViolation();
}
if (null === $timesheetEnd && !$project->getCustomer()->isVisible()) {
$context->buildViolation('Cannot start a disabled customer.')
->atPath('customer')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::DISABLED_CUSTOMER_ERROR)
->addViolation();
}
$projectBegin = $project->getStart();
$projectEnd = $project->getEnd();
if (null !== $projectBegin || null !== $projectEnd) {
$timesheetStart = $timesheet->getBegin();
$timesheetEnd = $timesheet->getEnd();
if (null !== $timesheetStart) {
if (null !== $projectBegin && $timesheetStart->getTimestamp() < $projectBegin->getTimestamp()) {
$context->buildViolation('The project has not started at that time.')
->atPath('begin')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::PROJECT_NOT_STARTED)
->addViolation();
} elseif (null !== $projectEnd && $timesheetStart->getTimestamp() > $projectEnd->getTimestamp()) {
$context->buildViolation('The project is finished at that time.')
->atPath('begin')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::PROJECT_ALREADY_ENDED)
->addViolation();
}
}
if (null !== $timesheetEnd) {
if (null !== $projectEnd && $timesheetEnd->getTimestamp() > $projectEnd->getTimestamp()) {
$context->buildViolation('The project is finished at that time.')
->atPath('end')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::PROJECT_ALREADY_ENDED)
->addViolation();
} elseif (null !== $projectBegin && $timesheetEnd->getTimestamp() < $projectBegin->getTimestamp()) {
$context->buildViolation('The project has not started at that time.')
->atPath('end')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::PROJECT_NOT_STARTED)
->addViolation();
}
}
}
}
}

View File

@@ -8,6 +8,23 @@
{{ parent() }}
{% endblock form_label %}
{% block quick_entry_week_row %}
<tr{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group qe-entry-week-row' ~ (not valid ? ' has-error'))|trim})} %}{{ block('attributes') }}{% endwith %}>
<td>
{{ form_row(form.project) }}
</td>
<td>
{{ form_row(form.activity) }}
</td>
{% for timesheet in form.timesheets %}
<td class="text-center{% if timesheet.vars.data.begin is weekend %} weekend{% endif %}{% if timesheet.vars.data.begin is today %} today{% endif %}">
{{ form_row(timesheet) }}
</td>
{% endfor %}
<td class="text-nowrap text-center total qe-totals-row"></td>
</tr>
{% endblock %}
{% block _team_edit_form_members_entry_user_widget %}
{# this will convert the select box into a hidden field, which are exchangable from an HTML perspective #}
{%- set type = 'hidden' -%}
@@ -47,7 +64,7 @@
{% endblock daterange_widget %}
{% block duration_widget %}
{% if form.vars.duration_presets is defined and form.vars.duration_presets is not empty %}
{% if (form.vars.duration_presets is defined and form.vars.duration_presets is not empty) and (form.vars.disabled is same as (false)) %}
<div class="input-group">
{{ block('form_widget_simple') }}
<div class="input-group-btn">
@@ -55,7 +72,7 @@
<ul class="dropdown-menu dropdown-menu-right pre-scrollable">
{% for value in form.vars.duration_presets %}
<li class="text-center">
<a href="#" onclick="$('#{{ form.vars.id }}').val('{{ value }}');$('#{{ form.vars.id }}').trigger('change');return false;">{{ value }}</a>
<a href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ value }}').trigger('change');return false;">{{ value }}</a>
</li>
{% endfor %}
</ul>
@@ -69,7 +86,7 @@
{% block datetime_widget -%}
<div class="input-group">
<div class="input-group-addon">
<a href="#" onclick="if (!$('#{{ id }}').is(':disabled')) { $('#{{ id }}').val(moment().format('{{ attr['data-format'] }}')).change(); }return false;"><i class="{{ 'calendar'|icon }}"></i></a>
<a href="#" onclick="if (!jQuery('#{{ id }}').is(':disabled')) { jQuery('#{{ id }}').val(moment().format('{{ attr['data-format'] }}')).change(); }return false;"><i class="{{ 'calendar'|icon }}"></i></a>
</div>
{{ block('form_widget_simple') }}
</div>
@@ -78,7 +95,7 @@
{% block date_widget -%}
<div class="input-group">
<div class="input-group-addon">
<a href="#" onclick="if (!$('#{{ id }}').is(':disabled')) { $('#{{ id }}').val(moment().format('{{ attr['data-format'] }}')).change(); }return false;"><i class="{{ 'calendar'|icon }}"></i></a>
<a href="#" onclick="if (!jQuery('#{{ id }}').is(':disabled')) { jQuery('#{{ id }}').val(moment().format('{{ attr['data-format'] }}')).change(); }return false;"><i class="{{ 'calendar'|icon }}"></i></a>
</div>
{{ block('form_widget_simple') }}
</div>
@@ -99,7 +116,7 @@
{% block yearpicker_widget -%}
<div class="btn-group">
<a class="btn btn-default btn-left" href="#" onclick="$('#{{ form.vars.id }}').val('{{ previousYear|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ previousYear|date_short }}">
<a class="btn btn-default btn-left" href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ previousYear|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ previousYear|date_short }}">
<i class="{{ 'left'|icon }}"></i>
</a>
<a class="btn btn-default" href="#" onclick="return false;">
@@ -111,7 +128,7 @@
{% endif %}
</span>
</a>
<a class="btn btn-default btn-right" href="#" onclick="$('#{{ form.vars.id }}').val('{{ nextYear|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ nextYear|date_short }}">
<a class="btn btn-default btn-right" href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ nextYear|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ nextYear|date_short }}">
<i class="{{ 'right'|icon }}"></i>
</a>
</div>
@@ -120,13 +137,13 @@
{% block monthpicker_widget -%}
<div class="btn-group">
<a class="btn btn-default btn-left" href="#" onclick="$('#{{ form.vars.id }}').val('{{ previousMonth|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ previousMonth|month_name(true) }}">
<a class="btn btn-default btn-left" href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ previousMonth|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ previousMonth|month_name(true) }}">
<i class="{{ 'left'|icon }}"></i>
</a>
<a class="btn btn-default" href="#" onclick="return false;">
<span id="{{ form.vars.id }}_month_name">{{ month|month_name(true) }}</span>
</a>
<a class="btn btn-default btn-right" href="#" onclick="$('#{{ form.vars.id }}').val('{{ nextMonth|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ nextMonth|month_name(true) }}">
<a class="btn btn-default btn-right" href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ nextMonth|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ nextMonth|month_name(true) }}">
<i class="{{ 'right'|icon }}"></i>
</a>
</div>
@@ -134,14 +151,14 @@
{%- endblock monthpicker_widget %}
{% block weekpicker_widget -%}
<div class="btn-group">
<a class="btn btn-default btn-left" href="#" onclick="$('#{{ form.vars.id }}').val('{{ previousWeek|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ 'stats.workingTimeWeek'|trans({'%week%': previousWeek|date_format('W')}) }}">
<div class="btn-group week-picker-btn-group">
<a class="btn btn-default btn-left" href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ previousWeek|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ 'stats.workingTimeWeek'|trans({'%week%': previousWeek|date_format('W')}) }}">
<i class="{{ 'left'|icon }}"></i>
</a>
<a class="btn btn-default" href="#" onclick="return false;">
<span id="{{ form.vars.id }}_week_number">{{ 'stats.workingTimeWeek'|trans({'%week%': week|date_format('W')}) }}</span>
</a>
<a class="btn btn-default btn-right" href="#" onclick="$('#{{ form.vars.id }}').val('{{ nextWeek|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ 'stats.workingTimeWeek'|trans({'%week%': nextWeek|date_format('W')}) }}">
<a class="btn btn-default btn-right" href="#" onclick="jQuery('#{{ form.vars.id }}').val('{{ nextWeek|report_date }}').change()" data-toggle="tooltip" data-placement="top" title="{{ 'stats.workingTimeWeek'|trans({'%week%': nextWeek|date_format('W')}) }}">
<i class="{{ 'right'|icon }}"></i>
</a>
</div>

View File

@@ -0,0 +1,5 @@
{% macro quickEntries(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set event = actions(app.user, 'weekly_times', view, {}) %}
{{ widgets.page_actions(event.actions) }}
{% endmacro %}

View File

@@ -0,0 +1,127 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "quick-entry/actions.html.twig" as actions %}
{% block page_title %}{{ 'quick_entry.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.quickEntries('index') }}{% endblock %}
{% block main %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_attributes %}id="quick_entry_box"{% endblock %}
{% block box_before %}{{ form_start(form, {attr: {class: 'form-dataTable'}}) }}{% endblock %}
{% block box_after %}{{ form_end(form) }}{% endblock %}
{% block box_title %}
{{ form_widget(form.date) }}
{% endblock %}
{% block box_body_class %}no-padding{% endblock %}
{% block box_footer %}
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
<button type="button" class="btn btn-success add-item-link" data-collection-prototype="{{ form.rows.vars.id }}" data-collection-holder="ts-collection">
<i class="{{ 'create'|icon }}"></i>
{{ 'action.add'|trans }}
</button>
{% endblock %}
{% block box_body %}
<table class="table dataTable">
<thead>
<tr>
<th>{{ 'label.project'|trans }}</th>
<th>{{ 'label.activity'|trans }}</th>
{% for id, week in days %}
<th class="text-center{% if week.day is weekend %} weekend{% endif %}{% if week.day is today %} today{% endif %}">
{{ week.day|day_name(true) }}<br>
{{ week.day|format_date('short') }}
</th>
{% endfor %}
<th class="summary">{{ 'label.duration'|trans }}</th>
</tr>
</thead>
<tbody id="ts-collection" data-index="{{ form.rows.children|length }}">
{{ form(form) }}
</tbody>
<tfoot>
<tr class="summary">
<td>{{ 'stats.durationTotal'|trans }}</td>
<td></td>
{% for id, week in days %}
<td class="text-center" id="qe-totals-day-{{ loop.index0 }}"></td>
{% endfor %}
<td class="text-center" id="qe-totals-week"></td>
</tr>
</tfoot>
</table>
{% endblock %}
{% endembed %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function(event) {
const kimai = event.detail.kimai;
const DATES = kimai.getPlugin('date');
const FORMS = kimai.getPlugin('form-select');
const recalculateTotals = function(e) {
const allFields = document.getElementsByClassName('duration-input');
let totalsPerDay = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0};
let fullTotals = 0;
for (let durationInput of allFields) {
let id = durationInput.id.replace(/_duration/, '').substr(-1);
totalsPerDay[id] += DATES.getSecondsFromDurationString(durationInput.value);
}
for (const [id, total] of Object.entries(totalsPerDay)) {
document.getElementById('qe-totals-day-' + id).innerText = DATES.formatSeconds(total);
fullTotals += total;
}
document.getElementById('qe-totals-week').innerText = DATES.formatSeconds(fullTotals);
const allRows = document.getElementsByClassName('qe-entry-week-row');
for (let qeWeekRow of allRows) {
let qeWeekRowFields = qeWeekRow.getElementsByClassName('duration-input');
let totalsRow = 0;
for (let durationInput of qeWeekRowFields) {
totalsRow += DATES.getSecondsFromDurationString(durationInput.value);
}
qeWeekRow.getElementsByClassName('qe-totals-row')[0].innerText = DATES.formatSeconds(totalsRow);
}
};
recalculateTotals();
{% if form.user is defined %}
jQuery('#{{ form.user.vars.id }}').on('change', function(ev) {
jQuery(this).closest('form').submit();
});
{% endif %}
jQuery('#{{ form.date.vars.id }}').on('change', function(ev) {
location.href = '{{ path('quick_entry', {'begin': '__BEGIN__'}) }}'.replace('__BEGIN__', jQuery(this).val());
});
jQuery('body').on('change', '.duration-input', recalculateTotals);
const addFormToCollection = function(e) {
const collectionHolder = document.getElementById(e.currentTarget.dataset.collectionHolder);
const collectionPrototype = document.getElementById(e.currentTarget.dataset.collectionPrototype);
const node = document.createElement('tr');
node.innerHTML = collectionPrototype
.dataset
.prototype
.replace(
/__name__/g,
collectionHolder.dataset.index
);
collectionHolder.appendChild(node);
jQuery(node).find('.selectpicker').each(function(i, el) {
FORMS.activateSelectPickerByElement(el, 'body');
});
collectionHolder.dataset.index++;
};
document.querySelectorAll('.add-item-link').forEach(btn => btn.addEventListener("click", addFormToCollection));
});
</script>
{% endblock %}

View File

@@ -30,7 +30,7 @@
<th>&nbsp;</th>
<th>&nbsp;</th>
{% for day in days.dateTimes %}
<th class="text-center text-nowrap{% if day is weekend %} weekend{% endif %}">
<th class="text-center text-nowrap{% if day is weekend %} weekend{% endif %}{% if day is today %} today{% endif %}">
{{ day|day_name(true) }}<br>
{{ day|format_date('short') }}
</th>
@@ -55,7 +55,7 @@
</td>
<th class="text-nowrap text-center total">{{ project.duration|duration }}</th>
{% for day in project.days.days %}
<td class="text-nowrap text-center day-total{% if day.date is weekend %} weekend{% endif %}">
<td class="text-nowrap text-center day-total{% if day.date is weekend %} weekend{% endif %}{% if day.date is today %} today{% endif %}">
{% if day.duration > 0 %}
{% set totals = totals|merge({(day.date|report_date): (totals[day.date|report_date] + day.duration)}) %}
<strong>{{ day.duration|duration }}</strong>
@@ -70,7 +70,7 @@
</td>
<th class="text-nowrap text-center total">{{ activity.duration|duration }}</th>
{% for day in activity.days.days %}
<td class="text-nowrap text-center day-total{% if day.date is weekend %} weekend{% endif %}">
<td class="text-nowrap text-center day-total{% if day.date is weekend %} weekend{% endif %}{% if day.date is today %} today{% endif %}">
{% if day.duration > 0 %}
{{ day.duration|duration }}
{% endif %}

View File

@@ -28,7 +28,7 @@
<th>&nbsp;</th>
<th>&nbsp;</th>
{% for day in stats.0.getDateTimes() %}
<th class="text-center text-nowrap{% if day is weekend %} weekend{% endif %}">
<th class="text-center text-nowrap{% if day is weekend %} weekend{% endif %}{% if day is today %} today{% endif %}">
{{ day|day_name(true) }}<br>
{{ day|format_date('short') }}
</th>
@@ -54,7 +54,7 @@
<a href="{{ path(subReportRoute, {'date': subReportDate|report_date, 'user': userDay.user.id}) }}">{{ usersTotalDuration|duration }}</a>
</th>
{% for day in userDay.days %}
<td class="text-nowrap text-center day-total{% if day.date is weekend %} weekend{% endif %}">
<td class="text-nowrap text-center day-total{% if day.date is weekend %} weekend{% endif %}{% if day.date is today %} today{% endif %}">
{% if day.totalDuration > 0 %}
{{ day.totalDuration|duration }}
{% endif %}

View File

@@ -527,7 +527,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
'begin' => ($dateTime->createDateTime('- 7 hours'))->format('Y-m-d\TH:m:0'),
'end' => ($dateTime->createDateTime())->format('Y-m-d\TH:m:0'),
'description' => 'foo',
'exported' => true,
'billable' => false,
];
$this->request($client, '/api/timesheets/' . $timesheets[0]->getId(), 'PATCH', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful());
@@ -537,7 +537,8 @@ class TimesheetControllerTest extends APIControllerBaseTest
self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$this->assertNotEmpty($result['id']);
$this->assertEquals(25200, $result['duration']);
$this->assertEquals(1, $result['exported']);
$this->assertEquals('foo', $result['description']);
$this->assertFalse($result['billable']);
}
public function testPatchActionWithInvalidUser()

View File

@@ -0,0 +1,102 @@
<?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\Controller;
use App\Tests\DataFixtures\TimesheetFixtures;
/**
* @group integration
*/
class QuickEntryControllerTest extends ControllerBaseTest
{
public function testIsSecure()
{
$this->assertUrlIsSecured('/quick_entry');
}
public function testIndexAction()
{
$client = $this->getClientForAuthenticatedUser();
$this->request($client, '/quick_entry');
$this->assertTrue($client->getResponse()->isSuccessful());
$node = $client->getCrawler()->filter('section.content form[name=quick_entry_form]');
self::assertEquals(1, $node->filter('div.btn-group.week-picker-btn-group')->count());
self::assertEquals(1, $node->filter('input.btn-primary[type=submit]')->count());
$addBtn = $node->filter('button.btn-success[type=button]');
self::assertEquals(1, $addBtn->count());
self::assertNotNull($addBtn->attr('data-collection-prototype'));
self::assertNotNull($addBtn->attr('data-collection-holder'));
$rows = $client->getCrawler()->filter('section.content form[name=quick_entry_form] table.dataTable tbody tr:not(.summary)');
self::assertEquals(3, $rows->count());
$validate = $rows->getIterator()[0];
$columns = [];
foreach ($validate->childNodes as $childNode) {
if ($childNode instanceof \DOMText) {
continue;
}
if ($childNode instanceof \DOMElement && $childNode->tagName === 'td') {
$columns[] = $childNode;
}
}
// project + activity + 7 days (duration) + row totals
self::assertCount(10, $columns);
$this->assertPageActions($client, [
'back' => $this->createUrl('/timesheet/'),
'help' => 'https://www.kimai.org/documentation/weekly-times.html'
]);
}
public function testIndexActionWith()
{
$client = $this->getClientForAuthenticatedUser();
$fixture = new TimesheetFixtures();
$fixture->setAmount(50);
$fixture->setUser($this->getUserByRole());
$fixture->setStartDate(new \DateTime('-7 days'));
$this->importFixture($fixture);
$this->request($client, '/quick_entry');
$this->assertTrue($client->getResponse()->isSuccessful());
$node = $client->getCrawler()->filter('section.content form[name=quick_entry_form]');
self::assertEquals(1, $node->filter('div.btn-group.week-picker-btn-group')->count());
self::assertEquals(1, $node->filter('input.btn-primary[type=submit]')->count());
$addBtn = $node->filter('button.btn-success[type=button]');
self::assertEquals(1, $addBtn->count());
self::assertNotNull($addBtn->attr('data-collection-prototype'));
self::assertNotNull($addBtn->attr('data-collection-holder'));
$rows = $client->getCrawler()->filter('section.content form[name=quick_entry_form] table.dataTable tbody tr:not(.summary)');
self::assertGreaterThanOrEqual(3, $rows->count());
$validate = $rows->getIterator()[0];
$columns = [];
foreach ($validate->childNodes as $childNode) {
if ($childNode instanceof \DOMText) {
continue;
}
if ($childNode instanceof \DOMElement && $childNode->tagName === 'td') {
$columns[] = $childNode;
}
}
// project + activity + 7 days (duration) + row totals
self::assertCount(10, $columns);
$this->assertPageActions($client, [
'back' => $this->createUrl('/timesheet/'),
'help' => 'https://www.kimai.org/documentation/weekly-times.html'
]);
}
}

View File

@@ -44,6 +44,7 @@ class TimesheetControllerTest extends ControllerBaseTest
'visibility' => '#',
'download toolbar-action modal-ajax-form' => $this->createUrl('/timesheet/export/'),
'create modal-ajax-form' => $this->createUrl('/timesheet/create'),
'quick_entry create-ts' => $this->createUrl('/quick_entry'),
'help' => 'https://www.kimai.org/documentation/timesheet.html'
]);
}

View File

@@ -104,12 +104,8 @@ class TimesheetValidationTest extends KernelTestCase
$this->assertHasViolationForField($entity, 'customer');
}
public function testValidationCustomerInvisibleDoesNotTriggerOnStoppedEntites()
private function createStoppedTimesheet(Project $project, Activity $activity, ?int $id = null): Timesheet
{
$customer = (new Customer())->setVisible(false);
$project = (new Project())->setName('foo')->setCustomer($customer);
$activity = (new Activity())->setName('hello-world')->setProject($project);
$entity = new Timesheet();
$entity
->setUser(new User())
@@ -119,9 +115,39 @@ class TimesheetValidationTest extends KernelTestCase
->setEnd(new \DateTime())
;
if ($id !== null) {
$o = new \ReflectionClass($entity);
$p = $o->getProperty('id');
$p->setAccessible(true);
$p->setValue($entity, $id);
$p->setAccessible(false);
}
return $entity;
}
public function testValidationCustomerInvisibleDoesNotTriggerOnStoppedEntities()
{
$customer = (new Customer())->setVisible(false);
$project = (new Project())->setName('foo')->setCustomer($customer);
$activity = (new Activity())->setName('hello-world')->setProject($project);
$entity = $this->createStoppedTimesheet($project, $activity, 99);
$this->assertHasNoViolations($entity);
}
public function testValidationCustomerInvisibleDoesTriggerOnNewEntities()
{
$customer = (new Customer())->setVisible(false);
$project = (new Project())->setName('foo')->setCustomer($customer);
$activity = (new Activity())->setName('hello-world')->setProject($project);
$entity = $this->createStoppedTimesheet($project, $activity);
$this->assertHasViolationForField($entity, 'customer');
}
public function testValidationProjectInvisible()
{
$customer = new Customer();
@@ -139,24 +165,28 @@ class TimesheetValidationTest extends KernelTestCase
$this->assertHasViolationForField($entity, 'project');
}
public function testValidationProjectInvisibleDoesNotTriggerOnStoppedEntites()
public function testValidationProjectInvisibleDoesNotTriggerOnStoppedEntities()
{
$customer = new Customer();
$project = (new Project())->setName('foo')->setCustomer($customer)->setVisible(false);
$activity = (new Activity())->setName('hello-world')->setProject($project);
$entity = new Timesheet();
$entity
->setUser(new User())
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$entity = $this->createStoppedTimesheet($project, $activity, 1);
$this->assertHasNoViolations($entity);
}
public function testValidationProjectInvisibleDoesTriggerOnNewEntities()
{
$customer = new Customer();
$project = (new Project())->setName('foo')->setCustomer($customer)->setVisible(false);
$activity = (new Activity())->setName('hello-world')->setProject($project);
$entity = $this->createStoppedTimesheet($project, $activity);
$this->assertHasViolationForField($entity, 'project');
}
public function testValidationActivityInvisible()
{
$customer = new Customer();
@@ -174,24 +204,28 @@ class TimesheetValidationTest extends KernelTestCase
$this->assertHasViolationForField($entity, 'activity');
}
public function testValidationActivityInvisibleDoesNotTriggerOnStoppedEntites()
public function testValidationActivityInvisibleDoesNotTriggerOnStoppedEntities()
{
$customer = new Customer();
$project = (new Project())->setName('foo')->setCustomer($customer);
$activity = (new Activity())->setName('hello-world')->setProject($project)->setVisible(false);
$entity = new Timesheet();
$entity
->setUser(new User())
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$entity = $this->createStoppedTimesheet($project, $activity, 2);
$this->assertHasNoViolations($entity);
}
public function testValidationActivityInvisibleDoesTriggerOnNewEntities()
{
$customer = new Customer();
$project = (new Project())->setName('foo')->setCustomer($customer);
$activity = (new Activity())->setName('hello-world')->setProject($project)->setVisible(false);
$entity = $this->createStoppedTimesheet($project, $activity);
$this->assertHasViolationForField($entity, 'activity');
}
public function testValidationEndNotEarlierThanBegin()
{
$entity = $this->getEntity();

View File

@@ -0,0 +1,23 @@
<?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\EventSubscriber\Actions;
use App\EventSubscriber\Actions\QuickEntrySubscriber;
/**
* @covers \App\EventSubscriber\Actions\QuickEntrySubscriber
*/
class QuickEntrySubscriberTest extends AbstractActionsSubscriberTest
{
public function testEventName()
{
$this->assertGetSubscribedEvent(QuickEntrySubscriber::class, 'weekly_times');
}
}

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\EventSubscriber;
use App\Event\ConfigureMainMenuEvent;
use App\EventSubscriber\MenuSubscriber;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\EventSubscriber\MenuSubscriber
*/
class MenuSubscriberTest extends TestCase
{
public function testGetSubscribedEvents()
{
$events = MenuSubscriber::getSubscribedEvents();
$this->assertArrayHasKey(ConfigureMainMenuEvent::class, $events);
$methodName = $events[ConfigureMainMenuEvent::class][0];
$this->assertTrue(method_exists(MenuSubscriber::class, $methodName));
}
}

View File

@@ -71,6 +71,7 @@ class DurationStringToSecondsTransformerTest extends TestCase
['00:00', 0],
['0', null],
[null, null],
['87600000000:00:00', 315360000000000],
];
}
@@ -80,6 +81,8 @@ class DurationStringToSecondsTransformerTest extends TestCase
['xxx'],
[':::'],
['0::0'],
['87600000000:00:01'],
[315360000000001],
];
}

View File

@@ -98,4 +98,14 @@ class DurationTypeTest extends TypeTestCase
self::assertArrayNotHasKey('duration_presets', $view->vars);
}
public function testHasDurationInputClass()
{
$view = $this->factory->create(DurationType::class, 3600, [
'attr' => ['class' => 'testing']
])->createView();
self::assertArrayHasKey('class', $view->vars['attr']);
self::assertStringContainsString('duration-input testing', $view->vars['attr']['class']);
}
}

View File

@@ -0,0 +1,133 @@
<?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\Form\Type;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Form\Type\QuickEntryTimesheetType;
use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Security\Core\Security;
/**
* @covers \App\Form\Type\QuickEntryTimesheetType
*/
class QuickEntryTimesheetTypeTest extends TypeTestCase
{
protected function getExtensions()
{
$auth = $this->createMock(Security::class);
$auth->method('getUser')->willReturn(new User());
$auth->method('isGranted')->willReturn(true);
$type = new QuickEntryTimesheetType($auth);
return [
new PreloadedExtension([$type], []),
];
}
public function getTestData()
{
yield [4.5, 16200];
yield ['4,5', 16200];
yield ['4:30', 16200];
yield ['4h30m', 16200];
}
/**
* @dataProvider getTestData
*/
public function testSubmitValidData($value, $expectedDuration)
{
$data = ['duration' => $value];
$model = $this->createDefaultModel();
$form = $this->factory->create(QuickEntryTimesheetType::class, $model);
$form->submit($data);
$this->assertTrue($form->isSynchronized());
$this->assertEquals($expectedDuration, $model->getDuration());
$this->assertEquals($expectedDuration, $model->getDuration(true));
}
private function createDefaultModel(): Timesheet
{
$begin = new \DateTime('2020-02-15 12:30:00');
$end = new \DateTime('2020-02-15 14:00:00');
$model = new Timesheet();
$model->setBegin($begin);
$model->setEnd($end);
return $model;
}
public function testPresetPopulatesView()
{
$view = $this->factory->create(QuickEntryTimesheetType::class, $this->createDefaultModel(), [
'duration_minutes' => 15,
'duration_hours' => 5,
])->createView();
$vars = $view->children['duration']->vars;
self::assertArrayHasKey('duration_presets', $vars);
self::assertCount(20, $vars['duration_presets']);
self::assertEquals('0:30', $vars['duration_presets'][1]);
self::assertEquals('4:45', $vars['duration_presets'][18]);
}
public function testPresetsAreNotGeneratedOnMissingHours()
{
$view = $this->factory->create(QuickEntryTimesheetType::class, $this->createDefaultModel())->createView();
$vars = $view->children['duration']->vars;
self::assertArrayNotHasKey('duration_presets', $vars);
}
public function testPresetsAreNotGeneratedOnMissingMinutes()
{
$view = $this->factory->create(QuickEntryTimesheetType::class, $this->createDefaultModel(), [
'duration_hours' => 5,
])->createView();
$vars = $view->children['duration']->vars;
self::assertArrayNotHasKey('duration_presets', $vars);
}
public function testPresetsAreNotGeneratedOnNegativeMinutes()
{
$view = $this->factory->create(QuickEntryTimesheetType::class, $this->createDefaultModel(), [
'duration_minutes' => -1,
'duration_hours' => 5,
])->createView();
$vars = $view->children['duration']->vars;
self::assertArrayNotHasKey('duration_presets', $vars);
}
public function testPresetsAreNotGeneratedOnNegativeHours()
{
$view = $this->factory->create(QuickEntryTimesheetType::class, $this->createDefaultModel(), [
'duration_minutes' => 5,
'duration_hours' => -1,
])->createView();
$vars = $view->children['duration']->vars;
self::assertArrayNotHasKey('duration_presets', $vars);
}
}

View File

@@ -0,0 +1,129 @@
<?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\Model;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\QuickEntryModel;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\QuickEntryModel
*/
class QuickEntryModelTest extends TestCase
{
public function testEmptyModel()
{
$sut = new QuickEntryModel();
self::assertTrue($sut->isPrototype());
self::assertNull($sut->getProject());
self::assertNull($sut->getActivity());
self::assertNull($sut->getUser());
self::assertEquals([], $sut->getNewTimesheet());
self::assertEquals([], $sut->getTimesheets());
self::assertNull($sut->getLatestEntry());
self::assertNull($sut->getFirstEntry());
self::assertFalse($sut->hasNewTimesheet());
self::assertFalse($sut->hasExistingTimesheet());
self::assertFalse($sut->hasTimesheetWithDuration());
}
public function testFullModel()
{
$user = new User();
$project = new Project();
$activity = new Activity();
$sut = new QuickEntryModel($user, $project, $activity);
self::assertFalse($sut->hasNewTimesheet());
$t = new Timesheet();
$t->setDuration(null);
$sut->addTimesheet($t);
self::assertFalse($sut->hasNewTimesheet());
$t = new Timesheet();
$t->setDuration(1);
$sut->addTimesheet($t);
self::assertTrue($sut->hasNewTimesheet());
self::assertCount(1, $sut->getNewTimesheet());
self::assertFalse($sut->isPrototype());
self::assertSame($project, $sut->getProject());
self::assertSame($activity, $sut->getActivity());
self::assertSame($user, $sut->getUser());
$t1 = new Timesheet();
$t1->setBegin(new \DateTime('2020-05-30'));
$sut->addTimesheet($t1);
$t2 = new Timesheet();
$t2->setBegin(new \DateTime('2020-01-19'));
$sut->addTimesheet($t2);
$t3 = new Timesheet();
$t3->setBegin(new \DateTime('2020-06-01'));
$sut->addTimesheet($t3);
$t4 = new Timesheet();
$t4->setBegin(new \DateTime('2020-01-09'));
$sut->addTimesheet($t4);
self::assertSame($t3, $sut->getLatestEntry());
self::assertEquals('2020-06-01', $sut->getLatestEntry()->getBegin()->format('Y-m-d'));
self::assertSame($t4, $sut->getFirstEntry());
self::assertEquals('2020-01-09', $sut->getFirstEntry()->getBegin()->format('Y-m-d'));
self::assertCount(5, $sut->getNewTimesheet());
self::assertCount(6, $sut->getTimesheets());
self::assertFalse($sut->hasExistingTimesheet());
self::assertTrue($sut->hasTimesheetWithDuration());
$sut->setProject(null);
self::assertNull($sut->getProject());
$project2 = new Project();
$sut->setProject($project2);
self::assertSame($project2, $sut->getProject());
$sut->setActivity(null);
self::assertNull($sut->getActivity());
$activity2 = new Activity();
$sut->setActivity($activity2);
self::assertSame($activity2, $sut->getActivity());
$sut->setTimesheets([$t1, $t2, $t3, $t4]);
self::assertCount(4, $sut->getNewTimesheet());
self::assertCount(4, $sut->getTimesheets());
}
public function testHasExistingTimesheet()
{
$sut = new QuickEntryModel();
self::assertTrue($sut->isPrototype());
self::assertFalse($sut->hasExistingTimesheet());
$mock = $this->createMock(Timesheet::class);
$mock->method('getId')->willReturn(1);
$sut->addTimesheet($mock);
self::assertTrue($sut->hasExistingTimesheet());
self::assertFalse($sut->isPrototype());
}
public function testDefaultModel()
{
$user = new User();
$project = new Project();
$activity = new Activity();
$sut = new QuickEntryModel($user, $project, $activity);
self::assertFalse($sut->isPrototype());
self::assertSame($project, $sut->getProject());
self::assertSame($activity, $sut->getActivity());
self::assertSame($user, $sut->getUser());
}
}

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\Model;
use App\Model\QuickEntryModel;
use App\Model\QuickEntryWeek;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\QuickEntryWeek
*/
class QuickEntryWeekTest extends TestCase
{
public function testModel()
{
$date = new \DateTime();
$rows = [];
$sut = new QuickEntryWeek($date, $rows);
self::assertSame($date, $sut->getDate());
self::assertEquals([], $sut->getRows());
$rows = [
new QuickEntryModel()
];
$sut->setRows($rows);
self::assertSame($rows, $sut->getRows());
}
}

View File

@@ -31,7 +31,18 @@ class ProjectFormTypeQueryTest extends BaseFormTypeQueryTest
$sut->setWithCustomer(true);
self::assertTrue($sut->withCustomer());
self::assertNull($sut->getProjectToIgnore());
self::assertInstanceOf(ProjectFormTypeQuery::class, $sut->setProjectToIgnore($project));
$sut->setProjectToIgnore($project);
self::assertSame($project, $sut->getProjectToIgnore());
self::assertNotNull($sut->getProjectStart());
self::assertNotNull($sut->getProjectEnd());
$date = new \DateTime('2019-04-20');
$sut->setProjectStart($date);
self::assertSame($date, $sut->getProjectStart());
$date = new \DateTime('2020-01-01');
$sut->setProjectEnd($date);
self::assertSame($date, $sut->getProjectEnd());
}
}

View File

@@ -17,6 +17,7 @@ use PHPUnit\Framework\TestCase;
use Symfony\Component\Intl\Util\IntlTestHelper;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
/**
* @covers \App\Twig\LocaleFormatExtensions
@@ -82,6 +83,22 @@ class LocaleFormatExtensionsTest extends TestCase
}
}
public function testGetTests()
{
$tests = ['weekend', 'today'];
$i = 0;
$sut = $this->getSut('de', []);
$twigTests = $sut->getTests();
$this->assertCount(\count($tests), $twigTests);
/** @var TwigTest $test */
foreach ($twigTests as $test) {
$this->assertInstanceOf(TwigTest::class, $test);
$this->assertEquals($tests[$i++], $test->getName());
}
}
/**
* @param string $locale
* @param \DateTime|string $date
@@ -494,6 +511,39 @@ class LocaleFormatExtensionsTest extends TestCase
$this->assertEquals('0.00', $sut->durationDecimal(null));
}
private function getTest(string $name): TwigTest
{
$sut = $this->getSut('en', $this->localeEn);
foreach ($sut->getTests() as $test) {
if ($test->getName() === $name) {
return $test;
}
}
throw new \Exception('Unknown twig test: ' . $name);
}
public function testIsToday()
{
$test = $this->getTest('today');
self::assertTrue(\call_user_func($test->getCallable(), new \DateTime()));
self::assertFalse(\call_user_func($test->getCallable(), new \DateTime('-1 day')));
self::assertFalse(\call_user_func($test->getCallable(), new \DateTime('+1 day')));
self::assertFalse(\call_user_func($test->getCallable(), new \stdClass()));
self::assertFalse(\call_user_func($test->getCallable(), null));
}
public function testIsWeekend()
{
$test = $this->getTest('weekend');
self::assertTrue(\call_user_func($test->getCallable(), new \DateTime('first saturday this month')));
self::assertTrue(\call_user_func($test->getCallable(), new \DateTime('first sunday this month')));
self::assertFalse(\call_user_func($test->getCallable(), new \DateTime('first monday this month')));
self::assertFalse(\call_user_func($test->getCallable(), new \DateTime('first friday this month')));
self::assertFalse(\call_user_func($test->getCallable(), new \stdClass()));
self::assertFalse(\call_user_func($test->getCallable(), null));
}
protected function getTimesheet($seconds)
{
$begin = new \DateTime();

View File

@@ -16,6 +16,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\ColorChoices
* @covers \App\Validator\Constraints\ColorChoicesValidator
*/
class ColorChoicesValidatorTest extends ConstraintValidatorTestCase

View File

@@ -16,6 +16,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\DateTimeFormat
* @covers \App\Validator\Constraints\DateTimeFormatValidator
*/
class DateTimeFormatValidatorTest extends ConstraintValidatorTestCase

View File

@@ -17,6 +17,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\Duration
* @covers \App\Validator\Constraints\DurationValidator
*/
class DurationValidatorTest extends ConstraintValidatorTestCase

View File

@@ -16,6 +16,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\HexColor
* @covers \App\Validator\Constraints\HexColorValidator
*/
class HexColorValidatorTest extends ConstraintValidatorTestCase

View File

@@ -17,6 +17,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\Project
* @covers \App\Validator\Constraints\ProjectValidator
*/
class ProjectValidatorTest extends ConstraintValidatorTestCase
@@ -48,4 +49,10 @@ class ProjectValidatorTest extends ConstraintValidatorTestCase
->setCode(ProjectConstraint::END_BEFORE_BEGIN_ERROR)
->assertRaised();
}
public function testGetTargets()
{
$constraint = new ProjectConstraint();
self::assertEquals('class', $constraint->getTargets());
}
}

View File

@@ -0,0 +1,123 @@
<?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\Validator\Constraints;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Model\QuickEntryModel as QuickEntryModelEntity;
use App\Validator\Constraints\QuickEntryModel;
use App\Validator\Constraints\QuickEntryModelValidator;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\QuickEntryModel
* @covers \App\Validator\Constraints\QuickEntryModelValidator
*/
class QuickEntryModelValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator()
{
return new QuickEntryModelValidator();
}
public function testConstraintIsInvalid()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new Timesheet(), new NotBlank());
}
public function testInvalidValueThrowsException()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new Timesheet(), new QuickEntryModel());
}
public function testTriggersOnMissingProjectAndActivity()
{
$model = new QuickEntryModelEntity();
$timesheet = new Timesheet();
$timesheet->setBegin(new \DateTime());
$timesheet->setBegin(new \DateTime('+ 1 hour'));
$model->addTimesheet($timesheet);
$this->validator->validate($model, new QuickEntryModel());
$this->buildViolation('An activity needs to be selected.')
->atPath('property.path.activity')
->setCode(QuickEntryModel::ACTIVITY_REQUIRED)
->buildNextViolation('A project needs to be selected.')
->atPath('property.path.project')
->setCode(QuickEntryModel::PROJECT_REQUIRED)
->assertRaised();
}
public function testTriggersOnMissingActivity()
{
$model = new QuickEntryModelEntity();
$model->setProject(new Project());
$timesheet = new Timesheet();
$timesheet->setBegin(new \DateTime());
$timesheet->setBegin(new \DateTime('+ 1 hour'));
$model->addTimesheet($timesheet);
$this->validator->validate($model, new QuickEntryModel());
$this->buildViolation('An activity needs to be selected.')
->atPath('property.path.activity')
->setCode(QuickEntryModel::ACTIVITY_REQUIRED)
->assertRaised();
}
public function testTriggersOnMissingProject()
{
$model = new QuickEntryModelEntity();
$model->setActivity(new Activity());
$timesheet = new Timesheet();
$timesheet->setBegin(new \DateTime());
$timesheet->setBegin(new \DateTime('+ 1 hour'));
$model->addTimesheet($timesheet);
$this->validator->validate($model, new QuickEntryModel());
$this->buildViolation('A project needs to be selected.')
->atPath('property.path.project')
->setCode(QuickEntryModel::PROJECT_REQUIRED)
->assertRaised();
}
public function testDoesNotTriggerOnPrototype()
{
$model = new QuickEntryModelEntity();
$this->validator->validate($model, new QuickEntryModel());
$this->assertNoViolation();
}
public function testDoesNotTriggerOnProperlyFilled()
{
$model = new QuickEntryModelEntity();
$model->setActivity(new Activity());
$model->setProject(new Project());
$timesheet = new Timesheet();
$timesheet->setBegin(new \DateTime());
$timesheet->setBegin(new \DateTime('+ 1 hour'));
$model->addTimesheet($timesheet);
$this->validator->validate($model, new QuickEntryModel());
$this->assertNoViolation();
}
}

View File

@@ -0,0 +1,61 @@
<?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\Validator\Constraints;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Validator\Constraints\QuickEntryTimesheet;
use App\Validator\Constraints\QuickEntryTimesheetValidator;
use App\Validator\Constraints\TimesheetBasic;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\QuickEntryTimesheet
* @covers \App\Validator\Constraints\QuickEntryTimesheetValidator
*/
class QuickEntryTimesheetValidatorTest extends ConstraintValidatorTestCase
{
protected function createConstraint(): Constraint
{
return new QuickEntryTimesheet();
}
protected function createValidator()
{
return new QuickEntryTimesheetValidator([new TimesheetBasic()]);
}
public function testConstraintIsInvalid()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new Timesheet(), new NotBlank());
}
public function testInvalidValueThrowsException()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new Activity(), $this->createConstraint());
}
public function testNotTriggersOnEmptyDurationAndNewTimesheet()
{
$timesheet = new Timesheet();
$timesheet->setDuration(null);
$this->validator->validate($timesheet, $this->createConstraint());
$this->assertNoViolation();
}
}

View File

@@ -18,6 +18,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\Role
* @covers \App\Validator\Constraints\RoleValidator
*/
class RoleValidatorTest extends ConstraintValidatorTestCase

View File

@@ -19,6 +19,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\Team
* @covers \App\Validator\Constraints\TeamValidator
*/
class TeamValidatorTest extends ConstraintValidatorTestCase

View File

@@ -17,6 +17,7 @@ use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimeFormat
* @covers \App\Validator\Constraints\TimeFormatValidator
*/
class TimeFormatValidatorTest extends ConstraintValidatorTestCase

View File

@@ -0,0 +1,243 @@
<?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\Validator\Constraints;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Validator\Constraints\TimesheetBasic;
use App\Validator\Constraints\TimesheetBasicValidator;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetBasic
* @covers \App\Validator\Constraints\TimesheetBasicValidator
*/
class TimesheetBasicValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator()
{
return $this->createMyValidator();
}
protected function createMyValidator()
{
return new TimesheetBasicValidator();
}
public function testConstraintIsInvalid()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new Timesheet(), new NotBlank());
}
public function testInvalidValueThrowsException()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new NotBlank(), new TimesheetBasic(['message' => 'myMessage']));
}
public function testEmptyTimesheet()
{
$timesheet = new Timesheet();
$this->validator->validate($timesheet, new TimesheetBasic(['message' => 'myMessage']));
$this->buildViolation('You must submit a begin date.')
->atPath('property.path.begin')
->setCode(TimesheetBasic::MISSING_BEGIN_ERROR)
->buildNextViolation('An activity needs to be selected.')
->atPath('property.path.activity')
->setCode(TimesheetBasic::MISSING_ACTIVITY_ERROR)
->buildNextViolation('A project needs to be selected.')
->atPath('property.path.project')
->setCode(TimesheetBasic::MISSING_PROJECT_ERROR)
->assertRaised();
}
public function testFutureBegin()
{
$begin = new \DateTime('+10 hour');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$this->validator->validate($timesheet, new TimesheetBasic(['message' => 'myMessage']));
$this
->buildViolation('An activity needs to be selected.')
->atPath('property.path.activity')
->setCode(TimesheetBasic::MISSING_ACTIVITY_ERROR)
->buildNextViolation('A project needs to be selected.')
->atPath('property.path.project')
->setCode(TimesheetBasic::MISSING_PROJECT_ERROR)
// The test context is not able to handle calls to validate() - see ConstraintValidatorTestCase::createContext()
// therefor sub-constraints will not be executed :-(
/*
->buildNextViolation('The begin date cannot be in the future.')
->atPath('property.path.begin')
->setCode(TimesheetFutureTimes::BEGIN_IN_FUTURE_ERROR)
*/
->assertRaised();
}
public function testEndBeforeBegin()
{
$end = new \DateTime('-10 hour');
$begin = new \DateTime('-1 hour');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$timesheet->setEnd($end);
$this->validator->validate($timesheet, new TimesheetBasic(['message' => 'myMessage']));
$this->buildViolation('End date must not be earlier then start date.')
->atPath('property.path.end')
->setCode(TimesheetBasic::END_BEFORE_BEGIN_ERROR)
->buildNextViolation('An activity needs to be selected.')
->atPath('property.path.activity')
->setCode(TimesheetBasic::MISSING_ACTIVITY_ERROR)
->buildNextViolation('A project needs to be selected.')
->atPath('property.path.project')
->setCode(TimesheetBasic::MISSING_PROJECT_ERROR)
->assertRaised();
}
public function testProjectMismatch()
{
$end = new \DateTime('-1 hour');
$begin = new \DateTime('-10 hour');
$activity = new Activity();
$project1 = new Project();
$project2 = new Project();
$project2->setCustomer(new Customer());
$activity->setProject($project1);
$timesheet = new Timesheet();
$timesheet
->setBegin($begin)
->setEnd($end)
->setActivity($activity)
->setProject($project2)
;
$this->validator->validate($timesheet, new TimesheetBasic(['message' => 'myMessage']));
$this->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
->atPath('property.path.project')
->setCode(TimesheetBasic::ACTIVITY_PROJECT_MISMATCH_ERROR)
->assertRaised();
}
public function testDisabledValuesDuringStart()
{
$begin = new \DateTime('-10 hour');
$customer = new Customer();
$customer->setVisible(false);
$activity = new Activity();
$activity->setVisible(false);
$project = new Project();
$project->setVisible(false);
$project->setCustomer($customer);
$activity->setProject($project);
$timesheet = new Timesheet();
$timesheet
->setBegin($begin)
->setActivity($activity)
->setProject($project)
;
$this->validator->validate($timesheet, new TimesheetBasic(['message' => 'myMessage']));
$this->buildViolation('Cannot start a disabled activity.')
->atPath('property.path.activity')
->setCode(TimesheetBasic::DISABLED_ACTIVITY_ERROR)
->buildNextViolation('Cannot start a disabled project.')
->atPath('property.path.project')
->setCode(TimesheetBasic::DISABLED_PROJECT_ERROR)
->buildNextViolation('Cannot start a disabled customer.')
->atPath('property.path.customer')
->setCode(TimesheetBasic::DISABLED_CUSTOMER_ERROR)
->assertRaised();
}
public function getProjectStartEndTestData()
{
yield [new \DateTime(), new \DateTime(), [
['begin', TimesheetBasic::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
['end', TimesheetBasic::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
]];
yield [new \DateTime('-9 hour'), new \DateTime('-2 hour'), [
['begin', TimesheetBasic::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
['end', TimesheetBasic::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
]];
yield [new \DateTime('-19 hour'), new \DateTime('-12 hour'), [
['begin', TimesheetBasic::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
['end', TimesheetBasic::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
]];
yield [new \DateTime('-19 hour'), new \DateTime('-2 hour'), [
['end', TimesheetBasic::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
]];
yield [new \DateTime('-9 hour'), new \DateTime(), [
['begin', TimesheetBasic::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
]];
}
/**
* @dataProvider getProjectStartEndTestData
*/
public function testEndBeforeWithProjectStartAndEnd(\DateTime $start, \DateTime $end, array $violations)
{
$timesheet = new Timesheet();
$timesheet->setBegin(new \DateTime('-10 hour'));
$timesheet->setEnd(new \DateTime('-1 hour'));
$customer = new Customer();
$project = new Project();
$project->setStart($start);
$project->setEnd($end);
$project->setCustomer($customer);
$timesheet->setProject($project);
$timesheet->setActivity(new Activity());
$this->validator->validate($timesheet, new TimesheetBasic(['message' => 'myMessage']));
$assertion = null;
foreach ($violations as $violation) {
if (null === $assertion) {
$assertion = $this->buildViolation($violation[2])
->atPath('property.path.' . $violation[0])
->setCode($violation[1])
;
} else {
$assertion = $assertion->buildNextViolation($violation[2])
->atPath('property.path.' . $violation[0])
->setCode($violation[1])
;
}
}
$assertion->assertRaised();
}
public function testGetTargets()
{
$constraint = new TimesheetBasic();
self::assertEquals('class', $constraint->getTargets());
}
}

View File

@@ -0,0 +1,111 @@
<?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\Validator\Constraints;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Validator\Constraints\TimesheetExported;
use App\Validator\Constraints\TimesheetExportedValidator;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetExported
* @covers \App\Validator\Constraints\TimesheetExportedValidator
*/
class TimesheetExportedValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator()
{
return $this->createMyValidator(true);
}
protected function createMyValidator(bool $allowEdit)
{
$auth = $this->createMock(Security::class);
$auth->method('getUser')->willReturn(new User());
$auth->method('isGranted')->willReturnCallback(
function ($attributes, $subject = null) use ($allowEdit) {
switch ($attributes) {
case 'edit_exported_timesheet':
return $allowEdit;
}
return false;
}
);
return new TimesheetExportedValidator($auth);
}
public function testConstraintIsInvalid()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new Timesheet(), new NotBlank());
}
public function testInvalidValueThrowsException()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new NotBlank(), new TimesheetExported(['message' => 'myMessage']));
}
public function testTriggersOnMissingPermission()
{
$this->validator = $this->createMyValidator(false);
$this->validator->initialize($this->context);
$timesheet = new Timesheet();
$timesheet->setExported(true);
$this->validator->validate($timesheet, new TimesheetExported());
$this->buildViolation('This timesheet is already exported.')
->atPath('property.path.exported')
->setCode(TimesheetExported::TIMESHEET_EXPORTED)
->assertRaised();
}
public function testDoesNotTriggerWithPermission()
{
$this->validator = $this->createMyValidator(true);
$this->validator->initialize($this->context);
$timesheet = new Timesheet();
$timesheet->setExported(true);
$this->validator->validate($timesheet, new TimesheetExported());
$this->assertNoViolation();
}
public function testDoesNotTriggerIfNotExported()
{
$this->validator = $this->createMyValidator(false);
$this->validator->initialize($this->context);
$timesheet = new Timesheet();
$timesheet->setExported(false);
$this->validator->validate($timesheet, new TimesheetExported());
$this->assertNoViolation();
}
public function testGetTargets()
{
$constraint = new TimesheetExported();
self::assertEquals('class', $constraint->getTargets());
}
}

View File

@@ -19,6 +19,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetFutureTimes
* @covers \App\Validator\Constraints\TimesheetFutureTimesValidator
*/
class TimesheetFutureTimesValidatorTest extends ConstraintValidatorTestCase

View File

@@ -22,6 +22,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetLockdown
* @covers \App\Validator\Constraints\TimesheetLockdownValidator
*/
class TimesheetLockdownValidatorTest extends ConstraintValidatorTestCase

View File

@@ -19,6 +19,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetLongRunning
* @covers \App\Validator\Constraints\TimesheetLongRunningValidator
*/
class TimesheetLongRunningValidatorTest extends ConstraintValidatorTestCase
@@ -73,6 +74,33 @@ class TimesheetLongRunningValidatorTest extends ConstraintValidatorTestCase
->assertRaised();
}
public function testLongRunningTriggersOverMaximum()
{
$timesheet = new Timesheet();
$timesheet->setBegin(new \DateTime());
$timesheet->setEnd(new \DateTime());
$timesheet->setDuration(31536001);
$this->validator->validate($timesheet, new TimesheetLongRunning());
$this->buildViolation('Maximum duration exceeded.')
->atPath('property.path.duration')
->setCode(TimesheetLongRunning::MAXIMUM)
->assertRaised();
}
public function testLongRunningDoesNotTriggerOnMaximum()
{
$timesheet = new Timesheet();
$timesheet->setBegin(new \DateTime());
$timesheet->setEnd(new \DateTime());
$timesheet->setDuration(31536000);
$this->validator->validate($timesheet, new TimesheetLongRunning());
$this->assertNoViolation();
}
public function testLongRunningNotTriggersIfConfiguredToZero()
{
$this->validator = $this->createMyValidator(0);
@@ -114,4 +142,10 @@ class TimesheetLongRunningValidatorTest extends ConstraintValidatorTestCase
$this->validator->validate($timesheet, new TimesheetLongRunning());
$this->assertNoViolation();
}
public function testGetTargets()
{
$constraint = new TimesheetLongRunning();
self::assertEquals('class', $constraint->getTargets());
}
}

View File

@@ -20,6 +20,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetMultiUpdate
* @covers \App\Validator\Constraints\TimesheetMultiUpdateValidator
*/
class TimesheetMultiUpdateValidatorTest extends ConstraintValidatorTestCase

View File

@@ -17,6 +17,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetMultiUser
* @covers \App\Validator\Constraints\TimesheetMultiUserValidator
*/
class TimesheetMultiUserValidatorTest extends ConstraintValidatorTestCase

View File

@@ -20,6 +20,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetOverlapping
* @covers \App\Validator\Constraints\TimesheetOverlappingValidator
*/
class TimesheetOverlappingValidatorTest extends ConstraintValidatorTestCase

View File

@@ -24,6 +24,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetRestart
* @covers \App\Validator\Constraints\TimesheetRestartValidator
*/
class TimesheetRestartValidatorTest extends ConstraintValidatorTestCase

View File

@@ -9,19 +9,15 @@
namespace App\Tests\Validator\Constraints;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
use App\Validator\Constraints\TimesheetFutureTimes;
use App\Validator\Constraints\TimesheetValidator;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
use Symfony\Component\Validator\Test\ConstraintViolationAssertion;
/**
* @covers \App\Validator\Constraints\Timesheet
* @covers \App\Validator\Constraints\TimesheetValidator
*/
class TimesheetValidatorTest extends ConstraintValidatorTestCase
@@ -49,190 +45,4 @@ class TimesheetValidatorTest extends ConstraintValidatorTestCase
$this->validator->validate(new NotBlank(), new TimesheetConstraint(['message' => 'myMessage']));
}
public function testEmptyTimesheet()
{
$timesheet = new Timesheet();
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
$this->buildViolation('You must submit a begin date.')
->atPath('property.path.begin')
->setCode(TimesheetConstraint::MISSING_BEGIN_ERROR)
->buildNextViolation('A timesheet must have an activity.')
->atPath('property.path.activity')
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
->buildNextViolation('A timesheet must have a project.')
->atPath('property.path.project')
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
->assertRaised();
}
public function testFutureBegin()
{
$begin = new \DateTime('+10 hour');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
$this
->buildViolation('A timesheet must have an activity.')
->atPath('property.path.activity')
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
->buildNextViolation('A timesheet must have a project.')
->atPath('property.path.project')
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
// The test context is not able to handle calls to validate() - see ConstraintValidatorTestCase::createContext()
// therefor sub-constraints will not be executed :-(
/*
->buildNextViolation('The begin date cannot be in the future.')
->atPath('property.path.begin')
->setCode(TimesheetFutureTimes::BEGIN_IN_FUTURE_ERROR)
*/
->assertRaised();
}
public function testEndBeforeBegin()
{
$end = new \DateTime('-10 hour');
$begin = new \DateTime('-1 hour');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$timesheet->setEnd($end);
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
$this->buildViolation('End date must not be earlier then start date.')
->atPath('property.path.end')
->setCode(TimesheetConstraint::END_BEFORE_BEGIN_ERROR)
->buildNextViolation('A timesheet must have an activity.')
->atPath('property.path.activity')
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
->buildNextViolation('A timesheet must have a project.')
->atPath('property.path.project')
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
->assertRaised();
}
public function testProjectMismatch()
{
$end = new \DateTime('-1 hour');
$begin = new \DateTime('-10 hour');
$activity = new Activity();
$project1 = new Project();
$project2 = new Project();
$activity->setProject($project1);
$timesheet = new Timesheet();
$timesheet
->setBegin($begin)
->setEnd($end)
->setActivity($activity)
->setProject($project2)
;
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
$this->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
->atPath('property.path.project')
->setCode(TimesheetConstraint::ACTIVITY_PROJECT_MISMATCH_ERROR)
->assertRaised();
}
public function testDisabledValuesDuringStart()
{
$begin = new \DateTime('-10 hour');
$customer = new Customer();
$customer->setVisible(false);
$activity = new Activity();
$activity->setVisible(false);
$project = new Project();
$project->setVisible(false);
$project->setCustomer($customer);
$activity->setProject($project);
$timesheet = new Timesheet();
$timesheet
->setBegin($begin)
->setActivity($activity)
->setProject($project)
;
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
$this->buildViolation('Cannot start a disabled activity.')
->atPath('property.path.activity')
->setCode(TimesheetConstraint::DISABLED_ACTIVITY_ERROR)
->buildNextViolation('Cannot start a disabled project.')
->atPath('property.path.project')
->setCode(TimesheetConstraint::DISABLED_PROJECT_ERROR)
->buildNextViolation('Cannot start a disabled customer.')
->atPath('property.path.customer')
->setCode(TimesheetConstraint::DISABLED_CUSTOMER_ERROR)
->assertRaised();
}
public function getProjectStartEndTestData()
{
yield [new \DateTime(), new \DateTime(), [
['begin', TimesheetConstraint::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
['end', TimesheetConstraint::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
]];
yield [new \DateTime('-9 hour'), new \DateTime('-2 hour'), [
['begin', TimesheetConstraint::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
['end', TimesheetConstraint::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
]];
yield [new \DateTime('-19 hour'), new \DateTime('-12 hour'), [
['begin', TimesheetConstraint::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
['end', TimesheetConstraint::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
]];
yield [new \DateTime('-19 hour'), new \DateTime('-2 hour'), [
['end', TimesheetConstraint::PROJECT_ALREADY_ENDED, 'The project is finished at that time.'],
]];
yield [new \DateTime('-9 hour'), new \DateTime(), [
['begin', TimesheetConstraint::PROJECT_NOT_STARTED, 'The project has not started at that time.'],
]];
}
/**
* @dataProvider getProjectStartEndTestData
*/
public function testEndBeforeWithProjectStartAndEnd(\DateTime $start, \DateTime $end, array $violations)
{
$timesheet = new Timesheet();
$timesheet->setBegin(new \DateTime('-10 hour'));
$timesheet->setEnd(new \DateTime('-1 hour'));
$customer = new Customer();
$project = new Project();
$project->setStart($start);
$project->setEnd($end);
$project->setCustomer($customer);
$timesheet->setProject($project);
$timesheet->setActivity(new Activity());
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
/** @var ConstraintViolationAssertion $assertion */
$assertion = null;
foreach ($violations as $violation) {
if (null === $assertion) {
$assertion = $this->buildViolation($violation[2])
->atPath('property.path.' . $violation[0])
->setCode($violation[1])
;
} else {
$assertion = $assertion->buildNextViolation($violation[2])
->atPath('property.path.' . $violation[0])
->setCode($violation[1])
;
}
}
$assertion->assertRaised();
}
}

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