Update and delete multi timesheets and tags (#1240)

This commit is contained in:
Kevin Papst
2019-11-15 01:25:31 +01:00
committed by GitHub
parent b4739f8f03
commit 94c28ebbc5
49 changed files with 1836 additions and 307 deletions

View File

@@ -34,6 +34,7 @@ import KimaiFormSelect from "./plugins/KimaiFormSelect";
import KimaiForm from "./plugins/KimaiForm";
import KimaiDatePicker from "./plugins/KimaiDatePicker";
import KimaiConfirmationLink from "./plugins/KimaiConfirmationLink";
import KimaiMultiUpdateTable from "./plugins/KimaiMultiUpdateTable";
export default class KimaiLoader {
@@ -67,6 +68,7 @@ export default class KimaiLoader {
kimai.registerPlugin(new KimaiAutocomplete('.js-autocomplete'));
kimai.registerPlugin(new KimaiForm());
kimai.registerPlugin(new KimaiThemeInitializer());
kimai.registerPlugin(new KimaiMultiUpdateTable());
//kimai.registerPlugin(new KimaiPauseRecord('li.messages-menu ul.menu li'));
// notify all listeners that Kimai plugins can now be registered

View File

@@ -27,7 +27,7 @@ export default class KimaiAlert extends KimaiPlugin {
message = translation.get(message);
}
Swal.fire({
type: 'error',
icon: 'error',
title: title.replace('%reason%', ''),
text: message,
});
@@ -42,10 +42,11 @@ export default class KimaiAlert extends KimaiPlugin {
Swal.fire({
timer: 2000,
timerProgressBar: true,
toast: true,
position: 'top-end',
showConfirmButton: false,
type: 'success',
icon: 'success',
title: message,
});
}
@@ -65,7 +66,7 @@ export default class KimaiAlert extends KimaiPlugin {
Swal.fire({
title: message,
type: 'question',
icon: 'question',
showCancelButton: true,
confirmButtonText: translation.get('confirm'),
cancelButtonText: translation.get('cancel')

View File

@@ -30,15 +30,30 @@ export default class KimaiConfirmationLink extends KimaiPlugin {
if (target.classList.contains(self.selector)) {
const attributes = target.dataset;
// is this a link?
let url = attributes['href'];
// or another HTML element with a custom href
if (!url) {
url = target.getAttribute('href');
}
// or is this a button?
let form = null;
if (target.type === 'submit' && target.form !== undefined) {
form = target.form;
}
if (attributes.question !== undefined) {
self.getContainer().getPlugin('alert').question(attributes.question, function(value) {
if (value) {
if (form === null) {
document.location = url;
} else {
if (url !== null) {
form.action = url;
}
form.submit();
}
}
});
}

View File

@@ -51,6 +51,7 @@ export default class KimaiFormSelect extends KimaiPlugin {
updateOptions(selectIdentifier, data) {
let select = jQuery(selectIdentifier);
let emptyOption = jQuery(selectIdentifier + ' option[value=""]');
const selectedValue = select.val();
select.find('option').remove().end().find('optgroup').remove().end();
@@ -79,6 +80,9 @@ export default class KimaiFormSelect extends KimaiPlugin {
select.append(htmlOptions);
select.append(emptyOptions);
// if available, re-select the previous selected option (mostly usable for global activities)
select.val(selectedValue);
// if we don't trigger the change, the other selects won't be resetted
select.trigger('change');

View File

@@ -0,0 +1,73 @@
/*
* 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.
*/
/*!
* [KIMAI] KimaiMultiUpdateForm: handle the multi update checkbox list and form
*/
import KimaiPlugin from '../KimaiPlugin';
import jQuery from "jquery";
export default class KimaiMultiUpdateTable extends KimaiPlugin {
init() {
const self = this;
jQuery('body').
on('change', '#multi_update_all', function(event) {
jQuery('.multi_update_single').prop('checked', jQuery(event.target).prop('checked'));
self.toggleForm();
})
.on('change', '.multi_update_single', function(event) {
self.toggleForm();
})
.on('change', '#multi_update_table_action', function(event) {
const selectedItem = jQuery('#multi_update_table_action option:selected');
const selectedVal = selectedItem.val();
if (selectedVal === '') {
return;
}
const form = jQuery('#multi_update_form form');
const selectedText = selectedItem.text();
const ids = self.getSelectedIds();
const question = form.attr('data-question').replace(/%action%/, selectedText).replace(/%count%/, ids.length);
self.getContainer().getPlugin('alert').question(question, function(value) {
if (value) {
form.attr('action', selectedVal).submit();
} else {
jQuery('#multi_update_table_action').val('').trigger('change');
}
});
});
}
getSelectedIds()
{
let ids = [];
jQuery('.multi_update_single:checked').each(function(i){
ids[i] = $(this).val();
});
return ids;
}
toggleForm()
{
const ids = this.getSelectedIds();
jQuery('#multi_update_table_entities').val(ids.join(','));
if (ids.length > 0) {
jQuery('#multi_update_form').show();
} else {
jQuery('#multi_update_form').hide();
}
}
}

View File

@@ -33,10 +33,14 @@ table.dataTable {
&.summary td {
font-weight: bold;
border-bottom: 1px solid #ccc;
background-color: #eaeaea;
}
&.open-edit {
cursor: pointer;
}
th.multiCheckbox {
width: 15px;
}
}
td {
/*

View File

@@ -39,7 +39,7 @@
"sass-loader": "^7.0.2",
"select2": "^4.0.10",
"select2-bootstrap-theme": "^0.1.0-beta.10",
"sweetalert2": "^8.11.7",
"sweetalert2": "^9.3",
"webpack-notifier": "^1.5.1"
}
}

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

@@ -5,10 +5,10 @@
"build/runtime.4ee6be68.js",
"build/0.a87622f3.js",
"build/1.c1bee41f.js",
"build/app.09cc993e.js"
"build/app.120bf1d1.js"
],
"css": [
"build/app.4bdd4f2d.css"
"build/app.ca6a8b7f.css"
]
},
"chart": {
@@ -35,8 +35,8 @@
"build/runtime.4ee6be68.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
"build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI",
"build/app.09cc993e.js": "sha384-d/JgeK3j55PlnEKB+jFbHt0967vhUr5uwSRapKwFCpWoxGkahBuRz1rgkysgTy+g",
"build/app.4bdd4f2d.css": "sha384-XpqGJV9ny/Fk0uJHisAZrAZfWfkzDvm5RJQOaa9ozf6nJstSXyEHziVAza9fIifm",
"build/app.120bf1d1.js": "sha384-2Eohdb33hChqGmyJIUGgZFiqzPfUDIv74IoMAX+09VozhJDD/GtPIg5+N2CCOACl",
"build/app.ca6a8b7f.css": "sha384-NrITZnNaaIkzEjZkOjxciIbq6V6dKC9VlbJfxliLc2vi8n0113/yLfhbwi5rjzg2",
"build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
"build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.6f94d071.js": "sha384-CnbCkgF1mbGOycK2A78CF1ZsQ4O07ol9U4gB0Tiec0wKu1mNw8lE22lvoRNzYs/8",

View File

@@ -2,8 +2,8 @@
"build/0.a87622f3.js": "build/0.a87622f3.js",
"build/1.c1bee41f.js": "build/1.c1bee41f.js",
"build/2.7be60d8d.js": "build/2.7be60d8d.js",
"build/app.css": "build/app.4bdd4f2d.css",
"build/app.js": "build/app.09cc993e.js",
"build/app.css": "build/app.ca6a8b7f.css",
"build/app.js": "build/app.120bf1d1.js",
"build/calendar.css": "build/calendar.36ef2a02.css",
"build/calendar.js": "build/calendar.6f94d071.js",
"build/chart.js": "build/chart.0af3f813.js",

View File

@@ -10,12 +10,15 @@
namespace App\Controller;
use App\Entity\Tag;
use App\Form\MultiUpdate\MultiUpdateTable;
use App\Form\MultiUpdate\MultiUpdateTableDTO;
use App\Form\TagEditForm;
use App\Form\Toolbar\TagToolbarForm;
use App\Repository\Query\TagQuery;
use App\Repository\TagRepository;
use Doctrine\ORM\ORMException;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -54,6 +57,7 @@ class TagController extends AbstractController
'tags' => $tags,
'query' => $query,
'toolbarForm' => $form->createView(),
'multiUpdateForm' => $this->getMultiUpdateForm($repository)->createView(),
]);
}
@@ -119,6 +123,41 @@ class TagController extends AbstractController
]);
}
/**
* @Route(path="/multi-delete", name="tags_multi_delete", methods={"POST"})
* @Security("is_granted('delete_tag')")
*/
public function multiDelete(TagRepository $repository, Request $request)
{
$form = $this->getMultiUpdateForm($repository);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
/** @var MultiUpdateTableDTO $dto */
$dto = $form->getData();
$repository->multiDelete($dto->getEntities());
$this->flashSuccess('action.delete.success');
} catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->redirectToRoute('tags');
}
protected function getMultiUpdateForm(TagRepository $repository): FormInterface
{
$dto = new MultiUpdateTableDTO();
$dto->addDelete($this->generateUrl('tags_multi_delete'));
return $this->createForm(MultiUpdateTable::class, $dto, [
'action' => $this->generateUrl('tags'),
'repository' => $repository,
'method' => 'POST',
]);
}
/**
* @param TagQuery $query
* @return \Symfony\Component\Form\FormInterface

View File

@@ -16,6 +16,10 @@ use App\Entity\Timesheet;
use App\Event\TimesheetMetaDefinitionEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Export\ServiceExport;
use App\Form\MultiUpdate\MultiUpdateTable;
use App\Form\MultiUpdate\MultiUpdateTableDTO;
use App\Form\MultiUpdate\TimesheetMultiUpdate;
use App\Form\MultiUpdate\TimesheetMultiUpdateDTO;
use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\ActivityRepository;
@@ -130,6 +134,7 @@ abstract class TimesheetAbstractController extends AbstractController
'page' => $query->getPage(),
'query' => $query,
'toolbarForm' => $form->createView(),
'multiUpdateForm' => $this->getMultiUpdateActionForm()->createView(),
'showSummary' => $this->includeSummary(),
'showStartEndTime' => $this->canSeeStartEndTime(),
'metaColumns' => $this->findMetaColumns($query, $location),
@@ -174,6 +179,24 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
protected function getTags(TagRepository $tagRepository, $tagNames)
{
$tags = [];
if (!is_array($tagNames)) {
$tagNames = explode(',', $tagNames);
}
foreach ($tagNames as $tagName) {
$tag = $tagRepository->findTagByName($tagName);
if (!$tag) {
$tag = new Tag();
$tag->setName($tagName);
}
$tags[] = $tag;
}
return $tags;
}
protected function create(Request $request, string $renderTemplate, ProjectRepository $projectRepository, ActivityRepository $activityRepository, TagRepository $tagRepository): Response
{
$entry = new Timesheet();
@@ -190,13 +213,7 @@ abstract class TimesheetAbstractController extends AbstractController
}
if ($request->query->get('tags')) {
$tagNames = explode(',', $request->query->get('tags'));
foreach ($tagNames as $tagName) {
$tag = $tagRepository->findTagByName($tagName);
if (!$tag) {
$tag = new Tag();
$tag->setName($tagName);
}
foreach ($this->getTags($tagRepository, $request->query->get('tags')) as $tag) {
$entry->addTag($tag);
}
}
@@ -267,11 +284,142 @@ abstract class TimesheetAbstractController extends AbstractController
return $exporter->render($entries, $query);
}
protected function multiUpdate(Request $request, string $renderTemplate)
{
$dto = new TimesheetMultiUpdateDTO();
// initial request from the listing posts a different form
$form = $this->getMultiUpdateActionForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$dto->setEntities($form->getData()->getEntities());
}
$form = $this->getMultiUpdateForm($dto);
$form->handleRequest($request);
// remove all, which are not allowed to be edited
$timesheets = [];
/** @var Timesheet $timesheet */
foreach ($dto->getEntities() as $timesheet) {
if (!$this->isGranted('edit', $timesheet)) {
continue;
}
$timesheets[] = $timesheet;
}
$dto->setEntities($timesheets);
if (count($dto->getEntities()) === 0) {
return $this->redirectToRoute($this->getTimesheetRoute());
}
if ($form->isSubmitted() && $form->isValid()) {
/** @var Timesheet $timesheet */
$execute = false;
foreach ($dto->getEntities() as $timesheet) {
if ($dto->isReplaceTags()) {
foreach ($timesheet->getTags() as $tag) {
$timesheet->removeTag($tag);
}
$execute = true;
}
foreach ($dto->getTags() as $tag) {
$timesheet->addTag($tag);
$execute = true;
}
if (null !== $dto->getActivity()) {
$timesheet->setActivity($dto->getActivity());
$execute = true;
}
if (null !== $dto->getProject()) {
$timesheet->setProject($dto->getProject());
$execute = true;
}
if (null !== $dto->getUser()) {
$timesheet->setUser($dto->getUser());
$execute = true;
}
if (null !== $dto->isExported()) {
$timesheet->setExported($dto->isExported());
$execute = true;
}
}
if ($execute) {
try {
$this->repository->saveMultiple($dto->getEntities());
$this->flashSuccess('action.update.success');
return $this->redirectToRoute($this->getTimesheetRoute());
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
}
return $this->render($renderTemplate, [
'form' => $form->createView(),
'dto' => $dto,
]);
}
protected function multiDelete(Request $request)
{
$form = $this->getMultiUpdateActionForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$dto = $form->getData();
$timesheets = [];
/** @var Timesheet $timesheet */
foreach ($dto->getEntities() as $timesheet) {
if (!$this->isGranted('delete', $timesheet)) {
continue;
}
$timesheets[] = $timesheet;
}
$dto->setEntities($timesheets);
try {
$this->repository->deleteMultiple($dto->getEntities());
$this->flashSuccess('action.delete.success');
} catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->redirectToRoute($this->getTimesheetRoute());
}
protected function prepareQuery(TimesheetQuery $query)
{
$query->setUser($this->getUser());
}
protected function getMultiUpdateForm(TimesheetMultiUpdateDTO $multiUpdate): FormInterface
{
return $this->createForm(TimesheetMultiUpdate::class, $multiUpdate, [
'action' => $this->generateUrl($this->getMultiUpdateRoute(), []),
'method' => 'POST',
'include_exported' => $this->isGranted($this->getPermissionEditExport()),
'include_user' => $this->includeUserInForms(),
]);
}
protected function getMultiUpdateActionForm(): FormInterface
{
$dto = new MultiUpdateTableDTO();
$dto->addUpdate($this->generateUrl($this->getMultiUpdateRoute()));
$dto->addDelete($this->generateUrl($this->getMultiDeleteRoute()));
return $this->createForm(MultiUpdateTable::class, $dto, [
'action' => $this->generateUrl($this->getTimesheetRoute()),
'repository' => $this->getRepository(),
'method' => 'POST',
]);
}
protected function getCreateForm(Timesheet $entry, TrackingModeInterface $mode): FormInterface
{
return $this->createForm($this->getCreateFormClassName(), $entry, [
@@ -325,6 +473,11 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
protected function getPermissionEditExport(): string
{
return 'edit_export_own_timesheet';
}
protected function getCreateFormClassName(): string
{
return TimesheetEditForm::class;
@@ -360,6 +513,16 @@ abstract class TimesheetAbstractController extends AbstractController
return 'timesheet_create';
}
protected function getMultiUpdateRoute(): string
{
return 'timesheet_multi_update';
}
protected function getMultiDeleteRoute(): string
{
return 'timesheet_multi_delete';
}
protected function canSeeStartEndTime(): bool
{
return $this->getTrackingMode()->canSeeBeginAndEndTimes();

View File

@@ -65,6 +65,24 @@ class TimesheetController extends TimesheetAbstractController
return $this->edit($entry, $request, 'timesheet/edit.html.twig');
}
/**
* @Route(path="/multi-update", name="timesheet_multi_update", methods={"POST"})
* @Security("is_granted('edit_own_timesheet')")
*/
public function multiUpdateAction(Request $request)
{
return $this->multiUpdate($request, 'timesheet/multi-update.html.twig');
}
/**
* @Route(path="/multi-delete", name="timesheet_multi_delete", methods={"POST"})
* @Security("is_granted('delete_own_timesheet')")
*/
public function multiDeleteAction(Request $request)
{
return $this->multiDelete($request);
}
/**
* @Route(path="/create", name="timesheet_create", methods={"GET", "POST"})
* @Security("is_granted('create_own_timesheet')")

View File

@@ -80,11 +80,34 @@ class TimesheetTeamController extends TimesheetAbstractController
return $this->create($request, 'timesheet-team/edit.html.twig', $projectRepository, $activityRepository, $tagRepository);
}
/**
* @Route(path="/multi-update", name="admin_timesheet_multi_update", methods={"POST"})
* @Security("is_granted('edit_other_timesheet')")
*/
public function multiUpdateAction(Request $request)
{
return $this->multiUpdate($request, 'timesheet-team/multi-update.html.twig');
}
/**
* @Route(path="/multi-delete", name="admin_timesheet_multi_delete", methods={"POST"})
* @Security("is_granted('delete_other_timesheet')")
*/
public function multiDeleteAction(Request $request)
{
return $this->multiDelete($request);
}
protected function prepareQuery(TimesheetQuery $query)
{
$query->setCurrentUser($this->getUser());
}
protected function getPermissionEditExport(): string
{
return 'edit_export_other_timesheet';
}
protected function getCreateFormClassName(): string
{
return TimesheetAdminEditForm::class;
@@ -115,6 +138,16 @@ class TimesheetTeamController extends TimesheetAbstractController
return 'admin_timesheet_create';
}
protected function getMultiUpdateRoute(): string
{
return 'admin_timesheet_multi_update';
}
protected function getMultiDeleteRoute(): string
{
return 'admin_timesheet_multi_delete';
}
protected function canSeeStartEndTime(): bool
{
return true;

View File

@@ -63,7 +63,7 @@ final class MenuSubscriber implements EventSubscriberInterface
if ($auth->isGranted('view_own_timesheet')) {
$timesheets = new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], $this->getIcon('timesheet'));
$timesheets->setChildRoutes(['timesheet_export', 'timesheet_edit', 'timesheet_create']);
$timesheets->setChildRoutes(['timesheet_export', 'timesheet_edit', 'timesheet_create', 'timesheet_multi_update']);
$menu->addItem($timesheets);
$menu->addItem(
new MenuItemModel('calendar', 'calendar.title', 'calendar', [], $this->getIcon('calendar'))
@@ -89,7 +89,7 @@ final class MenuSubscriber implements EventSubscriberInterface
if ($auth->isGranted('view_other_timesheet')) {
$timesheets = new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], $this->getIcon('timesheet-team'));
$timesheets->setChildRoutes(['admin_timesheet_export', 'admin_timesheet_edit', 'admin_timesheet_create']);
$timesheets->setChildRoutes(['admin_timesheet_export', 'admin_timesheet_edit', 'admin_timesheet_create', 'admin_timesheet_multi_update']);
$menu->addChild($timesheets);
}

View File

@@ -64,10 +64,12 @@ class EnhancedChoiceTypeExtension extends AbstractTypeExtension
$view->vars['attr'] = [];
}
$view->vars['attr'] = array_merge(
$view->vars['attr'],
['class' => 'selectpicker', 'data-live-search' => true, 'data-width' => '100%']
);
$extendedOptions = ['class' => 'selectpicker', 'data-width' => '100%'];
if (!$options['search']) {
$extendedOptions['data-minimum-results-for-search'] = 'Infinity';
}
$view->vars['attr'] = array_merge($view->vars['attr'], $extendedOptions);
}
/**
@@ -78,5 +80,9 @@ class EnhancedChoiceTypeExtension extends AbstractTypeExtension
$resolver->setDefined(['selectpicker']);
$resolver->setAllowedTypes('selectpicker', 'boolean');
$resolver->setDefault('selectpicker', true);
$resolver->setDefined(['search']);
$resolver->setAllowedTypes('search', 'boolean');
$resolver->setDefault('search', true);
}
}

View File

@@ -0,0 +1,76 @@
<?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\MultiUpdate;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class MultiUpdateTable extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
/** @var EntityRepository $repository */
$repository = $options['repository'];
/** @var MultiUpdateTableDTO $dto */
$dto = $options['data'];
$builder->add('entities', HiddenType::class, [
'required' => false,
]);
$builder->get('entities')->addModelTransformer(
new CallbackTransformer(
function ($ids) {
return implode(',', $ids);
},
function ($ids) use ($repository) {
if (empty($ids)) {
return [];
}
return $repository->matching((new Criteria())->where(Criteria::expr()->in('id', explode(',', $ids))));
}
)
);
$builder->add('action', ChoiceType::class, [
'mapped' => false,
'required' => false,
'choices' => $dto->getActions(),
'search' => false,
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefined(['repository']);
$resolver->setAllowedTypes('repository', EntityRepository::class);
$resolver->setRequired('repository');
$resolver->setDefaults([
'data_class' => MultiUpdateTableDTO::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'entities_multiupdate',
]);
}
}

View File

@@ -0,0 +1,86 @@
<?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\MultiUpdate;
class MultiUpdateTableDTO
{
/**
* @var object[]
*/
private $entities = [];
/**
* @var string[]
*/
private $actions = ['' => ''];
/**
* @var string
*/
private $action = null;
/**
* @return object[]
*/
public function getEntities(): iterable
{
return $this->entities;
}
/**
* @param object[] $entities
* @return MultiUpdateTableDTO
*/
public function setEntities(iterable $entities): MultiUpdateTableDTO
{
$this->entities = $entities;
return $this;
}
/**
* @return string[]
*/
public function getActions(): array
{
return $this->actions;
}
public function addAction(string $label, string $url): MultiUpdateTableDTO
{
$this->actions[$label] = $url;
return $this;
}
public function addDelete(string $url): MultiUpdateTableDTO
{
$this->actions['action.delete'] = $url;
return $this;
}
public function addUpdate(string $url): MultiUpdateTableDTO
{
$this->actions['action.edit'] = $url;
return $this;
}
public function getAction(): ?string
{
return $this->action;
}
public function setAction(string $action): MultiUpdateTableDTO
{
$this->action = $action;
return $this;
}
}

View File

@@ -0,0 +1,232 @@
<?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\MultiUpdate;
use App\Form\Type\ActivityType;
use App\Form\Type\CustomerType;
use App\Form\Type\ProjectType;
use App\Form\Type\TagsInputType;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\TimesheetRepository;
use Doctrine\Common\Collections\Criteria;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TimesheetMultiUpdate extends AbstractType
{
/**
* @var TimesheetRepository
*/
private $timesheet;
/**
* @var CustomerRepository
*/
private $customers;
public function __construct(TimesheetRepository $timesheet, CustomerRepository $customer)
{
$this->timesheet = $timesheet;
$this->customers = $customer;
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$activity = null;
$project = null;
$customer = null;
$customerCount = $this->customers->countCustomer(true);
if (isset($options['data'])) {
/** @var TimesheetMultiUpdateDTO $entry */
$entry = $options['data'];
$activity = $entry->getActivity();
$project = $entry->getProject();
$customer = null === $project ? null : $project->getCustomer();
if (null === $project && null !== $activity) {
$project = $activity->getProject();
}
}
$builder
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'data' => $customer ? $customer : '',
'required' => false,
'placeholder' => '',
'mapped' => false,
'project_enabled' => true,
])
;
$projectOptions = [];
if ($customerCount < 2) {
$projectOptions['group_by'] = null;
}
$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);
},
])
);
// 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) {
$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, [
'required' => false,
'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);
},
]);
}
);
$builder
->add('activity', ActivityType::class, [
'required' => false,
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($activity, $project) {
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $project));
},
])
;
// replaces the activity select after submission, to make sure only activities for the selected project are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($activity) {
$data = $event->getData();
if (!isset($data['project']) || empty($data['project'])) {
return;
}
$event->getForm()->add('activity', ActivityType::class, [
'required' => false,
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $data['project']));
},
]);
}
);
$builder->add('replaceTags', ChoiceType::class, [
'label' => false,
'required' => true,
'expanded' => true,
'choices' => [
'label.replaceTags' => true,
'label.appendTags' => false,
]
]);
$builder->add('tags', TagsInputType::class, [
'required' => false,
]);
if ($options['include_user']) {
$builder->add('user', UserType::class, [
'required' => false,
]);
}
if ($options['include_exported']) {
$builder->add('exported', YesNoType::class, [
'label' => 'label.exported'
]);
}
$builder->add('entities', HiddenType::class, [
'required' => false,
]);
$builder->get('entities')->addModelTransformer(
new CallbackTransformer(
function ($timesheets) {
$ids = [];
/** @var \App\Entity\Timesheet $timesheet */
foreach ($timesheets as $timesheet) {
$ids[] = $timesheet->getId();
}
return implode(',', $ids);
},
function ($ids) {
if (empty($ids)) {
return [];
}
return $this->timesheet->matching((new Criteria())->where(Criteria::expr()->in('id', explode(',', $ids))));
}
)
);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => TimesheetMultiUpdateDTO::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'timesheet_multiupdate',
'include_user' => false,
'include_exported' => false,
]);
}
}

View File

@@ -0,0 +1,139 @@
<?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\MultiUpdate;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Tag;
use App\Entity\User;
use Doctrine\Common\Collections\ArrayCollection;
/**
* @App\Validator\Constraints\TimesheetMultiUpdate
*/
class TimesheetMultiUpdateDTO extends MultiUpdateTableDTO
{
/**
* @var Tag[]|ArrayCollection|iterable
*/
private $tags = [];
/**
* @var bool
*/
private $replaceTags = false;
/**
* @var Customer|null
*/
private $customer;
/**
* @var Project|null
*/
private $project;
/**
* @var Activity|null
*/
private $activity;
/**
* @var User|null
*/
private $user;
/**
* @var bool|null
*/
private $exported = null;
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(Customer $customer): TimesheetMultiUpdateDTO
{
$this->customer = $customer;
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(Project $project): TimesheetMultiUpdateDTO
{
$this->project = $project;
return $this;
}
public function getActivity(): ?Activity
{
return $this->activity;
}
public function setActivity(Activity $activity): TimesheetMultiUpdateDTO
{
$this->activity = $activity;
return $this;
}
/**
* @return Tag[]|ArrayCollection|iterable
*/
public function getTags(): iterable
{
return $this->tags;
}
public function setTags(iterable $tags): TimesheetMultiUpdateDTO
{
$this->tags = $tags;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(User $user): TimesheetMultiUpdateDTO
{
$this->user = $user;
return $this;
}
public function isExported(): ?bool
{
return $this->exported;
}
public function setExported(bool $exported): TimesheetMultiUpdateDTO
{
$this->exported = $exported;
return $this;
}
public function isReplaceTags(): bool
{
return $this->replaceTags;
}
public function setReplaceTags(bool $replaceTags): TimesheetMultiUpdateDTO
{
$this->replaceTags = $replaceTags;
return $this;
}
}

View File

@@ -103,6 +103,7 @@ abstract class AbstractToolbarForm extends AbstractType
'required' => false,
'placeholder' => null,
'label' => $label,
'search' => false
]);
}
@@ -110,6 +111,7 @@ abstract class AbstractToolbarForm extends AbstractType
{
$builder->add('pageSize', PageSizeType::class, [
'required' => false,
'search' => false
]);
}
@@ -218,6 +220,7 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
// TODO add a system setting to control if tags can be created on the fly
protected function addTagSelectField(FormBuilderInterface $builder)
{
$builder->add('tags', TagsSelectType::class, [
@@ -237,6 +240,7 @@ abstract class AbstractToolbarForm extends AbstractType
'label' => 'label.entryState',
'required' => false,
'placeholder' => null,
'search' => false,
'choices' => [
'entryState.all' => TimesheetQuery::STATE_ALL,
'entryState.running' => TimesheetQuery::STATE_RUNNING,
@@ -251,6 +255,7 @@ abstract class AbstractToolbarForm extends AbstractType
'label' => 'label.exported',
'required' => false,
'placeholder' => null,
'search' => false,
'choices' => [
'entryState.all' => TimesheetQuery::STATE_ALL,
'entryState.exported' => TimesheetQuery::STATE_EXPORTED,

View File

@@ -48,6 +48,14 @@ final class UserIdLoader implements LoaderInterface
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL u.{id}', 'preferences')
->from(User::class, 'u')
->leftJoin('u.preferences', 'preferences')
->andWhere($qb->expr()->in('u.id', $ids))
->getQuery()
->execute();
$teamIds = [];
foreach ($users as $user) {
foreach ($user->getTeams() as $team) {

View File

@@ -28,23 +28,14 @@ class UserQuery extends VisibilityQuery
]);
}
/**
* @return string|null
*/
public function getRole()
public function getRole(): ?string
{
return $this->role;
}
/**
* @param string|null $role
* @return UserQuery
*/
public function setRole($role)
public function setRole(?string $role): UserQuery
{
if (null === $role || false !== strpos($role, 'ROLE_')) {
$this->role = $role;
}
return $this;
}

View File

@@ -159,4 +159,25 @@ class TagRepository extends EntityRepository
return $qb;
}
/**
* @param Tag[] $tags
* @throws \Exception
*/
public function multiDelete(iterable $tags): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
foreach ($tags as $tag) {
$em->remove($tag);
}
$em->flush();
$em->commit();
} catch (\Exception $ex) {
$em->rollback();
throw $ex;
}
}
}

View File

@@ -67,6 +67,27 @@ class TimesheetRepository extends EntityRepository
$entityManager->flush();
}
/**
* @param Timesheet[] $timesheets
* @throws \Exception
*/
public function deleteMultiple(iterable $timesheets): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
foreach ($timesheets as $timesheet) {
$em->remove($timesheet);
}
$em->flush();
$em->commit();
} catch (\Exception $ex) {
$em->rollback();
throw $ex;
}
}
/**
* @param Timesheet $timesheet
* @throws \Doctrine\ORM\ORMException
@@ -79,6 +100,27 @@ class TimesheetRepository extends EntityRepository
$entityManager->flush();
}
/**
* @param Timesheet[] $timesheets
* @throws \Exception
*/
public function saveMultiple(array $timesheets): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
foreach ($timesheets as $timesheet) {
$em->persist($timesheet);
}
$em->flush();
$em->commit();
} catch (\Exception $ex) {
$em->rollback();
throw $ex;
}
}
/**
* @param Timesheet $entry
* @return bool

View File

@@ -0,0 +1,43 @@
<?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;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
* @Target({"CLASS", "PROPERTY", "METHOD", "ANNOTATION"})
*/
class TimesheetMultiUpdate extends Constraint
{
public const MISSING_ACTIVITY_ERROR = 'yd5hffg-dsfef3-426a-83d7-1f2d33hs5d84';
public const MISSING_PROJECT_ERROR = 'yd5hffg-dsfef3-426a-83d7-1f2d33hs5d85';
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'xy5hffg-dsfef3-426a-83d7-1f2d33hs5d86';
public const DISABLED_ACTIVITY_ERROR = 'yd5hffg-dsfef3-426a-83d7-1f2d33hs5d87';
public const DISABLED_PROJECT_ERROR = 'yd5hffg-dsfef3-426a-83d7-1f2d33hs5d88';
public const DISABLED_CUSTOMER_ERROR = 'yd5hffg-dsfef3-426a-83d7-1f2d33hs5d89';
protected static $errorNames = [
self::MISSING_ACTIVITY_ERROR => 'A timesheet must have an activity.',
self::MISSING_PROJECT_ERROR => 'A timesheet must have a project.',
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.',
self::DISABLED_CUSTOMER_ERROR => 'Cannot start a disabled customer.',
];
public $message = 'This form has invalid settings.';
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -0,0 +1,122 @@
<?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\Configuration\TimesheetConfiguration;
use App\Form\MultiUpdate\TimesheetMultiUpdateDTO;
use App\Validator\Constraints\TimesheetMultiUpdate as TimesheetConstraint;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class TimesheetMultiUpdateValidator extends ConstraintValidator
{
/**
* @var AuthorizationCheckerInterface
*/
protected $auth;
/**
* @var TimesheetConfiguration
*/
protected $configuration;
/**
* @param AuthorizationCheckerInterface $auth
* @param TimesheetConfiguration $configuration
*/
public function __construct(AuthorizationCheckerInterface $auth, TimesheetConfiguration $configuration)
{
$this->auth = $auth;
$this->configuration = $configuration;
}
/**
* @param TimesheetMultiUpdateDTO|mixed $value
* @param Constraint $constraint
*/
public function validate($value, Constraint $constraint)
{
if (!($constraint instanceof TimesheetConstraint)) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\TimesheetMultiUpdate');
}
if (!is_object($value) || !($value instanceof TimesheetMultiUpdateDTO)) {
return;
}
$this->validateActivityAndProject($value, $this->context);
}
/**
* @param TimesheetMultiUpdateDTO $dto
* @param ExecutionContextInterface $context
*/
protected function validateActivityAndProject(TimesheetMultiUpdateDTO $dto, ExecutionContextInterface $context)
{
$activity = $dto->getActivity();
$project = $dto->getProject();
// non global activity without project
if (null !== $activity && null !== $activity->getProject() && null === $project) {
$context->buildViolation('Missing project')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
->addViolation();
}
// only project was chosen
if (null === $activity && null !== $project) {
$context->buildViolation('Missing activity')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
->addViolation();
}
if (null !== $activity) {
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();
}
if (!$activity->isVisible()) {
$context->buildViolation('Cannot assign a disabled activity.')
->atPath('activity')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::DISABLED_ACTIVITY_ERROR)
->addViolation();
}
}
if (null !== $project) {
if (!$project->isVisible()) {
$context->buildViolation('Cannot assign a disabled project.')
->atPath('project')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::DISABLED_PROJECT_ERROR)
->addViolation();
}
if (!$project->getCustomer()->isVisible()) {
$context->buildViolation('Cannot assign a disabled customer.')
->atPath('customer')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::DISABLED_CUSTOMER_ERROR)
->addViolation();
}
}
}
}

View File

@@ -184,7 +184,7 @@
{{ macro.data_table_header_options(name, columns, {'skipStripped': skipStripped, 'reloadEvent': reloadEvent}) }}
{% endmacro %}
{% macro data_table_footer(entries, route) %}
{% macro data_table_footer(entries, route, multi_update_form) %}
</tbody>
</table>
</div>
@@ -192,6 +192,18 @@
</div>
</div>
</div>
{% if multi_update_form is not null %}
<div id="multi_update_form" style="display: none">
{{ form_start(multi_update_form, {'attr': {'data-question': 'update_multiple'|trans}}) }}
<label class="col-sm-1 col-xs-2 control-label" for="multi_update_table_action">
<span class="pull-left"><i class="fas fa-level-up-alt fa-2x fa-rotate-90"></i></span>
</label>
<div class="col-sm-11 col-xs-10">
{{ form_widget(multi_update_form.action) }}
</div>
{{ form_end(multi_update_form) }}
</div>
{% endif %}
{% if route is not empty and entries is not null%}
<div class="navigation text-center no-print">
{{ pagerfanta(entries, 'twitter_bootstrap3_translated', { proximity: 1, routeName: route }) }}
@@ -207,3 +219,11 @@
{{ widgets.form_type_value(metaField.type, metaField.value, entry) }}
{% endif %}
{% endmacro %}
{% macro datatable_multiupdate_all() %}
<input type="checkbox" id="multi_update_all" class="multiupdater">
{% endmacro %}
{% macro datatable_multiupdate_row(id) %}
<input type="checkbox" name="id" value="{{ id }}" class="multi_update_single multiupdater">
{% endmacro %}

View File

@@ -15,6 +15,7 @@
{% else %}
{% set columns = {
'id': {'class': 'alwaysVisible multiCheckbox', 'orderBy': false, 'title': false, 'html_before': tables.datatable_multiupdate_all()},
'name': '',
'amount': '',
'actions': 'actions alwaysVisible',
@@ -31,6 +32,7 @@
{% endif %}
<tr{% if manageAllowed %} class="modal-ajax-form open-edit" data-href="{{ path('tags_edit', {'id': tag.id}) }}"{% endif %}>
<td class="text-nowrap">{{ tables.datatable_multiupdate_row(tag.id) }}</td>
<td>{{ tag.name }}</td>
<td>{{ widgets.label(tag.amount, type) }}</td>
<td class="actions">
@@ -38,7 +40,7 @@
</td>
</tr>
{% endfor %}
{{ tables.data_table_footer(tags, 'tags_paginated') }}
{{ tables.data_table_footer(tags, 'tags_paginated', multiUpdateForm) }}
{% endif %}

View File

@@ -62,3 +62,14 @@
{{ widgets.entity_actions(event.payload.actions) }}
{% endif %}
{% endmacro %}
{% macro timesheets_team_multi_update(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% set actions = actions|merge({'back': path('admin_timesheet')}) %}
{% set actions = actions|merge({'help': {'url': 'timesheet.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.timesheets_team_multi_update', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}

View File

@@ -1,111 +1,16 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %}
{% import "macros/toolbar.html.twig" as toolbar %}
{% extends 'timesheet/layout-listing.html.twig' %}
{% import "timesheet-team/actions.html.twig" as actions %}
{% set tableName = 'timesheet_admin' %}
{% set columns = {
'date': {'class': 'alwaysVisible', 'orderBy': 'begin'},
} %}
{% if showStartEndTime %}
{% set columns = columns|merge({
'starttime': {'class': 'hidden-xs', 'orderBy': 'begin'},
'endtime': {'class': 'hidden-xs', 'orderBy': 'end'}
}) %}
{% endif %}
{% set columns = columns|merge({
'duration': '',
'rate': '',
'customer': 'hidden-xs hidden-sm hidden-md',
'project': 'hidden-xs hidden-sm hidden-md',
'activity': 'hidden-xs hidden-sm',
'description': 'hidden-xs hidden-sm',
'username': {'class': 'hidden-xs', 'orderBy': false},
'tags': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
}) %}
{% for field in metaColumns %}
{% set columns = columns|merge({
('mf_' ~ field.name): {'title': field.label, 'class': 'hidden-xs hidden-sm', 'orderBy': false}
}) %}
{% endfor %}
{% set columns = columns|merge({
'actions': 'actions alwaysVisible',
}) %}
{% set paginationRoute = 'admin_timesheet_paginated' %}
{% set editRoute = 'admin_timesheet_edit' %}
{% set canSeeRate = true %}
{% set canSeeUsername = true %}
{% set allowMarkdown = false %}
{% block page_title %}{{ 'admin_timesheet.title'|trans }}{% endblock %}
{% block page_search %}{{ toolbar.dropDownSearch(toolbarForm) }}{% endblock %}
{% block page_actions %}{{ actions.timesheets_team('index') }}{% endblock %}
{% block main_before %}
{{ tables.data_table_column_modal(tableName, columns) }}
{% endblock %}
{% block main %}
{% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.timesheetUpdate'}) }}
{% for entry in entries %}
<tr{% if is_granted('edit', entry) %} class="modal-ajax-form open-edit{% if not entry.end %} recording{% endif %}" data-href="{{ path('admin_timesheet_edit', {'id': entry.id}) }}"{% endif %}>
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td>
{% if showStartEndTime %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'starttime') }}">{{ entry.begin|time }}</td>
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'endtime') }}">
{% if entry.end %}
{{ entry.end|time }}
{% else %}
&dash;
{% endif %}
</td>
{% endif %}
{% if entry.end %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
{% else %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">
<i data-since="{{ entry.begin.format(constant('DATE_ISO8601')) }}" data-format="{{ get_format_duration() }}">{{ entry|duration }}</i>
</td>
{% endif %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'rate') }}">
{% if not entry.end or not is_granted('view_rate', entry) %}
&dash;
{% else %}
{{ entry.rate|money(entry.project.customer.currency) }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">
{{ widgets.label_customer(entry.project.customer) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{{ widgets.label_project(entry.project) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}">
{{ widgets.label_activity(entry.activity) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}">{{ entry.description|nl2br }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}">
{{ widgets.label_user(entry.user) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}">{{ widgets.tag_list(entry.tags) }}</td>
{% for field in metaColumns %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}">
{{ tables.datatable_meta_column(entry, field) }}
</td>
{% endfor %}
<td class="actions">
{% block row_action %}
{{- actions.timesheet_team(entry, 'index') -}}
</td>
</tr>
{% endfor %}
{{ tables.data_table_footer(entries, 'admin_timesheet_paginated') }}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% extends 'timesheet/layout-multi-update.html.twig' %}
{% block page_title %}{{ 'admin_timesheet.title'|trans }}{% endblock %}
{% block page_actions %}
{% import "timesheet-team/actions.html.twig" as actions %}
{{ actions.timesheets_team_multi_update('multi-update') }}
{% endblock %}

View File

@@ -64,3 +64,14 @@
{% endif %}
{%- endapply -%}
{% endmacro %}
{% macro timesheets_multi_update(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% set actions = actions|merge({'back': path('timesheet')}) %}
{% set actions = actions|merge({'help': {'url': 'timesheet.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.timesheets_multi_update', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}

View File

@@ -1,162 +1,16 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %}
{% import "macros/toolbar.html.twig" as toolbar %}
{% extends 'timesheet/layout-listing.html.twig' %}
{% import "timesheet/actions.html.twig" as actions %}
{% import _self as timesheet %}
{% set tableName = 'timesheet' %}
{% set paginationRoute = 'timesheet_paginated' %}
{% set editRoute = 'timesheet_edit' %}
{% set canSeeRate = is_granted('view_rate_own_timesheet') %}
{% set columns = {
'date': {'class': 'alwaysVisible', 'orderBy': 'begin'},
} %}
{% if showStartEndTime %}
{% set columns = columns|merge({
'starttime': {'class': '', 'orderBy': 'begin'},
'endtime': {'class': 'hidden-xs', 'orderBy': 'end'}
}) %}
{% endif %}
{% set columns = columns|merge({'duration': ''}) %}
{% if canSeeRate %}
{% set columns = columns|merge({'rate': 'hidden-xs'}) %}
{% endif %}
{% set columns = columns|merge({
'customer': 'hidden-xs hidden-sm hidden-md',
'project': 'hidden-xs hidden-sm hidden-md',
'activity': 'hidden-xs hidden-sm',
'description': 'hidden-xs hidden-sm',
'tags': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
}) %}
{% for field in metaColumns %}
{% set columns = columns|merge({
('mf_' ~ field.name): {'title': field.label, 'class': 'hidden-xs hidden-sm', 'orderBy': false}
}) %}
{% endfor %}
{% set columns = columns|merge({
'actions': 'actions alwaysVisible',
}) %}
{% set canSeeUsername = false %}
{% set allowMarkdown = true %}
{% block page_title %}{{ 'timesheet.title'|trans }}{% endblock %}
{% block page_search %}{{ toolbar.dropDownSearch(toolbarForm) }}{% endblock %}
{% block page_actions %}{{ actions.timesheets('index') }}{% endblock %}
{% block main_before %}
{{ tables.data_table_column_modal(tableName, columns) }}
{% endblock %}
{% block main %}
{% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {'striped': not showSummary, 'reload': 'kimai.timesheetUpdate'}) }}
{% set day = null %}
{% set dayDuration = 0 %}
{% set dayRate = {} %}
{% for entry in entries %}
{%- set customerCurrency = entry.project.customer.currency -%}
{%- if day is same as(null) -%}
{% set day = entry.begin|date_short %}
{% endif %}
{%- if showSummary and day is not same as(entry.begin|date_short) -%}
{{ timesheet.summary(day, dayDuration, dayRate, columns, canSeeRate, showStartEndTime, tableName, metaColumns) }}
{% set day = entry.begin|date_short %}
{% set dayDuration = 0 %}
{% set dayRate = {} %}
{%- endif -%}
<tr{% if is_granted('edit', entry) %} class="modal-ajax-form open-edit{% if not entry.end %} recording{% endif %}" data-href="{{ path('timesheet_edit', {'id': entry.id}) }}"{% endif %}>
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td>
{% if showStartEndTime %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'starttime') }}">{{ entry.begin|time }}</td>
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'endtime') }}">
{% if entry.end %}
{{ entry.end|time }}
{% else %}
&dash;
{% endif %}
</td>
{% endif %}
{% if entry.end %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
{% else %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">
<i data-since="{{ entry.begin.format(constant('DATE_ISO8601')) }}" data-format="{{ get_format_duration() }}">{{ entry|duration }}</i>
</td>
{% endif %}
{% if canSeeRate %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'rate') }}">
{% if not entry.end %}
&dash;
{% else %}
{{ entry.rate|money(entry.project.customer.currency) }}
{% endif %}
</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">{{ widgets.label_customer(entry.project.customer) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">{{ widgets.label_project(entry.project) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}">{{ widgets.label_activity(entry.activity) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">{{ entry.description|escape|desc2html }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}">{{ widgets.tag_list(entry.tags) }}</td>
{% for field in metaColumns %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}">
{{ tables.datatable_meta_column(entry, field) }}
</td>
{% endfor %}
<td class="actions">
{% block row_action %}
{{- actions.timesheet(entry, 'index') -}}
</td>
</tr>
{%- if entry.end -%}
{% if dayRate[customerCurrency] is not defined %}
{% set dayRate = dayRate|merge({(customerCurrency): 0}) %}
{% endif %}
{% set dayRate = dayRate|merge({(customerCurrency): dayRate[customerCurrency] + entry.rate}) %}
{%- endif -%}
{%- set dayDuration = dayDuration + entry.duration -%}
{% endfor %}
{% if showSummary %}
{{ timesheet.summary(day, dayDuration, dayRate, columns, canSeeRate, showStartEndTime, tableName, metaColumns) }}
{% endif %}
{{ tables.data_table_footer(entries, 'timesheet_paginated') }}
{% endif %}
{% endblock %}
{% macro summary(day, duration, dayRates, columns, canSeeRate, showStartEndTime, tableName, metaColumns) %}
{% import "macros/datatables.html.twig" as tables %}
<tr class="summary info">
<td class="text-nowrap">{{ day }}</td>
{% if showStartEndTime %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'starttime') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}"></td>
{% endif %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ duration|duration }}</td>
{% if canSeeRate %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'rate') }}">
{% for currency, rate in dayRates %}
{{ rate|money(currency) }}
{% if not loop.last %}
<br>
{% endif %}
{% endfor %}
</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}"></td>
{% for field in metaColumns %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}"></td>
{% endfor %}
<td class="actions"></td>
</tr>
{% endmacro %}

View File

@@ -0,0 +1,191 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %}
{% import "macros/toolbar.html.twig" as toolbar %}
{% set showSummary = showSummary|default(false) %}
{% set showStartEndTime = showStartEndTime|default(true) %}
{% set columns = {
'id': {'class': 'alwaysVisible multiCheckbox', 'orderBy': false, 'title': false, 'html_before': tables.datatable_multiupdate_all()},
'date': {'class': 'alwaysVisible', 'orderBy': 'begin'},
} %}
{% if showStartEndTime %}
{% set columns = columns|merge({
'starttime': {'class': 'hidden-xs', 'orderBy': 'begin'},
'endtime': {'class': 'hidden-xs', 'orderBy': 'end'}
}) %}
{% endif %}
{% set columns = columns|merge({'duration': ''}) %}
{% if canSeeRate %}
{% set columns = columns|merge({'rate': ''}) %}
{% endif %}
{% set columns = columns|merge({
'customer': 'hidden-xs hidden-sm hidden-md',
'project': 'hidden-xs hidden-sm hidden-md',
'activity': 'hidden-xs hidden-sm',
'description': 'hidden-xs hidden-sm',
}) %}
{% if canSeeUsername %}
{% set columns = columns|merge({
'username': {'class': 'hidden-xs', 'orderBy': false},
}) %}
{% endif %}
{% set columns = columns|merge({
'tags': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
}) %}
{% for field in metaColumns %}
{% set columns = columns|merge({
('mf_' ~ field.name): {'title': field.label, 'class': 'hidden-xs hidden-sm', 'orderBy': false}
}) %}
{% endfor %}
{% set columns = columns|merge({
'actions': 'actions alwaysVisible',
}) %}
{% block page_search %}{{ toolbar.dropDownSearch(toolbarForm) }}{% endblock %}
{% block main_before %}
{{ tables.data_table_column_modal(tableName, columns) }}
{% endblock %}
{% block main %}
{% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {'striped': not showSummary, 'reload': 'kimai.timesheetUpdate'}) }}
{% set day = null %}
{% set dayDuration = 0 %}
{% set dayRate = {} %}
{% for entry in entries %}
{%- set customerCurrency = entry.project.customer.currency -%}
{%- if day is same as(null) -%}
{% set day = entry.begin|date_short %}
{% endif %}
{%- if showSummary and day is not same as(entry.begin|date_short) -%}
{{ _self.summary(day, dayDuration, dayRate, columns, canSeeRate, canSeeUsername, showStartEndTime, tableName, metaColumns) }}
{% set day = entry.begin|date_short %}
{% set dayDuration = 0 %}
{% set dayRate = {} %}
{%- endif -%}
<tr{% if is_granted('edit', entry) %} class="modal-ajax-form open-edit{% if not entry.end %} recording{% endif %}" data-href="{{ path(editRoute, {'id': entry.id}) }}"{% endif %}>
<td class="text-nowrap">{{ tables.datatable_multiupdate_row(entry.id) }}</td>
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td>
{% if showStartEndTime %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'starttime') }}">{{ entry.begin|time }}</td>
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'endtime') }}">
{% if entry.end %}
{{ entry.end|time }}
{% else %}
&dash;
{% endif %}
</td>
{% endif %}
{% if entry.end %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
{% else %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">
<i data-since="{{ entry.begin.format(constant('DATE_ISO8601')) }}" data-format="{{ get_format_duration() }}">{{ entry|duration }}</i>
</td>
{% endif %}
{% if canSeeRate %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'rate') }}">
{% if not entry.end or not is_granted('view_rate', entry) %}
&dash;
{% else %}
{{ entry.rate|money(entry.project.customer.currency) }}
{% endif %}
</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">
{{ widgets.label_customer(entry.project.customer) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{{ widgets.label_project(entry.project) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}">
{{ widgets.label_activity(entry.activity) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">
{% if allowMarkdown %}
{{ entry.description|escape|desc2html }}
{% else %}
{{ entry.description|nl2br }}
{% endif %}
</td>
{% if canSeeUsername %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}">
{{ widgets.label_user(entry.user) }}
</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}">{{ widgets.tag_list(entry.tags) }}</td>
{% for field in metaColumns %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}">
{{ tables.datatable_meta_column(entry, field) }}
</td>
{% endfor %}
<td class="actions">
{{ block('row_action') }}
</td>
</tr>
{%- if entry.end -%}
{% if dayRate[customerCurrency] is not defined %}
{% set dayRate = dayRate|merge({(customerCurrency): 0}) %}
{% endif %}
{% set dayRate = dayRate|merge({(customerCurrency): dayRate[customerCurrency] + entry.rate}) %}
{%- endif -%}
{%- set dayDuration = dayDuration + entry.duration -%}
{% endfor %}
{% if showSummary %}
{{ _self.summary(day, dayDuration, dayRate, columns, canSeeRate, canSeeUsername, showStartEndTime, tableName, metaColumns) }}
{% endif %}
{{ tables.data_table_footer(entries, paginationRoute, multiUpdateForm) }}
{% endif %}
{% endblock %}
{% macro summary(day, duration, dayRates, columns, canSeeRate, canSeeUsername, showStartEndTime, tableName, metaColumns) %}
{% import "macros/datatables.html.twig" as tables %}
<tr class="summary info">
<td></td>
<td class="text-nowrap">{{ day }}</td>
{% if showStartEndTime %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'starttime') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}"></td>
{% endif %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ duration|duration }}</td>
{% if canSeeRate %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'rate') }}">
{% for currency, rate in dayRates %}
{{ rate|money(currency) }}
{% if not loop.last %}
<br>
{% endif %}
{% endfor %}
</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}"></td>
{% if canSeeUsername %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}"></td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}"></td>
{% for field in metaColumns %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}"></td>
{% endfor %}
<td class="actions"></td>
</tr>
{% endmacro %}

View File

@@ -0,0 +1,13 @@
{% extends 'base.html.twig' %}
{% block main %}
{% set formEditTemplate = 'default/_form.html.twig' %}
{% set formOptions = {
'title': 'update_multiple'|trans({'%action%': 'action.edit'|trans, '%count%': dto.entities|length}),
'form': form,
} %}
{% embed formEditTemplate with formOptions %}
{% endembed %}
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% extends 'timesheet/layout-multi-update.html.twig' %}
{% block page_title %}{{ 'timesheet.title'|trans }}{% endblock %}
{% block page_actions %}
{% import "timesheet/actions.html.twig" as actions %}
{{ actions.timesheets_multi_update('multi-update') }}
{% endblock %}

View File

@@ -9,6 +9,7 @@
namespace App\Tests\Controller;
use App\Entity\Tag;
use App\Entity\User;
use App\Tests\DataFixtures\TagFixtures;
@@ -95,4 +96,35 @@ class TagControllerTest extends ControllerBaseTest
$editForm = $client->getCrawler()->filter('form[name=tag_edit_form]')->form();
$this->assertEquals('Test 2 updated', $editForm->get('tag_edit_form[name]')->getValue());
}
public function testMultiDeleteAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->assertAccessIsGranted($client, '/admin/tags/');
$form = $client->getCrawler()->filter('form[name=multi_update_table]')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/admin/tags/multi-delete'));
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Tag[] $tags */
$tags = $em->getRepository(Tag::class)->findAll();
self::assertCount(10, $tags);
$ids = [];
foreach ($tags as $tag) {
$ids[] = $tag->getId();
}
$client->submit($form, [
'multi_update_table' => [
'action' => $this->createUrl('/admin/tags/multi-delete'),
'entities' => implode(',', $ids)
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/tags/'));
$client->followRedirect();
$em->clear(Tag::class);
self::assertEquals(0, $em->getRepository(Tag::class)->count([]));
}
}

View File

@@ -324,4 +324,98 @@ class TimesheetControllerTest extends ControllerBaseTest
$timesheet = $em->getRepository(Timesheet::class)->find(1);
$this->assertEquals('foo-bar', $timesheet->getDescription());
}
public function testMultiDeleteAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$user = $this->getUserByRole($em, User::ROLE_USER);
$fixture = new TimesheetFixtures();
$fixture->setAmount(10);
$fixture->setUser($user);
$this->importFixture($em, $fixture);
$this->assertAccessIsGranted($client, '/timesheet/');
$form = $client->getCrawler()->filter('form[name=multi_update_table]')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/timesheet/multi-delete'));
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Timesheet[] $timesheets */
$timesheets = $em->getRepository(Timesheet::class)->findAll();
self::assertCount(10, $timesheets);
$ids = [];
foreach ($timesheets as $timesheet) {
$ids[] = $timesheet->getId();
}
$client->submit($form, [
'multi_update_table' => [
'action' => $this->createUrl('/timesheet/multi-delete'),
'entities' => implode(',', $ids)
]
]);
$this->assertIsRedirect($client, $this->createUrl('/timesheet/'));
$client->followRedirect();
$em->clear(Timesheet::class);
self::assertEquals(0, $em->getRepository(Timesheet::class)->count([]));
}
public function testMultiUpdate()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$user = $this->getUserByRole($em, User::ROLE_SUPER_ADMIN);
$fixture = new TimesheetFixtures();
$fixture->setAmount(10);
$fixture->setUser($user);
$this->importFixture($em, $fixture);
$this->assertAccessIsGranted($client, '/timesheet/');
$form = $client->getCrawler()->filter('form[name=multi_update_table]')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/timesheet/multi-update'));
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Timesheet[] $timesheets */
$timesheets = $em->getRepository(Timesheet::class)->findAll();
self::assertCount(10, $timesheets);
$ids = [];
foreach ($timesheets as $timesheet) {
self::assertEmpty($timesheet->getTags());
self::assertFalse($timesheet->isExported());
$ids[] = $timesheet->getId();
}
$client->submit($form, [
'multi_update_table' => [
'action' => $this->createUrl('/timesheet/multi-update'),
'entities' => implode(',', $ids)
]
]);
$this->assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('form[name=timesheet_multi_update]')->form();
$client->submit($form, [
'timesheet_multi_update' => [
'exported' => true,
'tags' => 'test, foo-bar'
]
]);
$em->clear(Timesheet::class);
/** @var Timesheet[] $timesheets */
$timesheets = $em->getRepository(Timesheet::class)->findAll();
self::assertCount(10, $timesheets);
foreach ($timesheets as $timesheet) {
self::assertCount(2, $timesheet->getTags());
self::assertTrue($timesheet->isExported());
}
}
}

View File

@@ -240,4 +240,102 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
$this->assertEquals('foo-bar', $timesheet->getDescription());
$this->assertEquals($teamlead->getId(), $timesheet->getUser()->getId());
}
public function testMultiDeleteAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$user = $this->getUserByRole($em, User::ROLE_TEAMLEAD);
$fixture = new TimesheetFixtures();
$fixture->setAmount(10);
$fixture->setUser($user);
$this->importFixture($em, $fixture);
$this->assertAccessIsGranted($client, '/team/timesheet/');
$form = $client->getCrawler()->filter('form[name=multi_update_table]')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/team/timesheet/multi-delete'));
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Timesheet[] $timesheets */
$timesheets = $em->getRepository(Timesheet::class)->findAll();
self::assertCount(10, $timesheets);
$ids = [];
foreach ($timesheets as $timesheet) {
$ids[] = $timesheet->getId();
}
$client->submit($form, [
'multi_update_table' => [
'action' => $this->createUrl('/team/timesheet/multi-delete'),
'entities' => implode(',', $ids)
]
]);
$this->assertIsRedirect($client, $this->createUrl('/team/timesheet/'));
$client->followRedirect();
$em->clear(Timesheet::class);
self::assertEquals(0, $em->getRepository(Timesheet::class)->count([]));
}
public function testMultiUpdate()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$user = $this->getUserByRole($em, User::ROLE_TEAMLEAD);
$fixture = new TimesheetFixtures();
$fixture->setAmount(10);
$fixture->setUser($user);
$this->importFixture($em, $fixture);
$this->assertAccessIsGranted($client, '/team/timesheet/');
$form = $client->getCrawler()->filter('form[name=multi_update_table]')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/team/timesheet/multi-update'));
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Timesheet[] $timesheets */
$timesheets = $em->getRepository(Timesheet::class)->findAll();
self::assertCount(10, $timesheets);
$ids = [];
foreach ($timesheets as $timesheet) {
self::assertFalse($timesheet->isExported());
self::assertEquals($user->getId(), $timesheet->getUser()->getId());
$ids[] = $timesheet->getId();
}
$client->submit($form, [
'multi_update_table' => [
'action' => $this->createUrl('/team/timesheet/multi-update'),
'entities' => implode(',', $ids)
]
]);
$this->assertTrue($client->getResponse()->isSuccessful());
$newUser = $this->getUserByRole($em, User::ROLE_USER);
$form = $client->getCrawler()->filter('form[name=timesheet_multi_update]')->form();
$client->submit($form, [
'timesheet_multi_update' => [
'user' => $newUser->getId(),
'exported' => true,
'replaceTags' => true,
'tags' => 'test, foo-bar, tralalala'
]
]);
$em->clear(Timesheet::class);
/** @var Timesheet[] $timesheets */
$timesheets = $em->getRepository(Timesheet::class)->findAll();
self::assertCount(10, $timesheets);
foreach ($timesheets as $timesheet) {
self::assertCount(3, $timesheet->getTags());
self::assertEquals($newUser->getId(), $timesheet->getUser()->getId());
self::assertTrue($timesheet->isExported());
}
}
}

View File

@@ -164,9 +164,9 @@ class UserControllerTest extends ControllerBaseTest
$this->assertHasFlashDeleteSuccess($client);
// SQLIte does not necessarly support onCascade delete, so these timesheet will stay after deletion
// $em->clear();
// $timesheets = $em->getRepository(Timesheet::class)->findAll();
// $this->assertEquals(0, count($timesheets));
// $em->clear(Timesheet::class);
// $timesheets = $em->getRepository(Timesheet::class)->count([]);
// $this->assertEquals(0, $timesheets);
$this->request($client, '/admin/user/' . $user->getId() . '/edit');
$this->assertFalse($client->getResponse()->isSuccessful());

View File

@@ -0,0 +1,51 @@
<?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\MultiUpdate;
use App\Form\MultiUpdate\MultiUpdateTableDTO;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Form\MultiUpdate\MultiUpdateTableDTO
*/
class MultiUpdateTableDTOTest extends TestCase
{
public function testDefaultValues()
{
$sut = new MultiUpdateTableDTO();
self::assertEmpty($sut->getEntities());
self::assertEquals(['' => ''], $sut->getActions());
self::assertNull($sut->getAction());
}
public function testSetterAndGetter()
{
$sut = new MultiUpdateTableDTO();
self::assertInstanceOf(MultiUpdateTableDTO::class, $sut->addUpdate('foo'));
self::assertInstanceOf(MultiUpdateTableDTO::class, $sut->addDelete('bar'));
self::assertInstanceOf(MultiUpdateTableDTO::class, $sut->addAction('test', 'hello/world'));
self::assertEquals(
[
'' => '',
'action.edit' => 'foo',
'action.delete' => 'bar',
'test' => 'hello/world'
],
$sut->getActions()
);
self::assertInstanceOf(MultiUpdateTableDTO::class, $sut->setAction('sdfsdfsdf'));
self::assertEquals('sdfsdfsdf', $sut->getAction());
self::assertInstanceOf(MultiUpdateTableDTO::class, $sut->setEntities([1, 2, 3, 4, 5, 6, 7, 8, 9, '0815']));
self::assertEquals([1, 2, 3, 4, 5, 6, 7, 8, 9, '0815'], $sut->getEntities());
}
}

View File

@@ -0,0 +1,90 @@
<?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\MultiUpdate;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\User;
use App\Form\MultiUpdate\TimesheetMultiUpdateDTO;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Form\MultiUpdate\TimesheetMultiUpdateDTO
*/
class TimesheetMultiUpdateDTOTest extends TestCase
{
public function testDefaultValues()
{
$sut = new TimesheetMultiUpdateDTO();
self::assertEmpty($sut->getEntities());
self::assertEquals(['' => ''], $sut->getActions());
self::assertNull($sut->getAction());
self::assertNull($sut->isExported());
self::assertNull($sut->getProject());
self::assertNull($sut->getAction());
self::assertNull($sut->getCustomer());
self::assertEquals([], $sut->getTags());
self::assertNull($sut->getUser());
self::assertFalse($sut->isReplaceTags());
}
public function testSetterAndGetter()
{
$sut = new TimesheetMultiUpdateDTO();
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->addUpdate('foo'));
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->addDelete('bar'));
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->addAction('test', 'hello/world'));
self::assertEquals(
[
'' => '',
'action.edit' => 'foo',
'action.delete' => 'bar',
'test' => 'hello/world'
],
$sut->getActions()
);
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->setAction('sdfsdfsdf'));
self::assertEquals('sdfsdfsdf', $sut->getAction());
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->setEntities([1, 2, 3, 4, 5, 6, 7, 8, 9, '0815']));
self::assertEquals([1, 2, 3, 4, 5, 6, 7, 8, 9, '0815'], $sut->getEntities());
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->setExported(true));
self::assertTrue($sut->isExported());
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->setExported(false));
self::assertFalse($sut->isExported());
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->setTags(['foo', '0815']));
self::assertEquals(['foo', '0815'], $sut->getTags());
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->setReplaceTags(true));
self::assertTrue($sut->isReplaceTags());
$user = (new User())->setUsername('sdfsdfsd');
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->setUser($user));
self::assertSame($user, $sut->getUser());
$activity = (new Activity())->setName('sdfsdfsd');
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->setActivity($activity));
self::assertSame($activity, $sut->getActivity());
$project = (new Project())->setName('sdfsdfsd');
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->setProject($project));
self::assertSame($project, $sut->getProject());
$customer = (new Customer())->setName('sdfsdfsd');
self::assertInstanceOf(TimesheetMultiUpdateDTO::class, $sut->setCustomer($customer));
self::assertSame($customer, $sut->getCustomer());
}
}

View File

@@ -30,10 +30,6 @@ class UserQueryTest extends BaseQueryTest
protected function assertRole(UserQuery $sut)
{
$this->assertNull($sut->getRole());
$sut->setRole('foo-bar');
$this->assertNull($sut->getRole());
$sut->setRole('ROLE_USER');
$this->assertEquals('ROLE_USER', $sut->getRole());
}

View File

@@ -60,6 +60,10 @@
<source>my.profile</source>
<target>Mein Profil</target>
</trans-unit>
<trans-unit id="update_multiple">
<source>update_multiple</source>
<target>%action% von %count% Einträgen?</target>
</trans-unit>
<!--
Login / Security
@@ -404,6 +408,14 @@
<source>label.color</source>
<target>Farbe</target>
</trans-unit>
<trans-unit id="label.replaceTags">
<source>label.replaceTags</source>
<target>Schlagworte ersetzen</target>
</trans-unit>
<trans-unit id="label.appendTags">
<source>label.appendTags</source>
<target>Schlagworte hinzufügen</target>
</trans-unit>
<!--
User profile

View File

@@ -60,6 +60,10 @@
<source>my.profile</source>
<target>My profile</target>
</trans-unit>
<trans-unit id="update_multiple">
<source>update_multiple</source>
<target>%action% %count% entries?</target>
</trans-unit>
<!--
Login / Security
@@ -404,6 +408,14 @@
<source>label.color</source>
<target>Color</target>
</trans-unit>
<trans-unit id="label.replaceTags">
<source>label.replaceTags</source>
<target>Replace tags</target>
</trans-unit>
<trans-unit id="label.appendTags">
<source>label.appendTags</source>
<target>Append tags</target>
</trans-unit>
<!--
User profile

View File

@@ -6915,10 +6915,10 @@ svgo@^1.0.0:
unquote "~1.1.1"
util.promisify "~1.0.0"
sweetalert2@^8.11.7:
version "8.14.1"
resolved "https://registry.yarnpkg.com/sweetalert2/-/sweetalert2-8.14.1.tgz#591bb63056198f28c7118dde105ba4ae7a24e612"
integrity sha512-5kL0iJYG2LP62xPqPuwDwpDWeTKD4AEvV7e5OM2q794F3ko/yFyPHzOGjnRGa3SqkS8U9syt5OvZyLZ27nGMog==
sweetalert2@^9.3:
version "9.3.4"
resolved "https://registry.yarnpkg.com/sweetalert2/-/sweetalert2-9.3.4.tgz#2fc1fcd8538ea6613cbc80bc49e6d13f1b0150f6"
integrity sha512-nyW+SAZUkiKpbKSDE33J9nF5UQnc7IGC1eDHeW80HkhsggvkmQN0Clq7PHXb1roLkBLJryb2evVLDc9jZmptRQ==
syntax-error@^1.1.1:
version "1.4.0"