0
0
Fork 0
mirror of https://github.com/kevinpapst/kimai2.git synced 2025-03-17 06:22:38 +00:00

prepare release 1.13 ()

* make voters a final class
* upgrade dependencies
* sort project alphabetically in dashboard widget
* open detail page on row click
* do not break on null tag name
* added max height to scrollable widgets on dashboard
* added timesheet duplicate event
* allow to deactivate browser title update
* improve comment box
* moved role permissions to own menu
* removed tabs in user screen
* fix user can remove super-admin from own account
This commit is contained in:
Kevin Papst 2021-02-01 23:43:47 +01:00 committed by GitHub
parent a034b3519e
commit 8d41fa20bd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 693 additions and 232 deletions

View file

@ -21,7 +21,7 @@ jobs:
extensions: mbstring, xml, ctype, iconv, intl, pdo_sqlite, mysql, zip, gd, ldap
tools: cs2pr:1.1.0
- run: composer install --no-progress
- run: composer validate --no-check-all --strict
- run: composer validate --strict
- run: vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php_cs.dist --using-cache=no --show-progress=none --format=checkstyle | cs2pr
- run: vendor/bin/phpstan analyse src -c phpstan.neon --level=5 --no-progress --error-format=checkstyle | cs2pr
- run: vendor/bin/phpstan analyse tests -c tests/phpstan.neon --level=5 --no-progress --error-format=checkstyle | cs2pr

View file

@ -1,5 +1,5 @@
unreleased=true
future-release=1.12
future-release=1.13
exclude-labels=duplicate,support,question,invalid,wontfix,release,waiting for feedback
enhancement_labels=>enhancement,Enhancement,feature request,translation i18n,technical debt,documentation
issues-wo-labels=false

View file

@ -1,5 +1,8 @@
# Changelog
## [1.13](https://github.com/kevinpapst/kimai2/tree/1.13)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.12...1.13)
## [1.12](https://github.com/kevinpapst/kimai2/tree/1.12)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.11.1...1.12)

View file

@ -7,8 +7,8 @@ As announced in the [README](README.md) I only support the latest available rele
| Version | Supported |
| ------- | ------------------ |
| master | :white_check_mark: |
| 1.12 | :white_check_mark: |
| < 1.12 | :x: |
| 1.13 | :white_check_mark: |
| < 1.13 | :x: |
## Reporting a Vulnerability

View file

@ -24,6 +24,7 @@ export default class KimaiActiveRecordsDuration extends KimaiPlugin {
}
init() {
this.updateBrowserTitle = !!this.getConfiguration('updateBrowserTitle');
this.updateRecords();
const self = this;
const handle = function() { self.updateRecords(); };
@ -41,7 +42,9 @@ export default class KimaiActiveRecordsDuration extends KimaiPlugin {
const activeRecords = document.querySelectorAll(this.selector);
if (activeRecords.length === 0) {
document.title = document.querySelector('body').dataset['title'];
if (this.updateBrowserTitle) {
document.title = document.querySelector('body').dataset['title'];
}
return;
}
@ -59,6 +62,10 @@ export default class KimaiActiveRecordsDuration extends KimaiPlugin {
return;
}
if (!this.updateBrowserTitle) {
return;
}
let title = durations.shift();
let prefix = ' | ';

View file

@ -13,4 +13,8 @@ section {
}
}
}
.box-body-scrollable {
overflow: auto;
max-height: 300px;
}
}

View file

@ -40,7 +40,7 @@
"nelmio/cors-bundle": "^1.5",
"onelogin/php-saml": "^3.4",
"pagerfanta/pagerfanta": "^2.1",
"phpoffice/phpspreadsheet": "^1.10",
"phpoffice/phpspreadsheet": "^1.16",
"phpoffice/phpword": "^0.17",
"psr/log": "^1.1",
"sensio/framework-extra-bundle": "^5.2",
@ -66,6 +66,7 @@
"symfony/serializer": "^4.4",
"symfony/translation": "^4.4",
"symfony/twig-bundle": "^4.4",
"symfony/uid": "^5.0",
"symfony/validator": "^4.4",
"symfony/webpack-encore-bundle": "^1.5",
"symfony/yaml": "^4.4",
@ -132,7 +133,6 @@
"symfony/polyfill-php54": "*"
},
"suggest": {
"ext-mbstring": "If ext-mbstring is not available you MUST install symfony/polyfill-mbstring",
"laminas/laminas-ldap": "For LDAP authentication with Kimai"
},
"scripts": {

254
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "a0d2193a20b2d2620cda4d347f5160f0",
"content-hash": "7931f435fc1cfba7a09fe9d65369984e",
"packages": [
{
"name": "beberlei/doctrineextensions",
@ -1680,6 +1680,60 @@
"description": "A php library to manipulate Swagger specifications",
"time": "2018-07-27T06:40:00+00:00"
},
{
"name": "ezyang/htmlpurifier",
"version": "v4.13.0",
"source": {
"type": "git",
"url": "https://github.com/ezyang/htmlpurifier.git",
"reference": "08e27c97e4c6ed02f37c5b2b20488046c8d90d75"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/08e27c97e4c6ed02f37c5b2b20488046c8d90d75",
"reference": "08e27c97e4c6ed02f37c5b2b20488046c8d90d75",
"shasum": ""
},
"require": {
"php": ">=5.2"
},
"require-dev": {
"simpletest/simpletest": "dev-master#72de02a7b80c6bb8864ef9bf66d41d2f58f826bd"
},
"type": "library",
"autoload": {
"psr-0": {
"HTMLPurifier": "library/"
},
"files": [
"library/HTMLPurifier.composer.php"
],
"exclude-from-classmap": [
"/library/HTMLPurifier/Language/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "Edward Z. Yang",
"email": "admin@htmlpurifier.org",
"homepage": "http://ezyang.com"
}
],
"description": "Standards compliant HTML filter written in PHP",
"homepage": "http://htmlpurifier.org/",
"keywords": [
"html"
],
"support": {
"issues": "https://github.com/ezyang/htmlpurifier/issues",
"source": "https://github.com/ezyang/htmlpurifier/tree/master"
},
"time": "2020-06-29T00:56:53+00:00"
},
{
"name": "friendsofsymfony/rest-bundle",
"version": "3.0.3",
@ -3375,16 +3429,16 @@
},
{
"name": "myclabs/php-enum",
"version": "1.7.6",
"version": "1.7.7",
"source": {
"type": "git",
"url": "https://github.com/myclabs/php-enum.git",
"reference": "5f36467c7a87e20fbdc51e524fd8f9d1de80187c"
"reference": "d178027d1e679832db9f38248fcc7200647dc2b7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/php-enum/zipball/5f36467c7a87e20fbdc51e524fd8f9d1de80187c",
"reference": "5f36467c7a87e20fbdc51e524fd8f9d1de80187c",
"url": "https://api.github.com/repos/myclabs/php-enum/zipball/d178027d1e679832db9f38248fcc7200647dc2b7",
"reference": "d178027d1e679832db9f38248fcc7200647dc2b7",
"shasum": ""
},
"require": {
@ -3417,7 +3471,21 @@
"keywords": [
"enum"
],
"time": "2020-02-14T08:15:52+00:00"
"support": {
"issues": "https://github.com/myclabs/php-enum/issues",
"source": "https://github.com/myclabs/php-enum/tree/1.7.7"
},
"funding": [
{
"url": "https://github.com/mnapoli",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum",
"type": "tidelift"
}
],
"time": "2020-11-14T18:14:52+00:00"
},
{
"name": "nelmio/api-doc-bundle",
@ -4151,16 +4219,16 @@
},
{
"name": "phpoffice/phpspreadsheet",
"version": "1.15.0",
"version": "1.16.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "a8e8068b31b8119e1daa5b1eb5715a3a8ea8305f"
"reference": "76d4323b85129d0c368149c831a07a3e258b2b50"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/a8e8068b31b8119e1daa5b1eb5715a3a8ea8305f",
"reference": "a8e8068b31b8119e1daa5b1eb5715a3a8ea8305f",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/76d4323b85129d0c368149c831a07a3e258b2b50",
"reference": "76d4323b85129d0c368149c831a07a3e258b2b50",
"shasum": ""
},
"require": {
@ -4177,10 +4245,11 @@
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"ezyang/htmlpurifier": "^4.13",
"maennchen/zipstream-php": "^2.1",
"markbaker/complex": "^1.5|^2.0",
"markbaker/matrix": "^1.2|^2.0",
"php": "^7.2|^8.0",
"markbaker/complex": "^1.5||^2.0",
"markbaker/matrix": "^1.2||^2.0",
"php": "^7.2||^8.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/simple-cache": "^1.0"
@ -4191,7 +4260,7 @@
"jpgraph/jpgraph": "^4.0",
"mpdf/mpdf": "^8.0",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^8.5|^9.3",
"phpunit/phpunit": "^8.5||^9.3",
"squizlabs/php_codesniffer": "^3.5",
"tecnickcom/tcpdf": "^6.3"
},
@ -4243,7 +4312,11 @@
"xls",
"xlsx"
],
"time": "2020-10-11T13:20:59+00:00"
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.16.0"
},
"time": "2020-12-31T18:03:49+00:00"
},
{
"name": "phpoffice/phpword",
@ -8078,6 +8151,85 @@
],
"time": "2020-10-23T14:02:19+00:00"
},
{
"name": "symfony/polyfill-uuid",
"version": "v1.22.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-uuid.git",
"reference": "17e0611d2e180a91d02b4fa8b03aab0368b661bc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/17e0611d2e180a91d02b4fa8b03aab0368b661bc",
"reference": "17e0611d2e180a91d02b4fa8b03aab0368b661bc",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"suggest": {
"ext-uuid": "For best performance"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.22-dev"
},
"thanks": {
"name": "symfony/polyfill",
"url": "https://github.com/symfony/polyfill"
}
},
"autoload": {
"psr-4": {
"Symfony\\Polyfill\\Uuid\\": ""
},
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Grégoire Pineau",
"email": "lyrixx@lyrixx.info"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for uuid functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"uuid"
],
"support": {
"source": "https://github.com/symfony/polyfill-uuid/tree/v1.22.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2021-01-07T16:49:33+00:00"
},
{
"name": "symfony/postmark-mailer",
"version": "v4.4.16",
@ -9490,6 +9642,76 @@
],
"time": "2020-10-24T11:50:19+00:00"
},
{
"name": "symfony/uid",
"version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/uid.git",
"reference": "7085124d58b662d3fdfb1f7d2dde6c5659656aa4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/uid/zipball/7085124d58b662d3fdfb1f7d2dde6c5659656aa4",
"reference": "7085124d58b662d3fdfb1f7d2dde6c5659656aa4",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
"symfony/polyfill-uuid": "^1.15"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Uid\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Grégoire Pineau",
"email": "lyrixx@lyrixx.info"
},
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony Uid component",
"homepage": "https://symfony.com",
"keywords": [
"UID",
"uuid"
],
"support": {
"source": "https://github.com/symfony/uid/tree/v5.2.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2020-12-15T09:12:47+00:00"
},
{
"name": "symfony/validator",
"version": "v4.4.16",
@ -13343,5 +13565,5 @@
"platform-overrides": {
"php": "7.2.9"
},
"plugin-api-version": "1.1.0"
"plugin-api-version": "2.0.0"
}

View file

@ -11,6 +11,8 @@ doctrine:
default_connection: default
connections:
default:
# existing migrations will fail if the schema filter is activated
#schema_filter: ~^(?!(bundle_migration_|kimai2_sessions))~
url: '%env(resolve:DATABASE_URL)%'
driver: 'pdo_mysql'
# this setting prevents automatic database detection and finds a lot of false-negatives on doctrine:migrations:diff

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.098eaae1.js",
"build/0.79dbdbb9.js",
"build/1.32489d92.js",
"build/app.4f01ca95.js"
"build/app.19a715ed.js"
],
"css": [
"build/app.856a8108.css"
"build/app.cafebaaa.css"
]
},
"invoice": {
@ -53,8 +53,8 @@
"build/runtime.098eaae1.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.79dbdbb9.js": "sha384-U2Ao0ORAZ8PCeDmyRsqQFET3hc7pfUBimq0PrqFdG4/s0Bdi+qBj4TJK3o70bCd5",
"build/1.32489d92.js": "sha384-wVkjh5FzjFhMV4S4uNP23E/OLBOf+Zi7t3lpm9eWzoMr/tm2pydT+q0Op1XHuoUP",
"build/app.4f01ca95.js": "sha384-4gnIIZ7TRMm2k6gumDyHoJMiNcEtr7Swtd0i8s8HAiwExFTqb0hrAhP7s4I7kjim",
"build/app.856a8108.css": "sha384-LXQ3xtGnzZrgD1R/P0zS2d34VL7+QYCeQvjV0hzFHeoM/IppVbIUAt2FzuORmCuj",
"build/app.19a715ed.js": "sha384-Pesgkybq559ztW96CLUqdAiQq6IE19D/qMe/S3YdCsUA2PeaMBV4vYqIrXqDD9Gd",
"build/app.cafebaaa.css": "sha384-gTa4tNAiWKmWXUNGKUz45SW9E2Etr68bq3XC+Y+a+VC5EOfB3s9pbNGlwp6yEinW",
"build/invoice.74279541.js": "sha384-2BXic5Sgorf2tXai6zSAN4wLY2dbg06L03/xMKW6itMcszvtnRArKzfBh6DNcF3f",
"build/invoice.13d8ef4e.css": "sha384-B6RN/wZJToSBCZk2JeLokIqWEhbh+Eb9arYbt9dM+YoC2Z6PnCeTwTqSGyexWWJh",
"build/invoice-pdf.0efd7a97.js": "sha384-bSdIeRCtEJiYYuc2reb0e5CpJ1Kbd1lQNEkElMTiq1SX0IINzdwJJYf6WnCcHrNC",

View file

@ -2,8 +2,8 @@
"build/0.79dbdbb9.js": "build/0.79dbdbb9.js",
"build/1.32489d92.js": "build/1.32489d92.js",
"build/2.7ab75d0a.js": "build/2.7ab75d0a.js",
"build/app.css": "build/app.856a8108.css",
"build/app.js": "build/app.4f01ca95.js",
"build/app.css": "build/app.cafebaaa.css",
"build/app.js": "build/app.19a715ed.js",
"build/calendar.css": "build/calendar.1408f57e.css",
"build/calendar.js": "build/calendar.070aab88.js",
"build/chart.js": "build/chart.34d60a88.js",

View file

@ -13,6 +13,8 @@ namespace App\API;
use App\Entity\User;
use App\Event\RecentActivityEvent;
use App\Event\TimesheetDuplicatePostEvent;
use App\Event\TimesheetDuplicatePreEvent;
use App\Event\TimesheetMetaDefinitionEvent;
use App\Form\API\TimesheetApiEditForm;
use App\Repository\Query\TimesheetQuery;
@ -727,8 +729,12 @@ class TimesheetController extends BaseApiController
$copyTimesheet = clone $timesheet;
$this->dispatcher->dispatch(new TimesheetDuplicatePreEvent($copyTimesheet, $timesheet));
$this->service->saveNewTimesheet($copyTimesheet);
$this->dispatcher->dispatch(new TimesheetDuplicatePostEvent($copyTimesheet, $timesheet));
$view = new View($copyTimesheet, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View file

@ -17,7 +17,7 @@ class Constants
/**
* The current release version
*/
public const VERSION = '1.12';
public const VERSION = '1.13';
/**
* The current release status, either "stable" or "dev"
*/

View file

@ -25,7 +25,6 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
@ -35,7 +34,7 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
* @Route(path="/profile")
* @Security("is_granted('view_own_profile') or is_granted('view_other_profile')")
*/
class ProfileController extends AbstractController
final class ProfileController extends AbstractController
{
/**
* @var EventDispatcherInterface
@ -50,11 +49,10 @@ class ProfileController extends AbstractController
*/
private $teams;
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher, TeamRepository $teams)
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher)
{
$this->encoder = $encoder;
$this->dispatcher = $dispatcher;
$this->teams = $teams;
}
/**
@ -104,7 +102,11 @@ class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_edit', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'settings', $form);
return $this->render('user/profile.html.twig', [
'tab' => 'settings',
'user' => $profile,
'form' => $form->createView(),
]);
}
/**
@ -129,7 +131,11 @@ class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_password', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'password', null, $form);
return $this->render('user/profile.html.twig', [
'tab' => 'password',
'user' => $profile,
'form' => $form->createView(),
]);
}
/**
@ -154,7 +160,11 @@ class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_api_token', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'api-token', null, null, null, $form);
return $this->render('user/api-token.html.twig', [
'tab' => 'api-token',
'user' => $profile,
'form' => $form->createView(),
]);
}
/**
@ -163,10 +173,18 @@ class ProfileController extends AbstractController
*/
public function rolesAction(User $profile, Request $request)
{
$isSuperAdmin = $profile->isSuperAdmin();
$form = $this->createRolesForm($profile);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// fix that a super admin cannot remove this role from himself.
// would be a massive problem, in case that there is only one super-admin account existing
if ($isSuperAdmin && !$profile->isSuperAdmin() && $profile->getId() === $this->getUser()->getId()) {
$profile->setSuperAdmin(true);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($profile);
$entityManager->flush();
@ -176,7 +194,11 @@ class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_roles', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'roles', null, null, $form);
return $this->render('user/profile.html.twig', [
'tab' => 'roles',
'user' => $profile,
'form' => $form->createView(),
]);
}
/**
@ -198,7 +220,11 @@ class ProfileController extends AbstractController
return $this->redirectToRoute('user_profile_teams', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'teams', null, null, null, null, $form);
return $this->render('user/profile.html.twig', [
'tab' => 'teams',
'user' => $profile,
'form' => $form->createView(),
]);
}
/**
@ -283,45 +309,6 @@ class ProfileController extends AbstractController
]);
}
protected function getProfileView(
User $user,
string $tab,
FormInterface $editForm = null,
FormInterface $pwdForm = null,
FormInterface $rolesForm = null,
FormInterface $apiTokenForm = null,
FormInterface $teamsForm = null
): Response {
$forms = [];
if ($this->isGranted('edit', $user)) {
$editForm = $editForm ?: $this->createEditForm($user);
$forms['settings'] = $editForm->createView();
}
if ($this->isGranted('password', $user)) {
$pwdForm = $pwdForm ?: $this->createPasswordForm($user);
$forms['password'] = $pwdForm->createView();
}
if ($this->isGranted('api-token', $user)) {
$apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user);
$forms['api-token'] = $apiTokenForm->createView();
}
if ($this->isGranted('teams', $user) && $this->teams->count([]) > 0) {
$teamsForm = $teamsForm ?: $this->createTeamsForm($user);
$forms['teams'] = $teamsForm->createView();
}
if ($this->isGranted('roles', $user)) {
$rolesForm = $rolesForm ?: $this->createRolesForm($user);
$forms['roles'] = $rolesForm->createView();
}
return $this->render('user/profile.html.twig', [
'tab' => $tab,
'user' => $user,
'forms' => $forms
]);
}
private function createPreferencesForm(User $user): FormInterface
{
return $this->createForm(

View file

@ -51,7 +51,7 @@ class Tag
*
* @ORM\Column(name="name", type="string", length=100, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(min=2, max=100, allowEmptyString=false)
* @Assert\Length(min=2, max=100, allowEmptyString=false, normalizer="trim")
* @Assert\Regex(pattern="/,/",match=false,message="Tag name cannot contain comma")
*/
private $name;
@ -77,7 +77,7 @@ class Tag
return $this->id;
}
public function setName(string $tagName): Tag
public function setName(?string $tagName): Tag
{
$this->name = $tagName;

View file

@ -439,6 +439,18 @@ class User extends BaseUser implements UserInterface
return !$this->getTeams()->isEmpty();
}
public function hasTeamMember(User $user): bool
{
/** @var Team $team */
foreach ($this->getTeams() as $team) {
if ($team->hasUser($user)) {
return true;
}
}
return false;
}
/**
* @return Collection<Team>
*/

View file

@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\Timesheet;
final class TimesheetDuplicatePostEvent extends AbstractTimesheetEvent
{
/**
* @var Timesheet
*/
private $original;
public function __construct(Timesheet $new, Timesheet $original)
{
parent::__construct($new);
$this->original = $original;
}
public function getOriginalTimesheet(): Timesheet
{
return $this->original;
}
}

View file

@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\Timesheet;
final class TimesheetDuplicatePreEvent extends AbstractTimesheetEvent
{
/**
* @var Timesheet
*/
private $original;
public function __construct(Timesheet $new, Timesheet $original)
{
parent::__construct($new);
$this->original = $original;
}
public function getOriginalTimesheet(): Timesheet
{
return $this->original;
}
}

View file

@ -130,7 +130,12 @@ final class MenuSubscriber implements EventSubscriberInterface
if ($auth->isGranted('view_user')) {
$users = new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], $this->getIcon('user'));
$users->setChildRoutes(['admin_user_create', 'admin_user_delete', 'admin_user_permissions', 'user_profile', 'user_profile_edit', 'user_profile_password', 'user_profile_api_token', 'user_profile_roles', 'user_profile_teams', 'user_profile_preferences']);
$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'));
$menu->addChild($users);
}

View file

@ -159,6 +159,13 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setSection('theme')
->setType(CheckboxType::class),
(new UserPreference())
->setName('theme.update_browser_title')
->setValue(true)
->setOrder(550)
->setSection('theme')
->setType(CheckboxType::class),
(new UserPreference())
->setName('calendar.initial_view')
->setValue(CalendarViewType::DEFAULT_VIEW)

View file

@ -21,9 +21,6 @@ class TagArrayToStringTransformer implements DataTransformerInterface
*/
private $tagRepository;
/**
* @param TagRepository $tagRepository
*/
public function __construct(TagRepository $tagRepository)
{
$this->tagRepository = $tagRepository;
@ -33,7 +30,6 @@ class TagArrayToStringTransformer implements DataTransformerInterface
* Transforms an array of tags to a string.
*
* @param Tag[]|null $tags
*
* @return string
*/
public function transform($tags)
@ -48,35 +44,34 @@ class TagArrayToStringTransformer implements DataTransformerInterface
/**
* Transforms a string to an array of tags.
*
* @param string|null $stringOfTags
* @see \Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer::reverseTransform()
*
* @param string|null $stringOfTags
* @return Tag[]
* @throws TransformationFailedException if object (issue) is not found
* @throws TransformationFailedException
*/
public function reverseTransform($stringOfTags)
{
// check for empty tag list
if (empty($stringOfTags)) {
if ('' === $stringOfTags || null === $stringOfTags) {
return [];
}
$names = array_filter(array_unique(array_map('trim', explode(',', $stringOfTags))));
// Get the current tags and find the new ones that should be created
// get the current tags and find the new ones that should be created
$tags = $this->tagRepository->findBy(['name' => $names]);
// works, because of the implicit case: (string) $tag
$newNames = array_diff($names, $tags);
foreach ($newNames as $name) {
$tag = new Tag();
$tag->setName($name);
$tags[] = $tag;
// There's no need to persist these new tags because Doctrine does that automatically
// thanks to the cascade={"persist"} option in the App\Entity\Timesheet::$tags property.
// new tags persist automatically thanks to the cascade={"persist"}
}
// Return an array of tags to transform them back into a Doctrine Collection.
// See Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer::reverseTransform()
return $tags;
}
}

View file

@ -28,6 +28,9 @@ final class InvoiceDocumentRepository
}
}
/**
* @CloudRequired
*/
public function addDirectory(string $directory)
{
$this->documentDirs[] = $directory;
@ -35,6 +38,9 @@ final class InvoiceDocumentRepository
return $this;
}
/**
* @CloudRequired
*/
public function removeDirectory(string $directory)
{
if (($key = array_search($directory, $this->documentDirs)) !== false) {
@ -45,7 +51,7 @@ final class InvoiceDocumentRepository
}
/**
* @deprecated since 1.10 - will be removed with 2.0 - use getCustomInvoiceDirectory() instead
* @deprecated since 1.10 - will be removed with 2.0 - use getUploadDirectory() instead
*/
public function getCustomInvoiceDirectory(): string
{

View file

@ -17,10 +17,6 @@ use Twig\TwigFunction;
class EncoreExtension extends AbstractExtension implements ServiceSubscriberInterface
{
/**
* @var EntrypointLookupInterface
*/
private $encoreService;
/**
* @var string
*/

View file

@ -113,6 +113,9 @@ class AvatarService
return $this->directory;
}
/**
* @CloudRequired
*/
public function setStorageDirectory(string $directory)
{
$this->directory = realpath($directory);

View file

@ -29,6 +29,9 @@ final class FileHelper
$this->filesystem = new Filesystem();
}
/**
* @CloudRequired
*/
public function setDataDirectory(string $directory)
{
$this->dataDir = $directory;

View file

@ -19,7 +19,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check permissions on Activities.
*/
class ActivityVoter extends Voter
final class ActivityVoter extends Voter
{
/**
* support rules based on the given activity

View file

@ -19,7 +19,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check authorization on Customers.
*/
class CustomerVoter extends Voter
final class CustomerVoter extends Voter
{
/**
* supported attributes/rules based on the given customer

View file

@ -19,7 +19,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check permissions on Projects.
*/
class ProjectVoter extends Voter
final class ProjectVoter extends Voter
{
/**
* support rules based on the given project

View file

@ -18,7 +18,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check the free-configurable permission from "kimai.permissions".
*/
class RolePermissionVoter extends Voter
final class RolePermissionVoter extends Voter
{
private $permissionManager;

View file

@ -15,7 +15,7 @@ use App\Security\RolePermissionManager;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class TeamVoter extends Voter
final class TeamVoter extends Voter
{
/**
* support rules based on the given $subject (here: Team)

View file

@ -19,7 +19,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check permissions on Timesheets.
*/
class TimesheetVoter extends Voter
final class TimesheetVoter extends Voter
{
public const VIEW = 'view';
public const START = 'start';

View file

@ -17,7 +17,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A voter to check permissions on user profiles.
*/
class UserVoter extends Voter
final class UserVoter extends Voter
{
private const ALLOWED_ATTRIBUTES = [
'view',

View file

@ -237,6 +237,7 @@
autoReloadDatatable: {% if theme_config('auto_reload_datatable') %}true{% else %}false{% endif %},
autoComplete: {{ theme_config('autocomplete_chars') }},
defaultColor: '{{ constant('App\\Constants::DEFAULT_COLOR') }}',
updateBrowserTitle: {% if app.user.preferenceValue('theme.update_browser_title') %}true{% else %}false{% endif %}
},
{
{% for key, translation in javascript_translations() -%}

View file

@ -1,8 +1,8 @@
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'form': form, 'comments': comments, 'route_pin': route_pin|default(null), 'route_delete': route_delete|default(null)} %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'form': form, 'comments': comments, 'route_pin': route_pin|default(null), 'route_delete': route_delete|default(null), 'delete_by_user': delete_by_user|default(false)} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ 'label.comment'|trans }}{% endblock %}
{% block box_attributes %}id="comments_box"{% endblock %}
{% block box_body_class %}box-comments{% endblock %}
{% block box_body_class %}box-body-scrollable{% endblock %}
{% block box_body %}
{% set replacer = {} %}
{% for pref in app.user.preferences %}
@ -10,42 +10,52 @@
{% endfor %}
{% if comments|length == 0 %}
{{ 'error.no_comments_found'|trans }}
{% endif %}
{% for comment in comments %}
<div class="box-comment">
{{ widgets.user_avatar(comment.createdBy, comment.createdAt|date_full, 'img-sm') }}
<div class="comment-text">
<span class="username">
{{ widgets.username(comment.createdBy) }}
<span class="text-muted pull-right">
{% if route_pin is not null %}
<a href="{{ path(route_pin, {'id': comment.id}) }}" class="btn btn-default btn-xs {% if comment.pinned %}active{% endif %}"><i class="{{ 'pin'|icon }}"></i></a>
{% elseif comment.pinned %}
<i class="{{ 'pin'|icon }}"></i>
{% endif %}
{% if route_delete is not null %}
<a href="{{ path(route_delete, {'id': comment.id}) }}" class="confirmation-link btn btn-default btn-xs" data-question="confirm.delete"><i class="{{ 'delete'|icon }}"></i></a>
{% endif %}
{% else %}
{% for comment in comments %}
<div class="direct-chat-msg">
<div class="direct-chat-info clearfix">
<span class="direct-chat-name pull-left">
{{ widgets.username(comment.createdBy) }}
</span>
</span>
{{ comment.message|replace(replacer)|md2html }}
<span class="direct-chat-timestamp pull-left">
&nbsp;
{{ comment.createdAt|date_full }}
&nbsp;
</span>
<span class="pull-right">
{% if route_pin is not null %}
<a href="{{ path(route_pin, {'id': comment.id}) }}" class="btn btn-default btn-xs {% if comment.pinned %}active{% endif %}"><i class="{{ 'pin'|icon }}"></i></a>
{% elseif comment.pinned %}
<i class="{{ 'pin'|icon }}"></i>
{% endif %}
{% if route_delete is not null and ((not delete_by_user) or (delete_by_user and comment.createdBy.id == app.user.id)) %}
<a href="{{ path(route_delete, {'id': comment.id}) }}" class="confirmation-link btn btn-default btn-xs" data-question="confirm.delete"><i class="{{ 'delete'|icon }}"></i></a>
{% endif %}
</span>
</div>
{{ widgets.user_avatar(comment.createdBy, false, 'direct-chat-img img-sm') }}
<div class="direct-chat-text">
{{ comment.message|replace(replacer)|md2html }}
</div>
</div>
</div>
{% endfor %}
{% endfor %}
{% endif %}
{% endblock %}
{% block box_footer -%}
{% if form is not null %}
{{ form_start(form) }}
<div class="input-group">
{{ widgets.user_avatar(app.user, false, 'img-responsive img-sm') }}
<div class="img-push">
{{ form_widget(form.message, {'attr': {'rows': '1', 'placeholder': 'placeholder.type_message'|trans}}) }}
</div>
<span class="input-group-btn">
<div class="row">
<div class="col-md-12">
{{ form_widget(form.message, {'attr': {'rows': '3', 'placeholder': 'placeholder.type_message'|trans, 'style': 'margin-bottom: 5px'}}) }}
<button type="submit" class="btn btn-default">
<i class="{{ 'comment'|icon }}"></i>
{{ 'label.comment'|trans }}
</button>
</span>
</div>
</div>
<div class="row">
<div class="col-md-12">
</div>
</div>
{{ form_widget(form) }}
{{ form_end(form) }}

View file

@ -232,14 +232,16 @@
<ul class="dropdown-menu dropdown-menu-right">
{%- apply spaceless -%}
{%- for icon,values in actions %}
{% if icon == 'divider' and values is null %}
{% if 'divider' in icon and values is null %}
{% if not loop.last and divider is same as (false) %}
<li class="divider"></li>
{% endif %}
{% set divider = true %}
{% else %}
{% if values is iterable %}
{% set values = values|merge({'title': icon|trans({}, 'actions')}) %}
{% if values['title'] is not defined %}
{% set values = values|merge({'title': icon|trans({}, 'actions')}) %}
{% endif %}
{% else %}
{% set values = {'url': values, 'title': icon|trans({}, 'actions')} %}
{% endif %}
@ -301,6 +303,7 @@
{% set url = '#' %}
{% endif %}
{% else %}
{% set icon = values.icon ?? icon %}
{% set disabled = values.disabled ?? false %}
{% set url = values.url ?? '#' %}
{% set onclick = values.onclick ?? null %}

View file

@ -60,12 +60,12 @@
{% endif %}
{% endif %}
{% if view != 'index' %}
{% if view != 'index' and view != 'custom' %}
{% set actions = actions|merge({'back': options.back|default(path('timesheet'))}) %}
{% endif %}
{% set event = trigger('actions.timesheet', {'actions': actions, 'view': view, 'timesheet': timesheet}) %}
{% if view == 'index' %}
{% if view == 'index' or view == 'custom' %}
{{ widgets.table_actions(event.payload.actions) }}
{% else %}
{{ widgets.entity_actions(event.payload.actions) }}

View file

@ -11,10 +11,6 @@
{% set actions = actions|merge({'download': {'url': path('user_export'), 'class': 'toolbar-action'}}) %}
{% if is_granted('role_permissions') %}
{% set actions = actions|merge({'permissions': path('admin_user_permissions')}) %}
{% endif %}
{% if is_granted('create_user') %}
{% set actions = actions|merge({'create': {'url': path('admin_user_create')}}) %}
{% endif %}
@ -35,16 +31,12 @@
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if is_granted('view_user') %}
{% set actions = actions|merge({'back': path('admin_user')}) %}
{% endif %}
{% if view != 'index' and is_granted('role_permissions') %}
{% set actions = actions|merge({'permissions': path('admin_user_permissions')}) %}
{% endif %}
{% if view != 'role' and is_granted('role_permissions') %}
{% set actions = actions|merge({'roles': {'url': path('admin_user_roles'), 'class': 'modal-ajax-form'}}) %}
{% set actions = actions|merge({'create': {'url': path('admin_user_roles'), 'class': 'modal-ajax-form'}}) %}
{% endif %}
{% set actions = actions|merge({'help': {'url': 'permissions.html'|docu_link, 'target': '_blank'}}) %}
@ -62,15 +54,34 @@
{% if is_granted('view', user) %}
{% set actions = actions|merge({'profile-stats': {'url': path('user_profile', {'username' : user.username})}}) %}
{% endif %}
{% if is_granted('edit', user) %}
{% set actions = actions|merge({'edit': path('user_profile_edit', {'username' : user.username})}) %}
{% endif %}
{% if is_granted('preferences', user) %}
{% set actions = actions|merge({'settings': {'url': path('user_profile_preferences', {'username' : user.username})}}) %}
{% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}
{% set subActions = {} %}
{% if is_granted('edit', user) %}
{% set subActions = subActions|merge({'edit': path('user_profile_edit', {'username' : user.username})}) %}
{% endif %}
{% if is_granted('preferences', user) %}
{% set subActions = subActions|merge({'settings': {'url': path('user_profile_preferences', {'username' : user.username})}}) %}
{% endif %}
{% if is_granted('password', user) %}
{% set subActions = subActions|merge({'password': {'url': path('user_profile_password', {'username' : user.username}), 'title': ('profile.password'|trans)}}) %}
{% endif %}
{% if is_granted('api-token', user) %}
{% set subActions = subActions|merge({'api-token': {'url': path('user_profile_api_token', {'username' : user.username}), 'title': ('profile.api-token'|trans)}}) %}
{% endif %}
{% if is_granted('teams', user) %}
{% set subActions = subActions|merge({'teams': {'url': path('user_profile_teams', {'username' : user.username}), 'title': ('profile.teams'|trans)}}) %}
{% endif %}
{% if is_granted('roles', user) %}
{% set subActions = subActions|merge({'roles': {'url': path('user_profile_roles', {'username' : user.username}), 'title': ('profile.roles'|trans)}}) %}
{% endif %}
{% if subActions|length > 0 %}
{% set actions = actions|merge(subActions) %}
{% set actions = actions|merge({'divider2': null}) %}
{% endif %}
{% if is_granted('view_reporting') %}
{% if view_other or app.user.id == user.id %}
{% set actions = actions|merge({'report': path('report_user_month', {'user': user.id})}) %}
@ -87,7 +98,6 @@
{% if options.back is defined %}
{% set actions = actions|merge({'back': options.back}) %}
{% endif %}
{% set event = trigger('actions.user', {'actions': actions, 'view': view, 'user': user}) %}
{% if view == 'index' %}
{{ widgets.table_actions(event.payload.actions) }}

View file

@ -0,0 +1,20 @@
{% extends 'user/layout.html.twig' %}
{% block main %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ ('profile.' ~ tab)|trans }}{% endblock %}
{% block box_tools %}
<a class="btn btn-box-tool" target="_blank" href="{{ path('app.swagger_ui') }}"><i class="{{ 'api'|icon('fas fa-book') }}"></i></a>
<a class="btn btn-box-tool" target="_blank" href="{{ 'rest-api.html'|docu_link }}"><i class="{{ 'help'|icon }}"></i></a>
{% endblock %}
{% block box_body %}
{{ form_start(form) }}
{{ form_widget(form) }}
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
{{ form_end(form) }}
{% endblock %}
{% endembed %}
{% endblock %}

View file

@ -23,7 +23,7 @@
{% set tableName = 'user_admin_permissions' %}
{% block page_title %}{{ 'user_permissions.title'|trans }}{% endblock %}
{% block page_title %}{{ 'profile.roles'|trans }}{% endblock %}
{% block page_actions %}{{ actions.user_permissions('index') }}{% endblock %}
{% block main %}

View file

@ -1,25 +1,16 @@
{% extends 'user/layout.html.twig' %}
{% block main %}
<div class="row">
<div class="col-md-12">
<div class="nav-tabs-custom">
<ul class="nav nav-tabs">
{% for formName, form in forms %}
<li {% if tab == formName %}class="active"{% endif %}><a href="#{{ formName }}" data-toggle="tab" aria-expanded="false">{{ ('profile.' ~ formName)|trans }}</a></li>
{% endfor %}
</ul>
<div class="tab-content">
{% for formName, form in forms %}
<div class="tab-pane {% if tab == formName %}active{% endif %}" id="{{ formName }}">
{{ form_start(form) }}
{{ form_widget(form) }}
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
{{ form_end(form) }}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ ('profile.' ~ tab)|trans }}{% endblock %}
{% block box_body %}
{{ form_start(form) }}
{{ form_widget(form) }}
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
{{ form_end(form) }}
{% endblock %}
{% endembed %}
{% endblock %}

View file

@ -9,17 +9,17 @@
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_attributes %}id="{{ widgetId }}"{% endblock %}
{% block box_body_class %}no-padding{% endblock %}
{% block box_body_class %}no-padding box-body-scrollable{% endblock %}
{% block box_title %}
{% if not title is empty %}{{ title|trans }}{% endif %}
{% endblock %}
{% block box_body %}
<table class="table table-hover dataTable" role="grid">
<tbody>
{% for stats in projectStats %}
{% for stats in projectStats|sort((a, b) => a.project.name <=> b.project.name) %}
{% set project = stats.project %}
<tr>
<td>
<td{% if is_granted('details', stats.project) %} class="alternative-link open-edit" data-href="{{ path('project_details', {'id': stats.project.id}) }}"{% endif %}>
{{ widgets.label_project(stats.project) }}
<br>
<small>{{ widgets.label_customer(stats.project.customer) }}</small>

View file

@ -9,7 +9,7 @@
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ title|trans }}{% endblock %}
{% block box_attributes %}id="{{ widgetId }}"{% endblock %}
{% block box_body_class %}no-padding{% endblock %}
{% block box_body_class %}no-padding box-body-scrollable{% endblock %}
{% block box_body %}
{{ widgets.team_list(teams, false) }}
{% endblock %}

View file

@ -157,7 +157,7 @@ class CustomerControllerTest extends ControllerBaseTest
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
$node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-text');
self::assertStringContainsString('<p>A beautiful and short comment <strong>with some</strong> markdown formatting</p>', $node->html());
}
@ -173,15 +173,15 @@ class CustomerControllerTest extends ControllerBaseTest
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
$node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-msg');
self::assertStringContainsString('Blah foo bar', $node->html());
$node = $client->getCrawler()->filter('div.box#comments_box .box-comment a.confirmation-link');
$node = $client->getCrawler()->filter('div.box#comments_box .box-body a.confirmation-link');
self::assertEquals($this->createUrl('/admin/customer/1/comment_delete'), $node->attr('href'));
$this->request($client, '/admin/customer/1/comment_delete');
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
$node = $client->getCrawler()->filter('div.box#comments_box .box-body');
self::assertStringContainsString('There were no comments posted yet', $node->html());
}
@ -197,15 +197,15 @@ class CustomerControllerTest extends ControllerBaseTest
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
$node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-text');
self::assertStringContainsString('Blah foo bar', $node->html());
$node = $client->getCrawler()->filter('div.box#comments_box .box-comment a.btn.active');
$node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-text a.btn.active');
self::assertEquals(0, $node->count());
$this->request($client, '/admin/customer/1/comment_pin');
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box .box-comment a.btn.active');
$node = $client->getCrawler()->filter('div.box#comments_box .box-body a.btn.active');
self::assertEquals(1, $node->count());
self::assertEquals($this->createUrl('/admin/customer/1/comment_pin'), $node->attr('href'));
}

View file

@ -36,8 +36,8 @@ class PermissionControllerTest extends ControllerBaseTest
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 119);
$this->assertPageActions($client, [
'back' => $this->createUrl('/admin/user/'),
'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
//'back' => $this->createUrl('/admin/user/'),
'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
'help' => 'https://www.kimai.org/documentation/permissions.html'
]);

View file

@ -93,32 +93,20 @@ class ProfileControllerTest extends ControllerBaseTest
public function getTabTestData()
{
$userTabs = ['#settings', '#password', '#api-token'];
return [
[User::ROLE_USER, UserFixtures::USERNAME_USER, ['#settings', '#password', '#api-token']],
[User::ROLE_SUPER_ADMIN, UserFixtures::USERNAME_SUPER_ADMIN, array_merge($userTabs, ['#teams', '#roles'])],
[User::ROLE_USER, UserFixtures::USERNAME_USER],
[User::ROLE_SUPER_ADMIN, UserFixtures::USERNAME_SUPER_ADMIN],
];
}
/**
* @dataProvider getTabTestData
*/
public function testEditActionTabs($role, $username, $expectedTabs)
public function testEditActionTabs($role, $username)
{
$client = $this->getClientForAuthenticatedUser($role);
$this->request($client, '/profile/' . $username . '/edit');
$this->assertTrue($client->getResponse()->isSuccessful());
$tabs = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav-tabs li');
$this->assertEquals(\count($expectedTabs), $tabs->count());
$foundTabs = [];
/** @var \DOMElement $tab */
foreach ($tabs->filter('a') as $tab) {
$foundTabs[] = $tab->getAttribute('href');
}
$this->assertEmpty(array_diff($expectedTabs, $foundTabs));
}
public function testIndexActionWithDifferentUsername()

View file

@ -234,7 +234,7 @@ class ProjectControllerTest extends ControllerBaseTest
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
$node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-text');
self::assertStringContainsString('<p>A beautiful and long comment <strong>with some</strong> markdown formatting</p>', $node->html());
}
@ -250,15 +250,15 @@ class ProjectControllerTest extends ControllerBaseTest
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
$node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-text');
self::assertStringContainsString('Foo bar blub', $node->html());
$node = $client->getCrawler()->filter('div.box#comments_box .box-comment a.confirmation-link');
$node = $client->getCrawler()->filter('div.box#comments_box .box-body a.confirmation-link');
self::assertEquals($this->createUrl('/admin/project/1/comment_delete'), $node->attr('href'));
$this->request($client, '/admin/project/1/comment_delete');
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
$node = $client->getCrawler()->filter('div.box#comments_box .box-body');
self::assertStringContainsString('There were no comments posted yet', $node->html());
}
@ -274,15 +274,15 @@ class ProjectControllerTest extends ControllerBaseTest
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
$node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-text');
self::assertStringContainsString('Foo bar blub', $node->html());
$node = $client->getCrawler()->filter('div.box#comments_box .box-comment a.btn.active');
$node = $client->getCrawler()->filter('div.box#comments_box .box-body a.btn.active');
self::assertEquals(0, $node->count());
$this->request($client, '/admin/project/1/comment_pin');
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box .box-comment a.btn.active');
$node = $client->getCrawler()->filter('div.box#comments_box .box-body a.btn.active');
self::assertEquals(1, $node->count());
self::assertEquals($this->createUrl('/admin/project/1/comment_pin'), $node->attr('href'));
}

View file

@ -38,7 +38,6 @@ class UserControllerTest extends ControllerBaseTest
'search search-toggle visible-xs-inline' => '#',
'visibility' => '#',
'download toolbar-action' => $this->createUrl('/admin/user/export'),
'permissions' => $this->createUrl('/admin/permissions'),
'create' => $this->createUrl('/admin/user/create'),
'help' => 'https://www.kimai.org/documentation/users.html'
]);
@ -117,17 +116,6 @@ class UserControllerTest extends ControllerBaseTest
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode($username) . '/edit'));
$client->followRedirect();
$expectedTabs = ['#settings', '#password', '#api-token', '#teams', '#roles'];
$tabs = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav-tabs li');
$this->assertEquals(\count($expectedTabs), $tabs->count());
$foundTabs = [];
/** @var \DOMElement $tab */
foreach ($tabs->filter('a') as $tab) {
$foundTabs[] = $tab->getAttribute('href');
}
$this->assertEmpty(array_diff($expectedTabs, $foundTabs));
$form = $client->getCrawler()->filter('form[name=user_edit]')->form();
$this->assertEquals($username, $form->get('user_edit[alias]')->getValue());
}

View file

@ -34,6 +34,9 @@ class TagTest extends TestCase
$this->assertEquals('foo', $sut->getName());
$this->assertEquals('foo', (string) $sut);
$sut->setName(null);
$this->assertNull($sut->getName());
$this->assertInstanceOf(Tag::class, $sut->setColor('#fffccc'));
$this->assertEquals('#fffccc', $sut->getColor());
}

View file

@ -142,6 +142,7 @@ class UserTest extends TestCase
public function testTeams()
{
$sut = new User();
$user = new User();
$team = new Team();
self::assertEmpty($sut->getTeams());
self::assertEmpty($team->getUsers());
@ -151,6 +152,10 @@ class UserTest extends TestCase
self::assertSame($team, $sut->getTeams()[0]);
self::assertSame($sut, $team->getUsers()[0]);
self::assertTrue($sut->hasTeamAssignment());
self::assertFalse($sut->hasTeamMember($user));
$team->addUser($user);
self::assertTrue($sut->hasTeamMember($user));
self::assertFalse($sut->isTeamleadOf($team));
self::assertTrue($sut->isInTeam($team));

View file

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Timesheet;
use App\Event\AbstractTimesheetEvent;
use App\Event\TimesheetDuplicatePostEvent;
/**
* @covers \App\Event\TimesheetDuplicatePostEvent
*/
class TimesheetDuplicatePostEventTest extends AbstractTimesheetEventTest
{
protected function createTimesheetEvent(Timesheet $timesheet): AbstractTimesheetEvent
{
return new TimesheetDuplicatePostEvent($timesheet, new Timesheet());
}
public function testGetOriginalTimesheet()
{
$newTimesheet = new Timesheet();
$originalTimesheet = new Timesheet();
$sut = new TimesheetDuplicatePostEvent($newTimesheet, $originalTimesheet);
self::assertSame($newTimesheet, $sut->getTimesheet());
self::assertSame($originalTimesheet, $sut->getOriginalTimesheet());
}
}

View file

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Timesheet;
use App\Event\AbstractTimesheetEvent;
use App\Event\TimesheetDuplicatePreEvent;
/**
* @covers \App\Event\TimesheetDuplicatePreEvent
*/
class TimesheetDuplicatePreEventTest extends AbstractTimesheetEventTest
{
protected function createTimesheetEvent(Timesheet $timesheet): AbstractTimesheetEvent
{
return new TimesheetDuplicatePreEvent($timesheet, new Timesheet());
}
public function testGetOriginalTimesheet()
{
$newTimesheet = new Timesheet();
$originalTimesheet = new Timesheet();
$sut = new TimesheetDuplicatePreEvent($newTimesheet, $originalTimesheet);
self::assertSame($newTimesheet, $sut->getTimesheet());
self::assertSame($originalTimesheet, $sut->getOriginalTimesheet());
}
}

View file

@ -40,7 +40,7 @@ class UserPreferenceSubscriberTest extends TestCase
self::assertSame($user, $event->getUser());
$prefs = $sut->getDefaultPreferences($user);
self::assertCount(12, $prefs);
self::assertCount(13, $prefs);
foreach ($prefs as $pref) {
switch ($pref->getName()) {
@ -70,7 +70,7 @@ class UserPreferenceSubscriberTest extends TestCase
// TODO test merging values
$sut->loadUserPreferences($event);
$prefs = $event->getUser()->getPreferences();
self::assertCount(12, $prefs);
self::assertCount(13, $prefs);
foreach ($prefs as $pref) {
switch ($pref->getName()) {

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\Tests\Utils;
use App\Utils\AvatarService;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Utils\AvatarService
*/
class AvatarServiceTest extends TestCase
{
public function testDataDirectory()
{
$data = realpath(__DIR__ . '/../../');
$sut = new AvatarService($data);
self::assertEquals($data . '/public/avatars', $sut->getStorageDirectory());
$data = realpath(__DIR__ . '/../../var/data/');
$sut->setStorageDirectory($data);
self::assertEquals($data, $sut->getStorageDirectory());
}
}

View file

@ -39,4 +39,19 @@ class FileHelperTest extends TestCase
{
self::assertEquals($expected, FileHelper::convertToAsciiFilename($original));
}
public function testDataDirectory()
{
$data = realpath(__DIR__ . '/../_data/');
$sut = new FileHelper($data);
self::assertEquals($data . '/', $sut->getDataDirectory());
self::assertEquals($data . '/foo/', $sut->getDataDirectory('/foo/'));
self::assertEquals($data . '/foo/', $sut->getDataDirectory('foo'));
$data = realpath(__DIR__ . '/../../var/data/');
$sut->setDataDirectory($data);
self::assertEquals($data . '/', $sut->getDataDirectory());
self::assertEquals($data . '/foo/', $sut->getDataDirectory('/foo/'));
self::assertEquals($data . '/foo/', $sut->getDataDirectory('foo'));
}
}

View file

@ -552,6 +552,10 @@
<source>label.timesheet.export_decimal</source>
<target>Dezimal Format für Export nutzen</target>
</trans-unit>
<trans-unit id="theme.update_browser_title">
<source>theme.update_browser_title</source>
<target>Browser Titel aktualisieren</target>
</trans-unit>
<!--
User timesheet calendar
-->

View file

@ -560,7 +560,10 @@
<source>label.timesheet.export_decimal</source>
<target>Use decimal duration in export</target>
</trans-unit>
<trans-unit id="theme.update_browser_title">
<source>theme.update_browser_title</source>
<target>Update browser title</target>
</trans-unit>
<!--
User timesheet calendar
-->

View file

@ -1881,9 +1881,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001093:
version "1.0.30001111"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001111.tgz#dd0ce822c70eb6c7c068e4a55c22e19ec1501298"
integrity sha512-xnDje2wchd/8mlJu8sXvWxOGvMgv+uT3iZ3bkIAynKOzToCssWCmkz/ZIkQBs/2pUB4uwnJKVORWQ31UkbVjOg==
version "1.0.30001180"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001180.tgz"
integrity sha512-n8JVqXuZMVSPKiPiypjFtDTXc4jWIdjxull0f92WLo7e1MSi3uJ3NvveakSh/aCl1QKFAvIz3vIj0v+0K+FrXw==
caseless@~0.12.0:
version "0.12.0"