users weekly stats as bar-chart in dashboard (#847)

This commit is contained in:
Kevin Papst
2019-06-13 18:38:49 +02:00
committed by GitHub
parent e1f862507e
commit 3311f17bbb
96 changed files with 2754 additions and 846 deletions

View File

@@ -27,12 +27,12 @@ It is built with modern technologies such as Symfony, Bootstrap, RESTful API, re
### About ### About
This new version of the open source timetracker Kimai. It is in a stable development phase, usable in production and This is new version of the open source timetracker Kimai. It is in a stable development phase, usable in production and
with most advanced features from Kimai 1 and many new ones: with most advanced features from Kimai 1 and many new ones, including but not limited to:
JSON API, invoicing, data exports, multi-timer and punch-in punch-out mode, tagging, multi-user and multi-timezones, JSON API, invoicing, data exports, multi-timer and punch-in punch-out mode, tagging, multi-user and multi-timezones,
LDAP and built-in authentication, customizable role permissions, responsive and usable on your mobile device, LDAP and built-in authentication, customizable role permissions, responsive and ready for your mobile device,
hourly and fixed rates, advanced filtering, support for plugins and many more. hourly and fixed rates, advanced filtering, money and time budgets and report, support for plugins and many more.
## Installation ## Installation
@@ -40,7 +40,7 @@ hourly and fixed rates, advanced filtering, support for plugins and many more.
- [Docker](https://www.kimai.org/documentation/docker.html) - containerized - [Docker](https://www.kimai.org/documentation/docker.html) - containerized
- [Development](https://www.kimai.org/documentation/installation.html#development-installation) - on your local machine - [Development](https://www.kimai.org/documentation/installation.html#development-installation) - on your local machine
- [1-click installer](https://www.kimai.org/documentation/installation.html#hosting-and-1-click-installations) - hosted environments - [1-click installer](https://www.kimai.org/documentation/installation.html#hosting-and-1-click-installations) - hosted environments
- [FTP](https://www.kimai.org/documentation/installation.html#ftp-installation) - unfortunately still widely used - [FTP](https://www.kimai.org/documentation/installation.html#ftp-installation) - unfortunately still widely used ;-)
### Updating Kimai ### Updating Kimai
@@ -49,7 +49,7 @@ hourly and fixed rates, advanced filtering, support for plugins and many more.
### Plugins ### Plugins
- [Plugin marketplace](https://www.kimai.org/store/) - users find existing plugins here - [Plugin marketplace](https://www.kimai.org/store/) - find existing plugins here
- [Developer documentation](https://www.kimai.org/documentation/developers.html) - how to create a plugin - [Developer documentation](https://www.kimai.org/documentation/developers.html) - how to create a plugin
## Roadmap and releases ## Roadmap and releases

View File

@@ -35,6 +35,7 @@ Removed permission:
- API: Format for queries including a datetime object fixed to use HTML5 format (previously `2019-03-02 14:23` - now `2019-03-02T14:23:00`) - API: Format for queries including a datetime object fixed to use HTML5 format (previously `2019-03-02 14:23` - now `2019-03-02T14:23:00`)
- **Permission config**: the `permissions` definition in your `local.yaml` needs to be verified/changed, as the internal structure was highly optimized to simplify the definition. - **Permission config**: the `permissions` definition in your `local.yaml` needs to be verified/changed, as the internal structure was highly optimized to simplify the definition.
Thanks to the new structure, you should be able to remove almost everything from your `local.yaml` (tip: start over from scratch!). Please read [the updated permission docu](https://www.kimai.org/documentation/permissions.html). Thanks to the new structure, you should be able to remove almost everything from your `local.yaml` (tip: start over from scratch!). Please read [the updated permission docu](https://www.kimai.org/documentation/permissions.html).
- default widgets were removed from `kimai.yaml`, that shouldn't cause any issues ... but if something is odd, now you know [where to look for help](https://www.kimai.org/documentation/dashboard.html)
## [0.9](https://github.com/kevinpapst/kimai2/releases/tag/0.9) ## [0.9](https://github.com/kevinpapst/kimai2/releases/tag/0.9)

View File

@@ -41,7 +41,8 @@ export default class KimaiAlert extends KimaiPlugin {
} }
Swal.fire({ Swal.fire({
timer: 1500, timer: 2000,
toast: true,
position: 'top-end', position: 'top-end',
showConfirmButton: false, showConfirmButton: false,
type: 'success', type: 'success',

30
composer.lock generated
View File

@@ -2971,33 +2971,37 @@
}, },
{ {
"name": "kevinpapst/adminlte-bundle", "name": "kevinpapst/adminlte-bundle",
"version": "2.7.3", "version": "2.8.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/kevinpapst/AdminLTEBundle.git", "url": "https://github.com/kevinpapst/AdminLTEBundle.git",
"reference": "bbe650bda5319ac55637457e23d60bcafb734da1" "reference": "62df10d0565f2ed5971b1a28ef30195ad1893807"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/kevinpapst/AdminLTEBundle/zipball/bbe650bda5319ac55637457e23d60bcafb734da1", "url": "https://api.github.com/repos/kevinpapst/AdminLTEBundle/zipball/62df10d0565f2ed5971b1a28ef30195ad1893807",
"reference": "bbe650bda5319ac55637457e23d60bcafb734da1", "reference": "62df10d0565f2ed5971b1a28ef30195ad1893807",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.1.3", "php": "^7.1.3",
"symfony/config": ">3.4", "symfony/config": ">4.0",
"symfony/dependency-injection": ">3.4", "symfony/dependency-injection": ">4.0",
"symfony/event-dispatcher": ">3.4", "symfony/event-dispatcher": ">4.0",
"symfony/http-foundation": ">3.4", "symfony/http-foundation": ">4.0",
"symfony/http-kernel": ">3.4", "symfony/http-kernel": ">4.0",
"symfony/options-resolver": ">3.4", "symfony/options-resolver": ">4.0",
"symfony/security-core": ">3.4", "symfony/security-core": ">4.0",
"twig/twig": ">2.0" "twig/twig": ">2.0"
}, },
"require-dev": { "require-dev": {
"friendsofphp/php-cs-fixer": "^2.10", "friendsofphp/php-cs-fixer": "^2.10",
"knplabs/knp-menu-bundle": "^2.2",
"phpspec/prophecy": "^1.6", "phpspec/prophecy": "^1.6",
"phpunit/phpunit": "^7.3" "phpstan/phpstan": "^0.11.8",
"phpstan/phpstan-phpunit": "^0.11.2",
"phpunit/phpunit": "^7.3",
"symfony/framework-bundle": ">4.0"
}, },
"suggest": { "suggest": {
"friendsofsymfony/user-bundle": "Allows easy user management and security support", "friendsofsymfony/user-bundle": "Allows easy user management and security support",
@@ -3028,7 +3032,7 @@
} }
], ],
"description": "Admin theme bundle for Symfony 4 based on AdminLTE 2.4.8 with FOSUserBundle support", "description": "Admin theme bundle for Symfony 4 based on AdminLTE 2.4.8 with FOSUserBundle support",
"time": "2019-05-13T16:17:08+00:00" "time": "2019-06-13T11:25:39+00:00"
}, },
{ {
"name": "kimai/kimai2-composer", "name": "kimai/kimai2-composer",

View File

@@ -13,5 +13,12 @@ jms_serializer:
App: App:
namespace_prefix: "App" namespace_prefix: "App"
path: "%kernel.root_dir%/../config/serializer/App" path: "%kernel.root_dir%/../config/serializer/App"
warmup:
paths:
included:
- "%kernel.root_dir%/Entity/"
- "%kernel.root_dir%/API/Model/"
- "%kernel.root_dir%/../vendor/friendsofsymfony/user-bundle/Model"
excluded: []
property_naming: property_naming:
id: 'jms_serializer.identical_property_naming_strategy' id: 'jms_serializer.identical_property_naming_strategy'

View File

@@ -169,7 +169,8 @@ kimai:
title: dashboard.you title: dashboard.you
order: 10 order: 10
permission: ROLE_USER permission: ROLE_USER
widgets: [userDurationToday, userDurationWeek, userDurationMonth, userDurationYear] type: 'compoundChart'
widgets: [DailyWorkingTimeChart, userDurationToday, userDurationWeek, userDurationMonth, userDurationYear]
user_rates: user_rates:
title: ~ title: ~
order: 20 order: 20
@@ -190,34 +191,6 @@ kimai:
order: 50 order: 50
permission: view_rate_other_timesheet permission: view_rate_other_timesheet
widgets: [amountToday, amountWeek, amountMonth, amountYear] widgets: [amountToday, amountWeek, amountMonth, amountYear]
widgets:
userDurationToday: { title: stats.durationToday, query: duration, user: true, begin: '00:00:00', end: '23:59:59', icon: duration, color: green }
userDurationWeek: { title: stats.durationWeek, query: duration, user: true, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: duration, color: blue }
userDurationMonth: { title: stats.durationMonth, query: duration, user: true, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: duration, color: purple }
userDurationYear: { title: stats.durationYear, query: duration, user: true, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: duration, color: yellow }
userDurationTotal: { title: stats.durationTotal, query: duration, user: true, icon: duration, color: red }
userAmountToday: { title: stats.amountToday, query: rate, user: true, begin: '00:00:00', end: '23:59:59', icon: money, color: green }
userAmountWeek: { title: stats.amountWeek, query: rate, user: true, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: money, color: blue }
userAmountMonth: { title: stats.amountMonth, query: rate, user: true, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: money, color: purple }
userAmountYear: { title: stats.amountYear, query: rate, user: true, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: money, color: yellow }
userAmountTotal: { title: stats.amountTotal, query: rate, user: true, icon: money, color: red }
durationToday: { title: stats.durationToday, query: duration, begin: '00:00:00', end: '23:59:59', icon: duration, color: green }
durationWeek: { title: stats.durationWeek, query: duration, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: duration, color: blue }
durationMonth: { title: stats.durationMonth, query: duration, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: duration, color: purple }
durationYear: { title: stats.durationYear, query: duration, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: duration, color: yellow }
durationTotal: { title: stats.durationTotal, query: duration, icon: duration, color: red }
amountToday: { title: stats.amountToday, query: rate, begin: '00:00:00', end: '23:59:59', icon: money, color: green }
amountWeek: { title: stats.amountWeek, query: rate, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: money, color: blue }
amountMonth: { title: stats.amountMonth, query: rate, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: money, color: purple }
amountYear: { title: stats.amountYear, query: rate, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: money, color: yellow }
amountTotal: { title: stats.amountTotal, query: rate, icon: money, color: red }
activeUsersToday: { title: stats.userActiveToday, query: users, begin: '00:00:00', end: '23:59:59', icon: user, color: green }
activeUsersWeek: { title: stats.userActiveWeek, query: users, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: user, color: blue }
activeUsersMonth: { title: stats.userActiveMonth, query: users, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: user, color: purple }
activeUsersYear: { title: stats.userActiveYear, query: users, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: user, color: yellow }
activeUsersTotal: { title: stats.userActiveTotal, query: users, icon: user, color: red }
activeRecordings: { title: stats.activeRecordings, query: active, icon: duration, color: red }
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------

View File

@@ -77,6 +77,10 @@ services:
App\Export\ServiceExport: App\Export\ServiceExport:
arguments: [!tagged export.renderer] arguments: [!tagged export.renderer]
App\Widget\WidgetService:
arguments:
$renderer: !tagged widget.renderer
# ================================================================================ # ================================================================================
# DATABASE # DATABASE
# ================================================================================ # ================================================================================

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
"build/runtime.784b5d0d.js", "build/runtime.784b5d0d.js",
"build/0.66b5fd0b.js", "build/0.66b5fd0b.js",
"build/1.93f0cf2b.js", "build/1.93f0cf2b.js",
"build/app.26c26669.js" "build/app.0ac6f178.js"
], ],
"css": [ "css": [
"build/app.7f4a18a1.css" "build/app.7f4a18a1.css"
@@ -35,7 +35,7 @@
"build/runtime.784b5d0d.js": "sha384-+AsC7zoefPga1AkczlNSCXcbGwD0TwokkldvNISHf1nMlOpafanLjtKvlZy9cVsm", "build/runtime.784b5d0d.js": "sha384-+AsC7zoefPga1AkczlNSCXcbGwD0TwokkldvNISHf1nMlOpafanLjtKvlZy9cVsm",
"build/0.66b5fd0b.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok", "build/0.66b5fd0b.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
"build/1.93f0cf2b.js": "sha384-pmnwzvj+UJhDD3uDf+ekLARMBRtMDxWGbnJDrJvwIoRZd3MW/tOGzAW5Ybhbvw20", "build/1.93f0cf2b.js": "sha384-pmnwzvj+UJhDD3uDf+ekLARMBRtMDxWGbnJDrJvwIoRZd3MW/tOGzAW5Ybhbvw20",
"build/app.26c26669.js": "sha384-SyN1k+3kOWKnJSOyY+atWLSXNubTbsgz1DZ47jH8PrgLFj9WjXB9GRhO6E7tpJWf", "build/app.0ac6f178.js": "sha384-eORpGh3VKK5IISACV4rmp8CpkZ+s47G8aKaZnXVpswgZccU/YcJPkSa5+jhcI88T",
"build/app.7f4a18a1.css": "sha384-p8Dw7CP1RxxJ2K5yuaNpCw8r1lKd64DlVA2IQeEGxBAC2QxOjweafd8w9CSMk0GC", "build/app.7f4a18a1.css": "sha384-p8Dw7CP1RxxJ2K5yuaNpCw8r1lKd64DlVA2IQeEGxBAC2QxOjweafd8w9CSMk0GC",
"build/2.c06cd055.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q", "build/2.c06cd055.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
"build/chart.2bb71e2e.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y", "build/chart.2bb71e2e.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",

View File

@@ -3,7 +3,7 @@
"build/1.93f0cf2b.js": "build/1.93f0cf2b.js", "build/1.93f0cf2b.js": "build/1.93f0cf2b.js",
"build/2.c06cd055.js": "build/2.c06cd055.js", "build/2.c06cd055.js": "build/2.c06cd055.js",
"build/app.css": "build/app.7f4a18a1.css", "build/app.css": "build/app.7f4a18a1.css",
"build/app.js": "build/app.26c26669.js", "build/app.js": "build/app.0ac6f178.js",
"build/calendar.css": "build/calendar.b0551848.css", "build/calendar.css": "build/calendar.b0551848.css",
"build/calendar.js": "build/calendar.59417f4c.js", "build/calendar.js": "build/calendar.59417f4c.js",
"build/chart.js": "build/chart.2bb71e2e.js", "build/chart.js": "build/chart.2bb71e2e.js",

View File

@@ -10,8 +10,10 @@
namespace App\Controller; namespace App\Controller;
use App\Event\DashboardEvent; use App\Event\DashboardEvent;
use App\Model\DashboardSection; use App\Widget\Type\CompoundChart;
use App\Repository\WidgetRepository; use App\Widget\Type\CompoundRow;
use App\Widget\WidgetContainerInterface;
use App\Widget\WidgetService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
@@ -29,9 +31,9 @@ class DashboardController extends AbstractController
*/ */
protected $eventDispatcher; protected $eventDispatcher;
/** /**
* @var WidgetRepository * @var WidgetService
*/ */
protected $repository; protected $widgets;
/** /**
* @var array * @var array
*/ */
@@ -39,13 +41,13 @@ class DashboardController extends AbstractController
/** /**
* @param EventDispatcherInterface $dispatcher * @param EventDispatcherInterface $dispatcher
* @param WidgetRepository $repository * @param WidgetService $service
* @param array $dashboard * @param array $dashboard
*/ */
public function __construct(EventDispatcherInterface $dispatcher, WidgetRepository $repository, array $dashboard) public function __construct(EventDispatcherInterface $dispatcher, WidgetService $service, array $dashboard)
{ {
$this->eventDispatcher = $dispatcher; $this->eventDispatcher = $dispatcher;
$this->repository = $repository; $this->widgets = $service;
$this->dashboard = $dashboard; $this->dashboard = $dashboard;
} }
@@ -61,22 +63,26 @@ class DashboardController extends AbstractController
continue; continue;
} }
if (!$this->isGranted($widgetRow['permission'])) { if (null !== $widgetRow['permission'] && !$this->isGranted($widgetRow['permission'])) {
continue; continue;
} }
$row = new DashboardSection($widgetRow['title'] ?? null); // TODO this should be dynamic
$row if ($widgetRow['type'] === 'compoundChart') {
->setOrder($widgetRow['order']) $row = new CompoundChart();
->setType($widgetRow['type']) } else {
; $row = new CompoundRow();
}
$row->setTitle($widgetRow['title'] ?? '');
$row->setOrder($widgetRow['order']);
foreach ($widgetRow['widgets'] as $widgetName) { foreach ($widgetRow['widgets'] as $widgetName) {
if (!$this->repository->has($widgetName)) { if (!$this->widgets->hasWidget($widgetName)) {
throw new \Exception('Unknwon widget: ' . $widgetName); throw new \Exception(sprintf('Unknown widget "%s"', $widgetName));
} }
$row->addWidget($this->repository->get($widgetName, $event->getUser())); $row->addWidget($this->widgets->getWidget($widgetName));
} }
$event->addSection($row); $event->addSection($row);
@@ -91,7 +97,7 @@ class DashboardController extends AbstractController
uasort( uasort(
$sections, $sections,
function (DashboardSection $a, DashboardSection $b) { function (WidgetContainerInterface $a, WidgetContainerInterface $b) {
if ($a->getOrder() == $b->getOrder()) { if ($a->getOrder() == $b->getOrder()) {
return 0; return 0;
} }
@@ -101,7 +107,7 @@ class DashboardController extends AbstractController
); );
return $this->render('dashboard/index.html.twig', [ return $this->render('dashboard/index.html.twig', [
'widget_rows' => $sections 'widgets' => $sections
]); ]);
} }
} }

View File

@@ -0,0 +1,41 @@
<?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\DependencyInjection\Compiler;
use App\Kernel;
use App\Repository\WidgetRepository;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Dynamically adds all widgets to the WidgetRepository.
*/
class WidgetCompilerPass implements CompilerPassInterface
{
/**
* @param ContainerBuilder $container
* @throws \Exception
*/
public function process(ContainerBuilder $container)
{
// always first check if the primary service is defined
if (!$container->has(WidgetRepository::class)) {
return;
}
$definition = $container->findDefinition(WidgetRepository::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_WIDGET);
foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('registerWidget', [new Reference($id)]);
}
}
}

View File

@@ -9,8 +9,6 @@
namespace App\DependencyInjection; namespace App\DependencyInjection;
use App\Model\DashboardSection;
use App\Model\Widget;
use App\Timesheet\Rounding\RoundingInterface; use App\Timesheet\Rounding\RoundingInterface;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\Builder\TreeBuilder;
@@ -319,6 +317,15 @@ class Configuration implements ConfigurationInterface
->booleanNode('show_about') ->booleanNode('show_about')
->defaultTrue() ->defaultTrue()
->end() ->end()
->arrayNode('chart')
->addDefaultsIfNotSet()
->children()
->scalarNode('background_color')->defaultValue('rgba(0,115,183,0.7)')->end()
->scalarNode('border_color')->defaultValue('#3b8bba')->end()
->scalarNode('grid_color')->defaultValue('rgba(0,0,0,.05)')->end()
->scalarNode('height')->defaultValue('200')->end()
->end()
->end()
->end() ->end()
; ;
@@ -365,12 +372,7 @@ class Configuration implements ConfigurationInterface
->scalarNode('end')->end() ->scalarNode('end')->end()
->scalarNode('icon')->defaultValue('')->end() ->scalarNode('icon')->defaultValue('')->end()
->scalarNode('color')->defaultValue('')->end() ->scalarNode('color')->defaultValue('')->end()
->scalarNode('type') ->scalarNode('type')->defaultValue('counter')->end()
->validate()
->ifNotInArray([Widget::TYPE_COUNTER, Widget::TYPE_MORE])->thenInvalid('Unknown widget type')
->end()
->defaultValue(Widget::TYPE_COUNTER)
->end()
->end() ->end()
->end() ->end()
; ;
@@ -390,15 +392,10 @@ class Configuration implements ConfigurationInterface
->arrayPrototype() ->arrayPrototype()
->addDefaultsIfNotSet() ->addDefaultsIfNotSet()
->children() ->children()
->scalarNode('type') ->scalarNode('type')->defaultValue('simple')->end()
->validate()
->ifNotInArray([DashboardSection::TYPE_SIMPLE, DashboardSection::TYPE_CHART])->thenInvalid('Unknown section type')
->end()
->defaultValue(DashboardSection::TYPE_SIMPLE)
->end()
->integerNode('order')->defaultValue(0)->end() ->integerNode('order')->defaultValue(0)->end()
->scalarNode('title')->end() ->scalarNode('title')->end()
->scalarNode('permission')->isRequired()->end() ->scalarNode('permission')->defaultNull()->end()
->arrayNode('widgets') ->arrayNode('widgets')
->isRequired() ->isRequired()
->performNoDeepMerging() ->performNoDeepMerging()

View File

@@ -10,6 +10,7 @@
namespace App\Entity; namespace App\Entity;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
trait BudgetTrait trait BudgetTrait
{ {

View File

@@ -10,7 +10,7 @@
namespace App\Event; namespace App\Event;
use App\Entity\User; use App\Entity\User;
use App\Model\DashboardSection; use App\Widget\WidgetContainerInterface;
use Symfony\Component\EventDispatcher\Event; use Symfony\Component\EventDispatcher\Event;
class DashboardEvent extends Event class DashboardEvent extends Event
@@ -22,41 +22,31 @@ class DashboardEvent extends Event
*/ */
protected $user; protected $user;
/** /**
* @var DashboardSection[] * @var WidgetContainerInterface[]
*/ */
protected $widgetRows = []; protected $widgetRows = [];
/**
* @param User $user
*/
public function __construct(User $user) public function __construct(User $user)
{ {
$this->user = $user; $this->user = $user;
} }
/** public function getUser(): User
* @return User
*/
public function getUser()
{ {
return $this->user; return $this->user;
} }
/** public function addSection(WidgetContainerInterface $container): DashboardEvent
* @param DashboardSection $row
* @return DashboardEvent
*/
public function addSection(DashboardSection $row)
{ {
$this->widgetRows[] = $row; $this->widgetRows[] = $container;
return $this; return $this;
} }
/** /**
* @return DashboardSection[] * @return WidgetContainerInterface[]
*/ */
public function getSections() public function getSections(): array
{ {
return $this->widgetRows; return $this->widgetRows;
} }

View File

@@ -11,12 +11,12 @@ namespace App\EventSubscriber;
use App\Entity\User; use App\Entity\User;
use App\Event\DashboardEvent; use App\Event\DashboardEvent;
use App\Model\DashboardSection;
use App\Model\Widget;
use App\Repository\ActivityRepository; use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Widget\Type\CompoundRow;
use App\Widget\Type\More;
use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
@@ -79,7 +79,6 @@ class DashboardSubscriber implements EventSubscriberInterface
/** /**
* @param DashboardEvent $event * @param DashboardEvent $event
* @throws \Doctrine\ORM\NonUniqueResultException
*/ */
public function onDashboardEvent(DashboardEvent $event) public function onDashboardEvent(DashboardEvent $event)
{ {
@@ -87,54 +86,68 @@ class DashboardSubscriber implements EventSubscriberInterface
return; return;
} }
$this->addAdminWidgets($event); $section = new CompoundRow();
} $section->setTitle('ROLE_ADMIN');
/**
* @param DashboardEvent $event
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function addAdminWidgets(DashboardEvent $event)
{
$section = new DashboardSection('dashboard.admin');
$section->setOrder(100); $section->setOrder(100);
$widget = new Widget('stats.userTotal', $this->user->countUser()); if ($this->security->isGranted('view_user')) {
$widget $section->addWidget(
->setRoute('admin_user') (new More())
->setIcon('user') ->setId('userTotal')
->setColor('green') ->setTitle('stats.userTotal')
->setType(Widget::TYPE_MORE) ->setData($this->user->countUser())
; ->setOptions([
$section->addWidget($widget); 'route' => 'admin_user',
'icon' => 'user',
'color' => 'primary',
])
);
}
$widget = new Widget('stats.customerTotal', $this->customer->countCustomer()); if ($this->security->isGranted('view_customer')) {
$widget $section->addWidget(
->setRoute('admin_customer') (new More())
->setIcon('customer') ->setId('customerTotal')
->setColor('blue') ->setTitle('stats.customerTotal')
->setType(Widget::TYPE_MORE) ->setData($this->customer->countCustomer())
; ->setOptions([
$section->addWidget($widget); 'route' => 'admin_customer',
'icon' => 'customer',
'color' => 'primary',
])
);
}
$widget = new Widget('stats.projectTotal', $this->project->countProject()); if ($this->security->isGranted('view_project')) {
$widget $section->addWidget(
->setRoute('admin_project') (new More())
->setIcon('project') ->setId('projectTotal')
->setColor('yellow') ->setTitle('stats.projectTotal')
->setType(Widget::TYPE_MORE) ->setData($this->project->countProject())
; ->setOptions([
$section->addWidget($widget); 'route' => 'admin_project',
'icon' => 'project',
'color' => 'primary',
])
);
}
$widget = new Widget('stats.activityTotal', $this->activity->countActivity()); if ($this->security->isGranted('view_activity')) {
$widget $section->addWidget(
->setRoute('admin_activity') (new More())
->setIcon('activity') ->setId('activityTotal')
->setColor('purple') ->setTitle('stats.activityTotal')
->setType(Widget::TYPE_MORE) ->setData($this->activity->countActivity())
; ->setOptions([
$section->addWidget($widget); 'route' => 'admin_activity',
'icon' => 'activity',
'color' => 'primary',
])
);
}
$event->addSection($section); if (count($section->getWidgets()) > 0) {
$event->addSection($section);
}
} }
} }

View File

@@ -13,6 +13,7 @@ use App\DependencyInjection\AppExtension;
use App\DependencyInjection\Compiler\DoctrineCompilerPass; use App\DependencyInjection\Compiler\DoctrineCompilerPass;
use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass; use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass;
use App\DependencyInjection\Compiler\TwigContextCompilerPass; use App\DependencyInjection\Compiler\TwigContextCompilerPass;
use App\DependencyInjection\Compiler\WidgetCompilerPass;
use App\Export\RendererInterface as ExportRendererInterface; use App\Export\RendererInterface as ExportRendererInterface;
use App\Invoice\CalculatorInterface as InvoiceCalculator; use App\Invoice\CalculatorInterface as InvoiceCalculator;
use App\Invoice\NumberGeneratorInterface; use App\Invoice\NumberGeneratorInterface;
@@ -20,6 +21,8 @@ use App\Invoice\RendererInterface as InvoiceRendererInterface;
use App\Ldap\FormLoginLdapFactory; use App\Ldap\FormLoginLdapFactory;
use App\Plugin\PluginInterface; use App\Plugin\PluginInterface;
use App\Timesheet\CalculatorInterface as TimesheetCalculator; use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use App\Widget\WidgetInterface;
use App\Widget\WidgetRendererInterface;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension; use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension;
use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\Config\Loader\LoaderInterface;
@@ -37,10 +40,13 @@ class Kernel extends BaseKernel
public const CONFIG_EXTS = '.{php,xml,yaml,yml}'; public const CONFIG_EXTS = '.{php,xml,yaml,yml}';
public const TAG_PLUGIN = 'kimai.plugin'; public const TAG_PLUGIN = 'kimai.plugin';
public const TAG_WIDGET = 'widget';
public const TAG_WIDGET_RENDERER = 'widget.renderer';
public const TAG_EXPORT_RENDERER = 'export.renderer'; public const TAG_EXPORT_RENDERER = 'export.renderer';
public const TAG_INVOICE_RENDERER = 'invoice.renderer'; public const TAG_INVOICE_RENDERER = 'invoice.renderer';
public const TAG_INVOICE_NUMBER_GENERATOR = 'invoice.number_generator'; public const TAG_INVOICE_NUMBER_GENERATOR = 'invoice.number_generator';
public const TAG_INVOICE_CALCULATOR = 'invoice.calculator'; public const TAG_INVOICE_CALCULATOR = 'invoice.calculator';
public const TAG_TIMESHEET_CALCULATOR = 'timesheet.calculator';
public function getCacheDir() public function getCacheDir()
{ {
@@ -54,12 +60,14 @@ class Kernel extends BaseKernel
protected function build(ContainerBuilder $container) protected function build(ContainerBuilder $container)
{ {
$container->registerForAutoconfiguration(TimesheetCalculator::class)->addTag('timesheet.calculator'); $container->registerForAutoconfiguration(TimesheetCalculator::class)->addTag(self::TAG_TIMESHEET_CALCULATOR);
$container->registerForAutoconfiguration(ExportRendererInterface::class)->addTag(self::TAG_EXPORT_RENDERER); $container->registerForAutoconfiguration(ExportRendererInterface::class)->addTag(self::TAG_EXPORT_RENDERER);
$container->registerForAutoconfiguration(InvoiceRendererInterface::class)->addTag(self::TAG_INVOICE_RENDERER); $container->registerForAutoconfiguration(InvoiceRendererInterface::class)->addTag(self::TAG_INVOICE_RENDERER);
$container->registerForAutoconfiguration(NumberGeneratorInterface::class)->addTag(self::TAG_INVOICE_NUMBER_GENERATOR); $container->registerForAutoconfiguration(NumberGeneratorInterface::class)->addTag(self::TAG_INVOICE_NUMBER_GENERATOR);
$container->registerForAutoconfiguration(InvoiceCalculator::class)->addTag(self::TAG_INVOICE_CALCULATOR); $container->registerForAutoconfiguration(InvoiceCalculator::class)->addTag(self::TAG_INVOICE_CALCULATOR);
$container->registerForAutoconfiguration(PluginInterface::class)->addTag(self::TAG_PLUGIN); $container->registerForAutoconfiguration(PluginInterface::class)->addTag(self::TAG_PLUGIN);
$container->registerForAutoconfiguration(WidgetRendererInterface::class)->addTag(self::TAG_WIDGET_RENDERER);
$container->registerForAutoconfiguration(WidgetInterface::class)->addTag(self::TAG_WIDGET);
/** @var SecurityExtension $extension */ /** @var SecurityExtension $extension */
$extension = $container->getExtension('security'); $extension = $container->getExtension('security');
@@ -144,6 +152,7 @@ class Kernel extends BaseKernel
$container->addCompilerPass(new DoctrineCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); $container->addCompilerPass(new DoctrineCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new TwigContextCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); $container->addCompilerPass(new TwigContextCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new InvoiceServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); $container->addCompilerPass(new InvoiceServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new WidgetCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
} }
protected function configureRoutes(RouteCollectionBuilder $routes) protected function configureRoutes(RouteCollectionBuilder $routes)

View File

@@ -1,106 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
class DashboardSection
{
public const TYPE_SIMPLE = 'simple';
public const TYPE_CHART = 'chart';
/**
* @var null|string
*/
protected $title;
/**
* @var Widget[]
*/
protected $widgets = [];
/**
* @var int
*/
protected $order = 0;
/**
* @var string
*/
protected $type = self::TYPE_SIMPLE;
/**
* @param null|string $title
*/
public function __construct(?string $title)
{
$this->title = $title;
}
/**
* @return string
*/
public function getType(): string
{
return $this->type;
}
/**
* @param string $type
* @return DashboardSection
*/
public function setType(string $type)
{
$this->type = $type;
return $this;
}
/**
* @return int
*/
public function getOrder(): int
{
return $this->order;
}
/**
* @param int $order
* @return DashboardSection
*/
public function setOrder(int $order)
{
$this->order = $order;
return $this;
}
/**
* @return string
*/
public function getTitle(): ?string
{
return $this->title;
}
/**
* @return Widget[]
*/
public function getWidgets(): array
{
return $this->widgets;
}
/**
* @param Widget $widget
* @return DashboardSection
*/
public function addWidget(Widget $widget)
{
$this->widgets[] = $widget;
return $this;
}
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model\Statistic;
use DateTime;
class Day
{
/**
* @var int
*/
protected $totalDuration = 0;
/**
* @var float
*/
protected $totalRate = 0.00;
/**
* @var DateTime
*/
protected $day;
public function __construct(DateTime $day, int $duration, float $rate)
{
$this->day = $day;
$this->totalDuration = $duration;
$this->totalRate = $rate;
}
public function getDay(): DateTime
{
return $this->day;
}
public function getTotalDuration(): int
{
return $this->totalDuration;
}
public function setTotalDuration(int $seconds): Day
{
$this->totalDuration = $seconds;
return $this;
}
public function getTotalRate(): float
{
return $this->totalRate;
}
public function setTotalRate(float $totalRate): Day
{
$this->totalRate = $totalRate;
return $this;
}
}

View File

@@ -9,6 +9,8 @@
namespace App\Model\Statistic; namespace App\Model\Statistic;
use InvalidArgumentException;
/** /**
* Monthly statistics * Monthly statistics
*/ */
@@ -23,9 +25,9 @@ class Month
*/ */
protected $totalDuration = 0; protected $totalDuration = 0;
/** /**
* @var int * @var float
*/ */
protected $totalRate = 0; protected $totalRate = 0.00;
/** /**
* @param string $month * @param string $month
@@ -34,7 +36,9 @@ class Month
{ {
$monthNumber = (int) $month; $monthNumber = (int) $month;
if ($monthNumber < 1 || $monthNumber > 12) { if ($monthNumber < 1 || $monthNumber > 12) {
throw new \InvalidArgumentException('Invalid month given, expected 01-12 but given: ' . $monthNumber); throw new InvalidArgumentException(
sprintf('Invalid month given. Expected 1-12, received "%s".', $monthNumber)
);
} }
$this->month = $month; $this->month = $month;
} }
@@ -47,38 +51,24 @@ class Month
return $this->month; return $this->month;
} }
/** public function getTotalDuration(): int
* @return int
*/
public function getTotalDuration()
{ {
return $this->totalDuration; return $this->totalDuration;
} }
/** public function setTotalDuration(int $seconds): Month
* @param int $totalDuration
* @return $this
*/
public function setTotalDuration($totalDuration)
{ {
$this->totalDuration = $totalDuration; $this->totalDuration = $seconds;
return $this; return $this;
} }
/** public function getTotalRate(): float
* @return int
*/
public function getTotalRate()
{ {
return $this->totalRate; return $this->totalRate;
} }
/** public function setTotalRate(float $totalRate): Month
* @param int $totalRate
* @return $this
*/
public function setTotalRate($totalRate)
{ {
$this->totalRate = $totalRate; $this->totalRate = $totalRate;

View File

@@ -24,7 +24,6 @@ class Year
protected $months = []; protected $months = [];
/** /**
* Year constructor.
* @param string $year * @param string $year
*/ */
public function __construct($year) public function __construct($year)
@@ -40,22 +39,14 @@ class Year
return $this->year; return $this->year;
} }
/** public function setMonth(Month $month): Year
* @param Month $month
* @return $this
*/
public function setMonth(Month $month)
{ {
$this->months[(int) $month->getMonth()] = $month; $this->months[(int) $month->getMonth()] = $month;
return $this; return $this;
} }
/** public function getMonth(int $month): ?Month
* @param int $month
* @return null|Month
*/
public function getMonth(int $month)
{ {
if (isset($this->months[$month])) { if (isset($this->months[$month])) {
return $this->months[$month]; return $this->months[$month];
@@ -67,7 +58,7 @@ class Year
/** /**
* @return Month[] * @return Month[]
*/ */
public function getMonths() public function getMonths(): array
{ {
return array_values($this->months); return array_values($this->months);
} }

View File

@@ -1,219 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Model;
class Widget
{
public const TYPE_COUNTER = 'counter';
public const TYPE_MORE = 'more';
public const DATA_TYPE_INT = 'int';
public const DATA_TYPE_MONEY = 'money';
public const DATA_TYPE_DURATION = 'duration';
/**
* @var string|null
*/
protected $title;
/**
* @var string
*/
protected $icon = '';
/**
* @var string
*/
protected $color = '';
/**
* @var string
*/
protected $type = self::TYPE_COUNTER;
/**
* @var string
*/
protected $range;
/**
* @var string|null
*/
protected $route;
/**
* @var array
*/
protected $routeOptions = [];
/**
* @var mixed
*/
protected $data;
/**
* @var string
*/
protected $dataType = self::DATA_TYPE_INT;
/**
* @param string $title
* @param mixed $data
*/
public function __construct(string $title, $data)
{
$this->title = $title;
$this->data = $data;
}
/**
* @return string
*/
public function getDataType(): string
{
return $this->dataType;
}
/**
* @param string $dataType
* @return Widget
*/
public function setDataType(string $dataType)
{
$this->dataType = $dataType;
return $this;
}
/**
* @return array
*/
public function getRouteOptions(): array
{
return $this->routeOptions;
}
/**
* @param array $routeOptions
* @return Widget
*/
public function setRouteOptions(array $routeOptions)
{
$this->routeOptions = $routeOptions;
return $this;
}
/**
* @return string|null
*/
public function getRoute(): ?string
{
return $this->route;
}
/**
* @param string $route
* @return Widget
*/
public function setRoute(string $route)
{
$this->route = $route;
return $this;
}
/**
* @return mixed
*/
public function getData()
{
return $this->data;
}
/**
* @param mixed $data
* @return Widget
*/
public function setData($data)
{
$this->data = $data;
return $this;
}
/**
* @return string|null
*/
public function getTitle(): ?string
{
return $this->title;
}
/**
* @param string $title
* @return Widget
*/
public function setTitle(string $title)
{
$this->title = $title;
return $this;
}
/**
* @return string
*/
public function getIcon(): string
{
return $this->icon;
}
/**
* @param string $icon
* @return Widget
*/
public function setIcon(string $icon)
{
$this->icon = $icon;
return $this;
}
/**
* @return string
*/
public function getColor(): string
{
return $this->color;
}
/**
* @param string $color
* @return Widget
*/
public function setColor(string $color)
{
$this->color = $color;
return $this;
}
/**
* @return string
*/
public function getType(): string
{
return $this->type;
}
/**
* @param string $type
* @return Widget
*/
public function setType(string $type)
{
$this->type = $type;
return $this;
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Repository;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Model\Statistic\Day;
use App\Model\Statistic\Month; use App\Model\Statistic\Month;
use App\Model\Statistic\Year; use App\Model\Statistic\Year;
use App\Model\TimesheetStatistic; use App\Model\TimesheetStatistic;
@@ -98,6 +99,9 @@ class TimesheetRepository extends AbstractRepository
case self::STATS_QUERY_MONTHLY: case self::STATS_QUERY_MONTHLY:
return $this->getMonthlyStats($user, $begin, $end); return $this->getMonthlyStats($user, $begin, $end);
case 'daily':
return $this->getDailyStats($user, $begin, $end);
case self::STATS_QUERY_DURATION: case self::STATS_QUERY_DURATION:
$what = 'SUM(t.duration)'; $what = 'SUM(t.duration)';
break; break;
@@ -253,14 +257,87 @@ class TimesheetRepository extends AbstractRepository
} }
$month = new Month($statRow['month']); $month = new Month($statRow['month']);
$month->setTotalDuration($statRow['duration']) $month->setTotalDuration((int) $statRow['duration'])
->setTotalRate($statRow['rate']); ->setTotalRate((float) $statRow['rate']);
$years[$curYear]->setMonth($month); $years[$curYear]->setMonth($month);
} }
return $years; return $years;
} }
/**
* @param DateTime $begin
* @param DateTime $end
* @param User|null $user
* @return mixed
*/
public function getDailyData(DateTime $begin, DateTime $end, ?User $user = null)
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->addSelect('SUM(t.rate) as rate')
->addSelect('SUM(t.duration) as duration')
->addSelect('MONTH(t.begin) as month')
->addSelect('YEAR(t.begin) as year')
->addSelect('DAY(t.begin) as day')
->from(Timesheet::class, 't')
->andWhere($qb->expr()->gte('t.begin', ':from'))
->setParameter('from', $begin, Type::DATETIME)
->andWhere($qb->expr()->lte('t.end', ':to'))
->setParameter('to', $end, Type::DATETIME);
if (null !== $user) {
$qb->andWhere('t.user = :user')
->setParameter('user', $user);
}
$qb
->addGroupBy('year')
->addGroupBy('month')
->addGroupBy('day')
->addOrderBy('year', 'DESC')
->addOrderBy('month', 'ASC')
->addOrderBy('day', 'ASC')
;
return $qb->getQuery()->execute();
}
/**
* @param User $user
* @param DateTime $begin
* @param DateTime $end
* @return Day[]
* @throws \Exception
*/
public function getDailyStats(User $user, DateTime $begin, DateTime $end): array
{
$results = $this->getDailyData($begin, $end, $user);
/** @var Day[] $days */
$days = [];
// prefill the array
$tmp = clone $end;
$until = (int) $begin->format('Ymd');
while ((int) $tmp->format('Ymd') > $until) {
$tmp->modify('-1 day');
$last = clone $tmp;
$days[$last->format('Ymd')] = new Day($last, 0, 0.00);
}
foreach ($results as $statRow) {
$dateTime = new DateTime();
$dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']);
$days[$dateTime->format('Ymd')] = new Day($dateTime, (int) $statRow['duration'], (float) $statRow['rate']);
}
ksort($days);
return array_values($days);
}
/** /**
* @param User $user * @param User $user
* @return Timesheet[]|null * @return Timesheet[]|null

View File

@@ -10,8 +10,14 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\User; use App\Entity\User;
use App\Model\Widget; use App\Security\CurrentUser;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\WidgetException;
use App\Widget\WidgetInterface;
/**
* @internal
*/
class WidgetRepository class WidgetRepository
{ {
/** /**
@@ -22,61 +28,398 @@ class WidgetRepository
* @var array * @var array
*/ */
protected $widgets = []; protected $widgets = [];
/**
* @var array
*/
protected $definitions = [];
/**
* @var User|null
*/
protected $user;
/** /**
* @param TimesheetRepository $repository * @param TimesheetRepository $repository
* @param CurrentUser $user
* @param array $widgets * @param array $widgets
*/ */
public function __construct(TimesheetRepository $repository, array $widgets) public function __construct(TimesheetRepository $repository, CurrentUser $user, array $widgets)
{ {
$this->repository = $repository; $this->repository = $repository;
$this->widgets = $widgets; $this->user = $user->getUser();
$this->definitions = array_merge($this->getDefaultWidgets(), $widgets);
} }
/** public function has(string $id): bool
* @param string $name
* @return bool
*/
public function has(string $name)
{ {
return isset($this->widgets[$name]); return isset($this->definitions[$id]) || isset($this->widgets[$id]);
} }
/** public function registerWidget(WidgetInterface $widget): WidgetRepository
* @param string $name
* @param User|null $user
* @return Widget
* @throws \InvalidArgumentException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function get(string $name, ?User $user)
{ {
if (!$this->has($name)) { if (!empty($widget->getId())) {
throw new \InvalidArgumentException('Cannot find widget: ' . $name); $this->widgets[$widget->getId()] = $widget;
} }
$widget = $this->widgets[$name]; return $this;
}
public function get(string $id): WidgetInterface
{
if (!$this->has($id)) {
throw new \InvalidArgumentException(sprintf('Cannot find widget "%s".', $id));
}
if (isset($this->widgets[$id])) {
return $this->widgets[$id];
}
// this code should ONLY be reached for internal (pre-registered) widgets
$this->registerWidget($this->create($id, $this->definitions[$id]));
return $this->widgets[$id];
}
protected function create(string $name, array $widget): WidgetInterface
{
$user = $this->user;
$begin = !empty($widget['begin']) ? new \DateTime($widget['begin']) : null; $begin = !empty($widget['begin']) ? new \DateTime($widget['begin']) : null;
$end = !empty($widget['end']) ? new \DateTime($widget['end']) : null; $end = !empty($widget['end']) ? new \DateTime($widget['end']) : null;
$theUser = $widget['user'] ? $user : null; $theUser = $widget['user'] ? $user : null;
$type = $widget['type'] ?? Widget::TYPE_COUNTER;
if (!isset($widget['type'])) {
@trigger_error('Using a widget definition without a "type" is deprecated', E_USER_DEPRECATED);
$widget['type'] = 'counter';
}
$widgetClassName = '\\App\\Widget\\Type\\' . ucfirst($widget['type']);
if (!class_exists($widgetClassName)) {
throw new WidgetException(sprintf('Unknown widget type "%s"', $widgetClassName));
}
$model = new \ReflectionClass($widgetClassName);
if (!$model->isSubclassOf(AbstractWidgetType::class)) {
throw new WidgetException(sprintf('Invalid widget type "%s" does not extend AbstractWidgetType', $widgetClassName));
}
$data = $this->repository->getStatistic($widget['query'], $begin, $end, $theUser); $data = $this->repository->getStatistic($widget['query'], $begin, $end, $theUser);
$model = new Widget($widget['title'], $data); /** @var AbstractWidgetType $model */
$model = new $widgetClassName();
$model $model
->setColor($widget['color']) ->setId($name)
->setIcon($widget['icon']) ->setTitle($widget['title'])
->setType($type) ->setData($data);
;
if ($widget['query'] == TimesheetRepository::STATS_QUERY_DURATION) { if ($widget['query'] == TimesheetRepository::STATS_QUERY_DURATION) {
$model->setDataType(Widget::DATA_TYPE_DURATION); $model->setOption('dataType', 'duration');
} elseif ($widget['query'] == TimesheetRepository::STATS_QUERY_RATE) { } elseif ($widget['query'] == TimesheetRepository::STATS_QUERY_RATE) {
$model->setDataType(Widget::DATA_TYPE_MONEY); $model->setOption('dataType', 'money');
} else {
$model->setOption('dataType', 'int');
}
if (isset($widget['color'])) {
$model->setOption('color', $widget['color']);
}
if (isset($widget['icon'])) {
$model->setOption('icon', $widget['icon']);
} }
return $model; return $model;
} }
protected function getDefaultWidgets(): array
{
return
[
'userDurationToday' => [
'title' => 'stats.durationToday',
'query' => 'duration',
'user' => true,
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'duration',
'color' => 'green',
'type' => 'counter'
],
'userDurationWeek' => [
'title' => 'stats.durationWeek',
'query' => 'duration',
'user' => true,
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'icon' => 'duration',
'color' => 'blue',
'type' => 'counter'
],
'userDurationMonth' => [
'title' => 'stats.durationMonth',
'query' => 'duration',
'user' => true,
'begin' => 'first day of this month 00:00:00',
'end' => 'last day of this month 23:59:59',
'icon' => 'duration',
'color' => 'purple',
'type' => 'counter'
],
'userDurationYear' => [
'title' => 'stats.durationYear',
'query' => 'duration',
'user' => true,
'begin' => '01 january this year 00:00:00',
'end' => '31 december this year 23:59:59',
'icon' => 'duration',
'color' => 'yellow',
'type' => 'counter'
],
'userDurationTotal' => [
'title' => 'stats.durationTotal',
'query' => 'duration',
'user' => true,
'icon' => 'duration',
'color' => 'red',
'type' => 'counter'
],
'userAmountToday' => [
'title' => 'stats.amountToday',
'query' => 'rate',
'user' => true,
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'money',
'color' => 'green',
'type' => 'counter'
],
'userAmountWeek' => [
'title' => 'stats.amountWeek',
'query' => 'rate',
'user' => true,
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'icon' => 'money',
'color' => 'blue',
'type' => 'counter'
],
'userAmountMonth' => [
'title' => 'stats.amountMonth',
'query' => 'rate',
'user' => true,
'begin' => 'first day of this month 00:00:00',
'end' => 'last day of this month 23:59:59',
'icon' => 'money',
'color' => 'purple',
'type' => 'counter'
],
'userAmountYear' => [
'title' => 'stats.amountYear',
'query' => 'rate',
'user' => true,
'begin' => '01 january this year 00:00:00',
'end' => '31 december this year 23:59:59',
'icon' => 'money',
'color' => 'yellow',
'type' => 'counter'
],
'userAmountTotal' => [
'title' => 'stats.amountTotal',
'query' => 'rate',
'user' => true,
'icon' => 'money',
'color' => 'red',
'type' => 'counter'
],
'durationToday' => [
'title' => 'stats.durationToday',
'query' => 'duration',
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'duration',
'color' => 'green',
'user' => false,
'type' => 'counter'
],
'durationWeek' => [
'title' => 'stats.durationWeek',
'query' => 'duration',
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'icon' => 'duration',
'color' => 'blue',
'user' => false,
'type' => 'counter'
],
'durationMonth' => [
'title' => 'stats.durationMonth',
'query' => 'duration',
'begin' => 'first day of this month 00:00:00',
'end' => 'last day of this month 23:59:59',
'icon' => 'duration',
'color' => 'purple',
'user' => false,
'type' => 'counter'
],
'durationYear' => [
'title' => 'stats.durationYear',
'query' => 'duration',
'begin' => '01 january this year 00:00:00',
'end' => '31 december this year 23:59:59',
'icon' => 'duration',
'color' => 'yellow',
'user' => false,
'type' => 'counter'
],
'durationTotal' => [
'title' => 'stats.durationTotal',
'query' => 'duration',
'icon' => 'duration',
'color' => 'red',
'user' => false,
'type' => 'counter'
],
'amountToday' => [
'title' => 'stats.amountToday',
'query' => 'rate',
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'money',
'color' => 'green',
'user' => false,
'type' => 'counter'
],
'amountWeek' => [
'title' => 'stats.amountWeek',
'query' => 'rate',
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'icon' => 'money',
'color' => 'blue',
'user' => false,
'type' => 'counter'
],
'amountMonth' => [
'title' => 'stats.amountMonth',
'query' => 'rate',
'begin' => 'first day of this month 00:00:00',
'end' => 'last day of this month 23:59:59',
'icon' => 'money',
'color' => 'purple',
'user' => false,
'type' => 'counter'
],
'amountYear' => [
'title' => 'stats.amountYear',
'query' => 'rate',
'begin' => '01 january this year 00:00:00',
'end' => '31 december this year 23:59:59',
'icon' => 'money',
'color' => 'yellow',
'user' => false,
'type' => 'counter'
],
'amountTotal' => [
'title' => 'stats.amountTotal',
'query' => 'rate',
'icon' => 'money',
'color' => 'red',
'user' => false,
'type' => 'counter'
],
'activeUsersToday' => [
'title' => 'stats.userActiveToday',
'query' => 'users',
'begin' => '00:00:00',
'end' => '23:59:59',
'icon' => 'user',
'color' => 'green',
'user' => false,
'type' => 'counter'
],
'activeUsersWeek' => [
'title' => 'stats.userActiveWeek',
'query' => 'users',
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'icon' => 'user',
'color' => 'blue',
'user' => false,
'type' => 'counter'
],
'activeUsersMonth' => [
'title' => 'stats.userActiveMonth',
'query' => 'users',
'begin' => 'first day of this month 00:00:00',
'end' => 'last day of this month 23:59:59',
'icon' => 'user',
'color' => 'purple',
'user' => false,
'type' => 'counter'
],
'activeUsersYear' => [
'title' => 'stats.userActiveYear',
'query' => 'users',
'begin' => '01 january this year 00:00:00',
'end' => '31 december this year 23:59:59',
'icon' => 'user',
'color' => 'yellow',
'user' => false,
'type' => 'counter'
],
'activeUsersTotal' => [
'title' => 'stats.userActiveTotal',
'query' => 'users',
'icon' => 'user',
'color' => 'red',
'user' => false,
'type' => 'counter'
],
'activeRecordings' => [
'title' => 'stats.activeRecordings',
'query' => 'active',
'icon' => 'duration',
'color' => 'red',
'user' => false,
'type' => 'counter'
],
'userRecapThisYear' => [
'title' => 'stats.yourWorkingHours',
'query' => 'monthly',
'user' => true,
'begin' => '01 january this year 00:00:00',
'end' => '31 december this year 23:59:59',
'color' => '',
'icon' => '',
'type' => 'yearChart'
],
'userRecapLastYear' => [
'title' => 'stats.yourWorkingHours',
'query' => 'monthly',
'user' => true,
'begin' => '01 january last year 00:00:00',
'end' => '31 december last year 23:59:59',
'color' => 'rgba(0,115,183,0.7)|#3b8bba',
'icon' => '',
'type' => 'yearChart'
],
'userRecapTwoYears' => [
'title' => 'stats.yourWorkingHours',
'query' => 'monthly',
'user' => true,
'begin' => '01 january last year 00:00:00',
'end' => '31 december this year 23:59:59',
'color' => 'rgba(0,115,183,0.6)|#3b8bba;rgba(233,233,233,0.8)|#ccc',
'icon' => '',
'type' => 'yearChart'
],
'userRecapThreeYears' => [
'title' => 'stats.yourWorkingHours',
'query' => 'monthly',
'user' => true,
'begin' => '2 years ago first day of january 00:00:00',
'end' => 'this year last day of december 23:59:59',
'color' => 'rgba(0,115,183,0.4)|#3b8bba;rgba(233,233,233,0.7)|#ccc;rgba(210,214,222,0.9)|#c1c7d1',
'icon' => '',
'type' => 'yearChart'
],
];
}
} }

View File

@@ -0,0 +1,65 @@
<?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\Twig;
use App\Widget\WidgetException;
use App\Widget\WidgetInterface;
use App\Widget\WidgetService;
use InvalidArgumentException;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class WidgetExtension extends AbstractExtension
{
/**
* @var WidgetService
*/
protected $service;
public function __construct(WidgetService $service)
{
$this->service = $service;
}
/**
* {@inheritdoc}
*/
public function getFunctions()
{
return [
new TwigFunction('render_widget', [$this, 'renderWidget'], ['is_safe' => ['html']]),
];
}
/**
* @param WidgetInterface|string $widget
* @param array $options
* @return string
* @throws WidgetException
*/
public function renderWidget($widget, array $options = [])
{
if (!($widget instanceof WidgetInterface) && !is_string($widget)) {
throw new InvalidArgumentException('Widget must either implement WidgetInterface or be a string');
}
if (is_string($widget)) {
if (!$this->service->hasWidget($widget)) {
throw new InvalidArgumentException(sprintf('Unknown widget "%s" requested', $widget));
}
$widget = $this->service->getWidget($widget);
}
$renderer = $this->service->findRenderer($widget);
return $renderer->render($widget, $options);
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Renderer;
use App\Widget\WidgetRendererInterface;
use Twig\Environment;
abstract class AbstractTwigRenderer implements WidgetRendererInterface
{
/**
* @var Environment
*/
protected $twig;
/**
* @param Environment $twig
*/
public function __construct(Environment $twig)
{
$this->twig = $twig;
}
/**
* @param string $template
* @param array $data
* @return string
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
protected function renderTemplate(string $template, array $data): string
{
return $this->twig->render($template, $data);
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Renderer;
use App\Widget\Type\CompoundChart;
use App\Widget\WidgetInterface;
class CompoundChartRenderer extends AbstractTwigRenderer
{
public function supports(WidgetInterface $widget): bool
{
return ($widget instanceof CompoundChart);
}
public function render(WidgetInterface $widget, array $options = []): string
{
return $this->renderTemplate('widget/section-chart.html.twig', [
'title' => $widget->getTitle(),
'widgets' => $widget->getData(),
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Renderer;
use App\Widget\Type\CompoundRow;
use App\Widget\WidgetInterface;
class CompoundRowRenderer extends AbstractTwigRenderer
{
public function supports(WidgetInterface $widget): bool
{
return ($widget instanceof CompoundRow);
}
public function render(WidgetInterface $widget, array $options = []): string
{
return $this->renderTemplate('widget/section-simple.html.twig', [
'title' => $widget->getTitle(),
'widgets' => $widget->getData(),
]);
}
}

View File

@@ -0,0 +1,33 @@
<?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\Widget\Renderer;
use App\Widget\Type\SimpleWidget;
use App\Widget\WidgetInterface;
use ReflectionClass;
class SimpleWidgetRenderer extends AbstractTwigRenderer
{
public function supports(WidgetInterface $widget): bool
{
return $widget instanceof SimpleWidget;
}
public function render(WidgetInterface $widget, array $options = []): string
{
$name = (new ReflectionClass($widget))->getShortName();
return $this->renderTemplate(sprintf('widget/widget-%s.html.twig', strtolower($name)), [
'data' => $widget->getData($options),
'options' => $widget->getOptions($options),
'title' => $widget->getTitle(),
]);
}
}

View File

@@ -0,0 +1,95 @@
<?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\Widget\Type;
use App\Widget\WidgetContainerInterface;
use App\Widget\WidgetInterface;
use BadMethodCallException;
abstract class AbstractContainer implements WidgetContainerInterface
{
/**
* @var string
*/
protected $title = '';
/**
* @var WidgetInterface[]
*/
protected $widgets = [];
/**
* @var int
*/
protected $order = 0;
public function setTitle(string $title)
{
$this->title = $title;
}
/**
* @param array $options
* @return WidgetInterface[]|array|mixed|null
*/
public function getData(array $options = [])
{
return $this->getWidgets();
}
public function getOptions(array $options = []): array
{
return $options;
}
/**
* @param string $name
* @param mixed $value
*/
public function setOption(string $name, $value): void
{
throw new BadMethodCallException('setOption() is not supported on AbstractContainer');
}
public function getId(): string
{
return $this->title;
}
public function getOrder(): int
{
return $this->order;
}
public function setOrder(int $order)
{
$this->order = $order;
return $this;
}
public function getTitle(): string
{
return $this->title;
}
/**
* @return WidgetInterface[]
*/
public function getWidgets(): array
{
return $this->widgets;
}
public function addWidget(WidgetInterface $widget)
{
$this->widgets[] = $widget;
return $this;
}
}

View File

@@ -0,0 +1,109 @@
<?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\Widget\Type;
use App\Widget\WidgetInterface;
abstract class AbstractWidgetType implements WidgetInterface
{
/**
* @var string
*/
protected $id = '';
/**
* @var string
*/
protected $title = '';
/**
* @var array
*/
protected $options = [];
/**
* @var mixed
*/
protected $data;
public function setId(string $id): AbstractWidgetType
{
$this->id = $id;
return $this;
}
public function getId(): string
{
return $this->id;
}
public function setData($data): AbstractWidgetType
{
$this->data = $data;
return $this;
}
/**
* @param array $options
* @return mixed|null
*/
public function getData(array $options = [])
{
return $this->data;
}
public function setTitle(string $title): AbstractWidgetType
{
$this->title = $title;
return $this;
}
public function getTitle(): string
{
return $this->title;
}
public function setOptions(array $options): AbstractWidgetType
{
foreach ($options as $key => $value) {
$this->options[$key] = $value;
}
return $this;
}
/**
* @param string $name
* @param mixed $value
*/
public function setOption(string $name, $value): void
{
$this->options[$name] = $value;
}
/**
* @param string $name
* @param mixed|null $default
* @return mixed|null
*/
public function getOption(string $name, $default = null)
{
if (array_key_exists($name, $this->options)) {
return $this->options[$name];
}
return $default;
}
public function getOptions(array $options = []): array
{
return array_merge($this->options, $options);
}
}

View File

@@ -0,0 +1,14 @@
<?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\Widget\Type;
class CompoundChart extends AbstractContainer
{
}

View File

@@ -0,0 +1,14 @@
<?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\Widget\Type;
class CompoundRow extends AbstractContainer
{
}

View File

@@ -0,0 +1,18 @@
<?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\Widget\Type;
class Counter extends SimpleWidget
{
public function __construct()
{
$this->setOption('dataType', 'int');
}
}

View File

@@ -0,0 +1,65 @@
<?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\Widget\Type;
use App\Repository\TimesheetRepository;
use App\Security\CurrentUser;
use DateTime;
class DailyWorkingTimeChart extends SimpleWidget
{
public const DEFAULT_CHART = 'bar';
/**
* @var TimesheetRepository
*/
protected $repository;
public function __construct(TimesheetRepository $repository, CurrentUser $user)
{
$this->repository = $repository;
$this->setId('DailyWorkingTimeChart');
$this->setTitle('stats.yourWorkingHours');
$this->setOptions([
'begin' => 'monday this week 00:00:00',
'end' => 'sunday this week 23:59:59',
'color' => '',
'user' => $user->getUser(),
'type' => self::DEFAULT_CHART,
'id' => '',
]);
}
public function getOptions(array $options = []): array
{
$options = parent::getOptions($options);
if (!in_array($options['type'], ['bar', 'line'])) {
$options['type'] = self::DEFAULT_CHART;
}
if (empty($options['id'])) {
$options['id'] = uniqid('DailyWorkingTimeChart_');
}
return $options;
}
public function getData(array $options = [])
{
$options = $this->getOptions($options);
$user = $options['user'];
$begin = new DateTime($options['begin']);
$end = new DateTime($options['end']);
return $this->repository->getDailyStats($user, $begin, $end);
}
}

18
src/Widget/Type/More.php Normal file
View File

@@ -0,0 +1,18 @@
<?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\Widget\Type;
class More extends SimpleWidget
{
public function __construct()
{
$this->setOption('dataType', 'int');
}
}

View File

@@ -0,0 +1,14 @@
<?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\Widget\Type;
class SimpleWidget extends AbstractWidgetType
{
}

View File

@@ -0,0 +1,14 @@
<?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\Widget\Type;
class YearChart extends SimpleWidget
{
}

View File

@@ -0,0 +1,24 @@
<?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\Widget;
interface WidgetContainerInterface extends WidgetInterface
{
public function getOrder(): int;
public function setOrder(int $order);
/**
* @return WidgetInterface[]
*/
public function getWidgets(): array;
public function addWidget(WidgetInterface $widget);
}

View File

@@ -0,0 +1,14 @@
<?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\Widget;
class WidgetException extends \Exception
{
}

View File

@@ -0,0 +1,65 @@
<?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\Widget;
interface WidgetInterface
{
/**
* Returns a unique ID for this widget.
*
* @return string
*/
public function getId(): string;
/**
* Returns the widget title.
*
* If no title is necessary, return an empty string.
*
* @return string
*/
public function getTitle(): string;
/**
* Returns the widgets data, to be used in the frontend rendering.
*
* If your widget relies on options to dynamically change the result data,
* make sure that the given $options will overwrite the internal option for
* this one call.
*
* @param array $options
* @return mixed|null
*/
public function getData(array $options = []);
/**
* Returns all widget options to be used in the frontend.
*
* The given $options are not meant to be persisted, but only to
* overwrite the default values one time.
*
* You can validate the options or simply return:
* return array_merge($this->options, $options);
*
* @param array $options
* @return array
*/
public function getOptions(array $options = []): array;
/**
* Sets one widget option, both for internal use and for frontend rendering.
*
* The given option should be persisted and permanently overwrite the internal option.
*
* @param string $name
* @param mixed $value
*/
public function setOption(string $name, $value): void;
}

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\Widget;
interface WidgetRendererInterface
{
/**
* Checks if the given widget can be rendered.
*
* Most renderer check something like:
* return $widget instanceof MyWidgetType;
*
* @param WidgetInterface $widget
* @return bool
*/
public function supports(WidgetInterface $widget): bool;
/**
* Renders the given widget.
*
* The given $options array overwrites the widgets internal options for this call.
*
* @param WidgetInterface $widget
* @param array $options
* @return string
*/
public function render(WidgetInterface $widget, array $options = []): string;
}

View File

@@ -0,0 +1,81 @@
<?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\Widget;
use App\Repository\WidgetRepository;
class WidgetService
{
/**
* @var WidgetRendererInterface[]
*/
protected $renderer = [];
/**
* @var WidgetRepository
*/
protected $repository;
/**
* @param WidgetRepository $repository
* @param WidgetRendererInterface[] $renderer
*/
public function __construct(WidgetRepository $repository, iterable $renderer)
{
foreach ($renderer as $render) {
$this->addRenderer($render);
}
$this->repository = $repository;
}
/**
* @param string $widget
* @return bool
*/
public function hasWidget(string $widget): bool
{
return $this->repository->has($widget);
}
public function getWidget(string $widget): WidgetInterface
{
return $this->repository->get($widget);
}
public function addRenderer(WidgetRendererInterface $renderer): WidgetService
{
$this->renderer[] = $renderer;
return $this;
}
/**
* @param WidgetInterface $widget
* @return WidgetRendererInterface
* @throws WidgetException
*/
public function findRenderer(WidgetInterface $widget): WidgetRendererInterface
{
foreach ($this->renderer as $renderer) {
if ($renderer->supports($widget)) {
return $renderer;
}
}
throw new WidgetException(sprintf('No renderer available for widget "%s"', get_class($widget)));
}
/**
* @return WidgetRendererInterface[]
*/
public function getRenderer(): array
{
return $this->renderer;
}
}

View File

@@ -35,13 +35,8 @@
{% set currency = activity.project.customer.currency %} {% set currency = activity.project.customer.currency %}
{% endif %} {% endif %}
{% if activity.budget > 0 %} {{ progress.progressbar(activity.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(currency) ~ ' / ' ~ activity.budget|money(currency) ) }}
{{ progress.progressbar(activity.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(currency) ~ ' / ' ~ activity.budget|money(currency) ) }} {{ progress.progressbar(activity.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ activity.timeBudget|duration ) }}
{% endif %}
{% if activity.timeBudget > 0 %}
{{ progress.progressbar(activity.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ activity.timeBudget|duration ) }}
{% endif %}
{% endblock %} {% endblock %}
{% endembed %} {% endembed %}

View File

@@ -24,13 +24,8 @@
{{ 'admin_customer.short_stats'|trans(params)|raw }} {{ 'admin_customer.short_stats'|trans(params)|raw }}
</p> </p>
{% if customer.budget > 0 %} {{ progress.progressbar(customer.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(customer.currency) ~ ' / ' ~ customer.budget|money(customer.currency) ) }}
{{ progress.progressbar(customer.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(customer.currency) ~ ' / ' ~ customer.budget|money(customer.currency) ) }} {{ progress.progressbar(customer.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ customer.timeBudget|duration ) }}
{% endif %}
{% if customer.timeBudget > 0 %}
{{ progress.progressbar(customer.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ customer.timeBudget|duration ) }}
{% endif %}
{% endblock %} {% endblock %}
{% endembed %} {% endembed %}

View File

@@ -18,12 +18,8 @@
{% block main %} {% block main %}
{% for row in widget_rows %} {% for row in widgets %}
{% if row.type == constant('App\\Model\\DashboardSection::TYPE_CHART') %} {{ render_widget(row) }}
{% embed 'dashboard/section-chart.html.twig' with { section: row } %}{% endembed %}
{% else %}
{% embed 'dashboard/section-simple.html.twig' with { section: row } %}{% endembed %}
{% endif %}
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}

View File

@@ -1,34 +0,0 @@
{% import "macros/widgets.html.twig" as widgets %}
{% if section.title %}
{{ widgets.page_header(section.title) }}
{% endif %}
{% set width = section.widgets|length %}
{% set rawWidth = 12 / width %}
{% set columnWidth = rawWidth|round(0, 'floor') %}
<div class="row">
{% for widget in section.widgets %}
{% set columnSize = columnWidth %}
{% if width == 5 and (loop.first or loop.last) %}
{% set columnSize = columnWidth + 1 %}
{% endif %}
<div class="col-md-{{ columnSize }} col-sm-{{ columnSize * 2 }} col-xs-{{ columnSize * 4 }}">
{% set data = widget.data %}
{% if widget.dataType == constant('App\\Model\\Widget::DATA_TYPE_DURATION') %}
{% set data = widget.data|duration %}
{% elseif widget.dataType == constant('App\\Model\\Widget::DATA_TYPE_MONEY') %}
{% set data = widget.data|money %}
{% endif %}
{% set url = null %}
{% if widget.route %}
{% set url = path(widget.route, widget.routeOptions) %}
{% endif %}
{% set embedOptions = {data: data, title: widget.title, icon: widget.icon, color: widget.color, url: url} %}
{% embed 'embeds/widget-'~widget.type~'.html.twig' with embedOptions %}{% endembed %}
</div>
{% endfor %}
</div>

View File

@@ -1,10 +0,0 @@
<div class="info-box">
<span class="info-box-icon bg-{{ color|default('green') }}"><i class="{{ icon|icon(icon) }}"></i></span>
<div class="info-box-content">
{% if url %}<a href="{{ url }}" class="small-box-footer">{% endif %}
<span class="info-box-text">{{ title|trans }}</span>
<span class="info-box-number">{{ data }}</span>
{% if url %}</a>{% endif %}
</div>
</div>

View File

@@ -1,14 +0,0 @@
<div class="small-box bg-{{ color|default('green') }}">
<div class="inner">
<h3>{{ data }}<sup style="font-size: 20px">{{ unit|default('') }}</sup></h3>
<p>{{ title|trans }}</p>
</div>
<div class="icon">
<i class="{{ icon|icon(icon) }}"></i>
</div>
{% if url %}
<a href="{{ url }}" class="small-box-footer">
{{ 'more.info.link'|trans }}
</a>
{% endif %}
</div>

View File

@@ -1,5 +1,8 @@
{% macro progressbar(max, current, title, subTitle) %} {% macro progressbar(max, current, title, subTitle) %}
{% set percentReached = (current / (max / 100)) %} {% set percentReached = 0 %}
{% if max > 0 %}
{% set percentReached = (current / (max / 100)) %}
{% endif %}
{% set class = "progress-bar-info" %} {% set class = "progress-bar-info" %}
{% set width = percentReached|number_format(1, '.', '') %} {% set width = percentReached|number_format(1, '.', '') %}
@@ -18,7 +21,7 @@
{% endif %} {% endif %}
<div class="progress-group"> <div class="progress-group">
<span class="progress-text">{{ title }} &ndash; {{ percentReached|number_format(2) }}%</span> <span class="progress-text">{{ title }}{% if percentReached > 0 %} &ndash; {{ percentReached|number_format(2) }}%{% endif %}</span>
<span class="progress-number">{{ subTitle }}</span> <span class="progress-number">{{ subTitle }}</span>
<div class="progress"> <div class="progress">

View File

@@ -254,29 +254,17 @@
{% endif %} {% endif %}
<a class="{{ class | trim }}" href="{{ url }}" <a class="{{ class | trim }}" href="{{ url }}"
{%- if disabled is same as (true) -%} {%- if disabled is same as (true) %} disabled="disabled"{% endif %}
disabled="disabled" {%- if id is not empty %} id="{{ id }}"{% endif %}
{%- endif -%} {%- if toggle is not empty %} data-toggle="{{ toggle }}"{% endif %}
{%- if id is not empty -%} {%- if modal is not empty %} data-toggle="modal" data-target="{{ modal }}"{% endif %}
id="{{ id }}" {%- if onclick is not empty %} onclick="{{ onclick }}"{% endif %}
{%- endif -%} {%- if target is not empty %} target="{{ target }}"{% endif %}
{%- if toggle is not empty -%} {%- if attr is not empty %}
data-toggle="{{ toggle }}" {% for name, value in attr %}
{%- endif -%} {{ ' ' ~ name }}={% if '"' in value %}'{{ value|raw }}'{% else %}"{{ value|raw }}"{% endif %}
{%- if modal is not empty -%} {% endfor %}
data-toggle="modal" data-target="{{ modal }}" {% endif %}
{%- endif -%}
{%- if onclick is not empty -%}
onclick="{{ onclick }}"
{%- endif -%}
{%- if target is not empty -%}
target="{{ target }}"
{%- endif -%}
{%- if attr is not empty -%}
{%- for name, value in attr %}
{{- ' ' ~ name }}={% if '"' in value %}'{{ value|raw }}'{% else %}"{{ value|raw }}"{% endif %}
{%- endfor -%}
{%- endif -%}
>{% if title is not null %}{{ title }}{% else %}{{ macro.icon(icon) }}{% endif %}</a> >{% if title is not null %}{{ title }}{% else %}{{ macro.icon(icon) }}{% endif %}</a>
{% endapply %} {% endapply %}
{% endmacro %} {% endmacro %}

View File

@@ -23,13 +23,8 @@
{{ 'admin_project.short_stats'|trans(params)|raw }} {{ 'admin_project.short_stats'|trans(params)|raw }}
</p> </p>
{% if project.budget > 0 %} {{ progress.progressbar(project.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(project.customer.currency) ~ ' / ' ~ project.budget|money(project.customer.currency) ) }}
{{ progress.progressbar(project.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(project.customer.currency) ~ ' / ' ~ project.budget|money(project.customer.currency) ) }} {{ progress.progressbar(project.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ project.timeBudget|duration ) }}
{% endif %}
{% if project.timeBudget > 0 %}
{{ progress.progressbar(project.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ project.timeBudget|duration ) }}
{% endif %}
{% endblock %} {% endblock %}
{% endembed %} {% endembed %}

View File

@@ -33,12 +33,22 @@
} }
}], }],
yAxes: [{ yAxes: [{
ticks: {
beginAtZero: true
},
gridLines: { gridLines: {
display: true, display: true,
color: 'rgba(0,0,0,.05)', color: '{{ kimai_context.chart.grid_color }}',
lineWidth: 1 lineWidth: 1
} }
}] }]
},
tooltips: {
callbacks: {
label: function(tooltipItem, data) {
return data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
}
}
} }
}; };
} }
@@ -51,7 +61,7 @@
{% block box_body %} {% block box_body %}
<div class="chart"> <div class="chart">
<canvas id="userProfileChart{{ year }}" style="height: 200px;"></canvas> <canvas id="userProfileChart{{ year }}" style="height: {{ kimai_context.chart.height }}px;"></canvas>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener('kimai.initialized', function() { document.addEventListener('kimai.initialized', function() {
@@ -60,11 +70,17 @@
datasets: [ datasets: [
{ {
label: '{{ year }}', label: '{{ year }}',
backgroundColor : '#00a65a', backgroundColor: '{{ kimai_context.chart.background_color }}',
borderColor : '#00a65a', borderColor: '{{ kimai_context.chart.border_color }}',
data: [ data: [
{% for month in yearStat.months %} {% for month in yearStat.months %}
{{ (month.totalDuration / 3600)|round }} {{ (month.totalDuration / 3600)|number_format(2, '.', '') }}
{% if not loop.last %},{% endif %}
{% endfor %}
],
realData : [
{% for month in yearStat.months %}
'{{ month.totalDuration|duration }}'
{% if not loop.last %},{% endif %} {% if not loop.last %},{% endif %}
{% endfor %} {% endfor %}
] ]

View File

@@ -1,11 +1,9 @@
{% set chartWidget = section.widgets.0 %}
{% set colors = chartWidget.color|split(';') %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div class="box"> <div class="box">
{# {#
<div class="box-header with-border"> <div class="box-header with-border">
<h3 class="box-title">{{ section.title|trans }}</h3> <h3 class="box-title">{{ title|trans }}</h3>
<div class="box-tools pull-right"> <div class="box-tools pull-right">
<button type="button" class="btn btn-box-tool" data-widget="collapse"><i class="fa fa-minus"></i> <button type="button" class="btn btn-box-tool" data-widget="collapse"><i class="fa fa-minus"></i>
</button> </button>
@@ -27,25 +25,7 @@
<div class="box-body"> <div class="box-body">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{# {{ render_widget(widgets.0) }}
<p class="text-center">
<strong>
{% for yearName, year in chartWidget.data|sort %}
{% if loop.first or loop.last %}
{% for id, month in year.months %}
{% if loop.first %}
moment.months({{ loop.index }}) {{ yearName }}
{% endif %}
{% endfor %}
{% endif %}
{% if loop.first %} - {% endif %}
{% endfor %}
</strong>
</p>
#}
<div class="chart">
<canvas id="timeChart" style="height: 180px;"></canvas>
</div>
</div> </div>
{# {#
<div class="col-md-4"> <div class="col-md-4">
@@ -85,17 +65,17 @@
#} #}
</div> </div>
</div> </div>
{% if section.widgets|length > 1 %} {% if widgets|length > 1 %}
<div class="box-footer"> <div class="box-footer">
<div class="row"> <div class="row">
{% set width = (section.widgets|length) - 1 %} {% set width = (widgets|length) - 1 %}
{% set rawWidth = 12 / width %} {% set rawWidth = 12 / width %}
{% set columnWidth = rawWidth|round(0, 'floor') %} {% set columnWidth = rawWidth|round(0, 'floor') %}
{% for widget in section.widgets|slice(1, width) %} {% for widget in widgets|slice(1, width) %}
{% set data = widget.data %} {% set data = widget.data %}
{% if widget.dataType == constant('App\\Model\\Widget::DATA_TYPE_DURATION') %} {% if widget.option('dataType') == 'duration' %}
{% set data = widget.data|duration %} {% set data = widget.data|duration %}
{% elseif widget.dataType == constant('App\\Model\\Widget::DATA_TYPE_MONEY') %} {% elseif widget.option('dataType') == 'money' %}
{% set data = widget.data|money %} {% set data = widget.data|money %}
{% endif %} {% endif %}
<div class="col-sm-{{ columnWidth }} col-xs-{{ columnWidth * 2 }}"> <div class="col-sm-{{ columnWidth }} col-xs-{{ columnWidth * 2 }}">
@@ -113,63 +93,5 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<script>
$(function () {
'use strict';
var timeChartData = {
labels: moment.months(),
datasets: [
{% for yearName, year in chartWidget.data %}
{% set yearColors = colors[loop.index-1]|split('|') %}
{
label : '{{ yearName }}',
backgroundColor : '{{ yearColors.1 }}',
borderColor : '{{ yearColors.0 }}',
pointRadius : 2,
borderWidth : 1,
pointHitRadius : 10,
lineTension : 0.3,
data : [
{% for month in year.months %}
{{ (month.totalDuration / 3600)|round }}
{% if not loop.last %},{% endif %}
{% endfor %}
]
}
{% if not loop.last %},{% endif %}
{% endfor %}
]
};
var timeChartOptions = {
maintainAspectRatio : true,
responsive : true,
scales: {
xAxes: [{
gridLines: {
display: false
}
}],
yAxes: [{
gridLines: {
display: true,
color: 'rgba(0,0,0,.05)',
lineWidth: 1
}
}]
}
};
var timeChartCanvas = $('#timeChart').get(0).getContext('2d');
var timeChart = new Chart(
timeChartCanvas, {
type: 'line',
data: timeChartData,
options: timeChartOptions
}
);
});
</script>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,20 @@
{% import "macros/widgets.html.twig" as widgets %}
{% if not title is empty %}
{{ widgets.page_header(title) }}
{% endif %}
{% set width = widgets|length %}
{% set rawWidth = 12 / width %}
{% set columnWidth = rawWidth|round(0, 'floor') %}
<div class="row">
{% for widget in widgets %}
{% set columnSize = columnWidth %}
{% if width == 5 and (loop.first or loop.last) %}
{% set columnSize = columnWidth + 1 %}
{% endif %}
<div class="col-md-{{ columnSize }} col-sm-{{ columnSize * 2 }} col-xs-{{ columnSize * 4 }}">
{{ render_widget(widget) }}
</div>
{% endfor %}
</div>

View File

@@ -0,0 +1,28 @@
{% set url = null %}
{% if options.route is defined %}
{% set url = path(options.route, options.routeOptions|default([])) %}
{% endif %}
<div class="info-box">
<span class="info-box-icon bg-{{ options.color|default('green') }}"><i class="{{ options.icon|icon(options.icon) }}"></i></span>
<div class="info-box-content">
{% if not url is empty %}<a href="{{ url }}" class="small-box-footer">{% endif %}
<span class="info-box-text">{{ title|trans }}</span>
<span class="info-box-number">
{% if data is iterable %}
{{ 'Invalid data' }}
{% else %}
{% block widget_data %}
{% if options.dataType == 'duration' %}
{% set data = data|duration %}
{% elseif options.dataType == 'money' %}
{% set data = data|money %}
{% endif %}
{{ data }}
{% endblock %}
{% endif %}
</span>
{% if not url is empty %}</a>{% endif %}
</div>
</div>

View File

@@ -0,0 +1,95 @@
{% set type = options.type|default('bar') %}
{% set chart_id = options.id %}
{% set backgroundColor = kimai_context.chart.background_color %}
{% set borderColor = kimai_context.chart.border_color %}
{% set gridColor = kimai_context.chart.grid_color %}
{% set colors = options.color|default('')|split(';') %}
{% if colors.0 is defined and not colors.0 is empty %}
{% set backgroundColor = colors.0 %}
{% set borderColor = colors.0 %}
{% if colors.1 is defined and not colors.1 is empty %}
{% set borderColor = colors.1 %}
{% endif %}
{% endif %}
{{ encore_entry_link_tags('chart') }}
{{ encore_entry_script_tags('chart') }}
{% if not title is empty %}
<p class="text-center">
<strong>
{{ title|trans }}
</strong>
</p>
{% endif %}
<div class="chart">
<canvas id="{{ chart_id }}" style="height: {{ kimai_context.chart.height }}px;"></canvas>
</div>
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
new Chart(
document.getElementById('{{ chart_id }}').getContext('2d'), {
type: '{{ type }}',
data: {
labels: [
{% for day in data -%}
moment('{{ day.day|date_format(constant('\DateTime::ISO8601')) }}').format('ll')
{% if not loop.last %},{% endif -%}
{%- endfor %}
],
datasets: [
{
backgroundColor: '{{ backgroundColor }}',
borderColor: '{{ borderColor }}',
data: [
{% for day in data -%}
{{ (day.totalDuration / 3600)|number_format(2, '.', '') }}
{%- if not loop.last %},{% endif -%}
{%- endfor %}
],
realData: [
{% for day in data -%}
'{{ day.totalDuration|duration }}'
{%- if not loop.last %},{% endif -%}
{%- endfor %}
]
}
]
},
options: {
maintainAspectRatio: true,
responsive: true,
legend: false,
barPercentage: 0.5,
categoryPercentage: 0.9,
scales: {
xAxes: [{
gridLines: {
display: false
},
}],
yAxes: [{
ticks: {
beginAtZero: true
},
gridLines: {
display: true,
color: '{{ gridColor }}',
lineWidth: 1
}
}]
},
tooltips: {
callbacks: {
label: function(tooltipItem, data) {
return data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
}
}
}
}
}
);
});
</script>

View File

@@ -0,0 +1,33 @@
{% set url = null %}
{% if options.route is defined %}
{% set url = path(options.route, options.routeOptions|default([])) %}
{% endif %}
<div class="small-box bg-{{ options.color|default('green') }}">
<div class="inner">
<h3>
{% if data is iterable %}
{{ 'Invalid data' }}
{% else %}
{% block widget_data %}
{% if options.dataType == 'duration' %}
{% set data = data|duration %}
{% elseif options.dataType == 'money' %}
{% set data = data|money %}
{% endif %}
{{ data }}
{% endblock %}
{% endif %}
<sup style="font-size: 20px">{{ unit|default('') }}</sup>
</h3>
<p>{{ title|trans }}</p>
</div>
<div class="icon">
<i class="{{ options.icon|icon(options.icon) }}"></i>
</div>
{% if not url is empty %}
<a href="{{ url }}" class="small-box-footer">
{{ 'more.info.link'|trans }}
</a>
{% endif %}
</div>

View File

@@ -0,0 +1,98 @@
{% set backgroundColor = kimai_context.chart.background_color %}
{% set borderColor = kimai_context.chart.border_color %}
{% set gridColor = kimai_context.chart.grid_color %}
{% set colors = options.color|default('')|split(';') %}
{% set type = options.type|default('line') %}
{% if type not in ['line', 'bar'] %}
{% set type = 'line' %}
{% endif %}
{% if not title is empty %}
<p class="text-center">
<strong>
{{ title|trans }}
</strong>
</p>
{% endif %}
<div class="chart">
<canvas id="timeChart" style="height: {{ kimai_context.chart.height }}px;"></canvas>
</div>
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
new Chart(
document.getElementById('timeChart').getContext('2d'), {
type: '{{ type }}',
data: {
labels: moment.months(),
datasets: [
{% for yearName, year in data %}
{% set yearBackgroundColor = backgroundColor %}
{% set yearBorderColor = borderColor %}
{% if colors[loop.index-1] is defined %}
{% set yearColors = colors[loop.index-1]|split('|') %}
{% if yearColors.0 is defined and not yearColors.0 is empty %}
{% set yearBackgroundColor = yearColors.0 %}
{% set yearBorderColor = yearColors.0 %}
{% if yearColors.1 is defined and not yearColors.1 is empty %}
{% set yearBorderColor = yearColors.1 %}
{% endif %}
{% endif %}
{% endif %}
{
label : '{{ yearName }}',
backgroundColor : '{{ yearBackgroundColor }}',
borderColor : '{{ yearBorderColor }}',
pointRadius : 2,
borderWidth : 1,
pointHitRadius : 10,
lineTension : 0.3,
data : [
{% for month in year.months %}
{{ (month.totalDuration / 3600)|number_format(2, '.', '') }}
{% if not loop.last %},{% endif %}
{% endfor %}
],
realData : [
{% for month in year.months %}
'{{ month.totalDuration|duration }}'
{% if not loop.last %},{% endif %}
{% endfor %}
]
}
{% if not loop.last %},{% endif %}
{% endfor %}
]
},
options: {
maintainAspectRatio: true,
responsive: true,
scales: {
xAxes: [{
gridLines: {
display: false
}
}],
yAxes: [{
gridLines: {
display: true,
color: '{{ gridColor }}',
lineWidth: 1
}
}]
},
tooltips: {
callbacks: {
label: function(tooltipItem, data) {
return data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
}
}
}
}
}
);
});
</script>

View File

@@ -97,6 +97,12 @@ class AppExtensionTest extends TestCase
'box_color' => 'green', 'box_color' => 'green',
'select_type' => null, 'select_type' => null,
'show_about' => true, 'show_about' => true,
'chart' => [
'background_color' => 'rgba(0,115,183,0.7)',
'border_color' => '#3b8bba',
'grid_color' => 'rgba(0,0,0,.05)',
'height' => '200'
]
], ],
'kimai.theme.select_type' => null, 'kimai.theme.select_type' => null,
'kimai.theme.show_about' => true, 'kimai.theme.show_about' => true,

View File

@@ -11,7 +11,7 @@ namespace App\Tests\Event;
use App\Entity\User; use App\Entity\User;
use App\Event\DashboardEvent; use App\Event\DashboardEvent;
use App\Model\DashboardSection; use App\Widget\Type\CompoundRow;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -29,7 +29,7 @@ class DashboardEventTest extends TestCase
$this->assertEquals($user, $sut->getUser()); $this->assertEquals($user, $sut->getUser());
$this->assertEquals([], $sut->getSections()); $this->assertEquals([], $sut->getSections());
$section = new DashboardSection('foo'); $section = new CompoundRow();
$sut->addSection($section); $sut->addSection($section);
$this->assertEquals([$section], $sut->getSections()); $this->assertEquals([$section], $sut->getSections());

View File

@@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
* @covers \App\Export\Renderer\CsvRenderer * @covers \App\Export\Renderer\CsvRenderer
* @covers \App\Export\Renderer\AbstractSpreadsheetRenderer * @covers \App\Export\Renderer\AbstractSpreadsheetRenderer
* @covers \App\Export\Renderer\RendererTrait * @covers \App\Export\Renderer\RendererTrait
* @group integration
*/ */
class CsvRendererTest extends AbstractRendererTest class CsvRendererTest extends AbstractRendererTest
{ {

View File

@@ -16,6 +16,7 @@ use Twig\Environment;
/** /**
* @covers \App\Export\Renderer\HtmlRenderer * @covers \App\Export\Renderer\HtmlRenderer
* @covers \App\Export\Renderer\RendererTrait * @covers \App\Export\Renderer\RendererTrait
* @group integration
*/ */
class HtmlRendererTest extends AbstractRendererTest class HtmlRendererTest extends AbstractRendererTest
{ {

View File

@@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
* @covers \App\Export\Renderer\OdsRenderer * @covers \App\Export\Renderer\OdsRenderer
* @covers \App\Export\Renderer\AbstractSpreadsheetRenderer * @covers \App\Export\Renderer\AbstractSpreadsheetRenderer
* @covers \App\Export\Renderer\RendererTrait * @covers \App\Export\Renderer\RendererTrait
* @group integration
*/ */
class OdsRendererTest extends AbstractRendererTest class OdsRendererTest extends AbstractRendererTest
{ {

View File

@@ -19,6 +19,7 @@ use Twig\Environment;
/** /**
* @covers \App\Export\Renderer\PDFRenderer * @covers \App\Export\Renderer\PDFRenderer
* @covers \App\Export\Renderer\RendererTrait * @covers \App\Export\Renderer\RendererTrait
* @group integration
*/ */
class PdfRendererTest extends AbstractRendererTest class PdfRendererTest extends AbstractRendererTest
{ {

View File

@@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
* @covers \App\Export\Renderer\XlsxRenderer * @covers \App\Export\Renderer\XlsxRenderer
* @covers \App\Export\Renderer\AbstractSpreadsheetRenderer * @covers \App\Export\Renderer\AbstractSpreadsheetRenderer
* @covers \App\Export\Renderer\RendererTrait * @covers \App\Export\Renderer\RendererTrait
* @group integration
*/ */
class XlsxRendererTest extends AbstractRendererTest class XlsxRendererTest extends AbstractRendererTest
{ {

View File

@@ -1,42 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Model;
use App\Model\DashboardSection;
use App\Model\Widget;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\DashboardSection
*/
class DashboardSectionTest extends TestCase
{
public function testDefaultValues()
{
$sut = new DashboardSection('test');
$this->assertEquals('test', $sut->getTitle());
$this->assertEquals(0, $sut->getOrder());
$this->assertEquals([], $sut->getWidgets());
$this->assertEquals(DashboardSection::TYPE_SIMPLE, $sut->getType());
}
public function testSetter()
{
$sut = new DashboardSection('hello-world');
$sut->setType(DashboardSection::TYPE_CHART);
$sut->setOrder(13);
$sut->addWidget(new Widget('bar', []));
$this->assertCount(1, $sut->getWidgets());
$this->assertEquals(DashboardSection::TYPE_CHART, $sut->getType());
$this->assertEquals(13, $sut->getOrder());
$this->assertEquals('bar', $sut->getWidgets()[0]->getTitle());
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Model\Statistic;
use App\Model\Statistic\Day;
use DateTime;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\Statistic\Day
*/
class DayTest extends TestCase
{
public function testConstruct()
{
$date = new DateTime('-8 hours');
$sut = new Day($date, 12340, 197.25956);
$this->assertSame($date, $sut->getDay());
$this->assertEquals(12340, $sut->getTotalDuration());
$this->assertEquals(197.25956, $sut->getTotalRate());
}
public function testAllowedMonths()
{
$date = new DateTime('-8 hours');
$sut = new Day($date, 12340, 197.25956);
$sut->setTotalDuration(999.27);
$sut->setTotalRate(0.123456789);
$this->assertEquals(999, $sut->getTotalDuration());
$this->assertEquals(0.123456789, $sut->getTotalRate());
}
}

View File

@@ -10,6 +10,8 @@
namespace App\Tests\Model\Statistic; namespace App\Tests\Model\Statistic;
use App\Model\Statistic\Month; use App\Model\Statistic\Month;
use Exception;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -42,12 +44,12 @@ class MonthTest extends TestCase
$ex = null; $ex = null;
try { try {
new Month($month); new Month($month);
} catch (\Exception $e) { } catch (Exception $e) {
$ex = $e; $ex = $e;
} }
$this->assertInstanceOf(\InvalidArgumentException::class, $ex); $this->assertInstanceOf(InvalidArgumentException::class, $ex);
$this->assertEquals( $this->assertEquals(
'Invalid month given, expected 01-12 but given: ' . ((int) $month), 'Invalid month given. Expected 1-12, received "' . ((int) $month) . '".',
$ex->getMessage() $ex->getMessage()
); );
} }
@@ -59,7 +61,7 @@ class MonthTest extends TestCase
$sut->setTotalDuration(999.27); $sut->setTotalDuration(999.27);
$sut->setTotalRate(0.123456789); $sut->setTotalRate(0.123456789);
$this->assertEquals(999.27, $sut->getTotalDuration()); $this->assertEquals(999, $sut->getTotalDuration());
$this->assertEquals(0.123456789, $sut->getTotalRate()); $this->assertEquals(0.123456789, $sut->getTotalRate());
} }
} }

View File

@@ -9,9 +9,9 @@
namespace App\Tests\Repository; namespace App\Tests\Repository;
use App\Model\Widget;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Repository\WidgetRepository; use App\Repository\WidgetRepository;
use App\Security\CurrentUser;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -22,8 +22,9 @@ class WidgetRepositoryTest extends TestCase
public function testHasWidget() public function testHasWidget()
{ {
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock(); $repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetRepository($repoMock, ['test' => []]); $sut = new WidgetRepository($repoMock, $userMock, ['test' => []]);
$this->assertFalse($sut->has('foo')); $this->assertFalse($sut->has('foo'));
$this->assertTrue($sut->has('test')); $this->assertTrue($sut->has('test'));
@@ -31,14 +32,41 @@ class WidgetRepositoryTest extends TestCase
/** /**
* @expectedException \InvalidArgumentException * @expectedException \InvalidArgumentException
* @expectedExceptionMessage Cannot find widget: foo * @expectedExceptionMessage Cannot find widget "foo".
*/ */
public function testGetWidgetThrowsExceptionOnNonExistingWidget() public function testGetWidgetThrowsExceptionOnNonExistingWidget()
{ {
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock(); $repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetRepository($repoMock, ['test' => []]); $sut = new WidgetRepository($repoMock, $userMock, ['test' => []]);
$sut->get('foo', null); $sut->get('foo');
}
/**
* @expectedException \App\Widget\WidgetException
* @expectedExceptionMessage Unknown widget type "\App\Widget\Type\FooBar"
*/
public function testGetWidgetThrowsExceptionOnInvalidType()
{
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetRepository($repoMock, $userMock, ['test' => ['type' => 'FooBar', 'user' => false]]);
$sut->get('test');
}
/**
* @expectedException \App\Widget\WidgetException
* @expectedExceptionMessage Invalid widget type "\App\Widget\Type\CompoundChart" does not extend AbstractWidgetType
*/
public function testGetWidgetTriggersExceptionOnWrongClass()
{
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetRepository($repoMock, $userMock, ['test' => ['type' => 'CompoundChart', 'user' => false]]);
$sut->get('test');
} }
/** /**
@@ -49,6 +77,8 @@ class WidgetRepositoryTest extends TestCase
$repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock(); $repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$repoMock->method('getStatistic')->willReturn($data); $repoMock->method('getStatistic')->willReturn($data);
$userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock();
$widget = [ $widget = [
'color' => 'sunny', 'color' => 'sunny',
'icon' => 'far fa-test', 'icon' => 'far fa-test',
@@ -59,24 +89,25 @@ class WidgetRepositoryTest extends TestCase
'title' => 'Test widget', 'title' => 'Test widget',
]; ];
$sut = new WidgetRepository($repoMock, ['test' => $widget]); $sut = new WidgetRepository($repoMock, $userMock, ['test' => $widget]);
$widget = $sut->get('test', null); $widget = $sut->get('test');
$options = $widget->getOptions();
$this->assertEquals('Test widget', $widget->getTitle()); $this->assertEquals('Test widget', $widget->getTitle());
$this->assertEquals($data, $widget->getData()); $this->assertEquals($data, $widget->getData());
$this->assertEquals('sunny', $widget->getColor()); $this->assertEquals('sunny', $options['color']);
$this->assertEquals('far fa-test', $widget->getIcon()); $this->assertEquals('far fa-test', $options['icon']);
$this->assertEquals($dataType, $widget->getDataType()); $this->assertEquals($dataType, $options['dataType']);
} }
public function getWidgetData() public function getWidgetData()
{ {
return [ return [
[12, TimesheetRepository::STATS_QUERY_DURATION, Widget::DATA_TYPE_DURATION], [12, TimesheetRepository::STATS_QUERY_DURATION, 'duration'],
[112233, TimesheetRepository::STATS_QUERY_AMOUNT, Widget::DATA_TYPE_INT], [112233, TimesheetRepository::STATS_QUERY_AMOUNT, 'int'],
[37, TimesheetRepository::STATS_QUERY_ACTIVE, Widget::DATA_TYPE_INT], [37, TimesheetRepository::STATS_QUERY_ACTIVE, 'int'],
[375, TimesheetRepository::STATS_QUERY_RATE, Widget::DATA_TYPE_MONEY], [375, TimesheetRepository::STATS_QUERY_RATE, 'money'],
[['test' => 'foo'], TimesheetRepository::STATS_QUERY_USER, Widget::DATA_TYPE_INT], [['test' => 'foo'], TimesheetRepository::STATS_QUERY_USER, 'int'],
]; ];
} }
} }

View File

@@ -0,0 +1,107 @@
<?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\Twig;
use App\Twig\WidgetExtension;
use App\Widget\Type\Counter;
use App\Widget\WidgetInterface;
use App\Widget\WidgetRendererInterface;
use App\Widget\WidgetService;
use PHPUnit\Framework\TestCase;
use Twig\TwigFunction;
/**
* @covers \App\Twig\WidgetExtension
*/
class WidgetExtensionTest extends TestCase
{
protected function getSut($hasWidget = null, $getWidget = null, $renderer = null): WidgetExtension
{
$service = $this->getMockBuilder(WidgetService::class)->disableOriginalConstructor()->setMethods(['hasWidget', 'getWidget', 'findRenderer'])->getMock();
if (null !== $hasWidget) {
$service->expects($this->once())->method('hasWidget')->willReturn($hasWidget);
}
if (null !== $getWidget) {
$service->expects($this->once())->method('getWidget')->willReturn($getWidget);
}
if (null !== $renderer) {
$service->expects($this->once())->method('findRenderer')->willReturn($renderer);
}
return new WidgetExtension($service);
}
public function testGetFunctions()
{
$functions = ['render_widget'];
$sut = $this->getSut();
$twigFunctions = $sut->getFunctions();
$this->assertCount(count($functions), $twigFunctions);
$i = 0;
/** @var TwigFunction $function */
foreach ($twigFunctions as $function) {
$this->assertInstanceOf(TwigFunction::class, $function);
$this->assertEquals($functions[$i++], $function->getName());
}
}
/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage Widget must either implement WidgetInterface or be a string
*/
public function testRenderWidgetForInvalidValue()
{
$sut = $this->getSut();
$sut->renderWidget(true);
}
/**
* @expectedException \InvalidArgumentException
* @expectedExceptionMessage Unknown widget "test" requested
*/
public function testRenderWidgetForUnknownWidget()
{
$sut = $this->getSut(false);
$sut->renderWidget('test');
}
public function testRenderWidgetByString()
{
$widget = new Counter();
$sut = $this->getSut(true, $widget, new TestRenderer());
$options = ['foo' => 'bar', 'dataType' => 'blub'];
$result = $sut->renderWidget('test', $options);
$data = json_decode($result, true);
$this->assertEquals($options, $data);
}
public function testRenderWidgetObject()
{
$widget = new Counter();
$sut = $this->getSut(null, null, new TestRenderer());
$options = ['foo' => 'bar', 'dataType' => 'blub'];
$result = $sut->renderWidget($widget, $options);
$data = json_decode($result, true);
$this->assertEquals($options, $data);
}
}
class TestRenderer implements WidgetRendererInterface
{
public function supports(WidgetInterface $widget): bool
{
return true;
}
public function render(WidgetInterface $widget, array $options = []): string
{
return json_encode($widget->getOptions($options));
}
}

View File

@@ -0,0 +1,54 @@
<?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\Widget\Renderer;
use App\Widget\Renderer\CompoundChartRenderer;
use App\Widget\Type\CompoundChart;
use App\Widget\Type\CompoundRow;
use App\Widget\Type\Counter;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
/**
* @covers \App\Widget\Renderer\CompoundChartRenderer
* @covers \App\Widget\Renderer\AbstractTwigRenderer
*/
class CompoundChartRendererTest extends TestCase
{
public function testSupports()
{
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock();
$sut = new CompoundChartRenderer($twig);
self::assertTrue($sut->supports(new CompoundChart()));
self::assertFalse($sut->supports(new CompoundRow()));
}
public function testRenderWithCounter()
{
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->setMethods(['render'])->getMock();
$twig->expects($this->once())->method('render')->willReturnCallback(function ($name, $options) {
return json_encode([$name, $options]);
});
$sut = new CompoundChartRenderer($twig);
$row = new CompoundChart();
$row->setTitle('foo-bar');
$row->addWidget(new Counter());
$result = $sut->render($row);
$result = json_decode($result, true);
self::assertEquals('widget/section-chart.html.twig', $result[0]);
self::assertArrayHasKey('title', $result[1]);
self::assertEquals('foo-bar', $result[1]['title']);
self::assertArrayHasKey('widgets', $result[1]);
self::assertIsArray($result[1]['widgets']);
self::assertCount(1, $result[1]['widgets']);
}
}

View File

@@ -0,0 +1,54 @@
<?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\Widget\Renderer;
use App\Widget\Renderer\CompoundRowRenderer;
use App\Widget\Type\CompoundChart;
use App\Widget\Type\CompoundRow;
use App\Widget\Type\Counter;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
/**
* @covers \App\Widget\Renderer\CompoundRowRenderer
* @covers \App\Widget\Renderer\AbstractTwigRenderer
*/
class CompoundRowRendererTest extends TestCase
{
public function testSupports()
{
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock();
$sut = new CompoundRowRenderer($twig);
self::assertTrue($sut->supports(new CompoundRow()));
self::assertFalse($sut->supports(new CompoundChart()));
}
public function testRenderWithCounter()
{
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->setMethods(['render'])->getMock();
$twig->expects($this->once())->method('render')->willReturnCallback(function ($name, $options) {
return json_encode([$name, $options]);
});
$sut = new CompoundRowRenderer($twig);
$row = new CompoundRow();
$row->setTitle('foo-bar');
$row->addWidget(new Counter());
$result = $sut->render($row);
$result = json_decode($result, true);
self::assertEquals('widget/section-simple.html.twig', $result[0]);
self::assertArrayHasKey('title', $result[1]);
self::assertEquals('foo-bar', $result[1]['title']);
self::assertArrayHasKey('widgets', $result[1]);
self::assertIsArray($result[1]['widgets']);
self::assertCount(1, $result[1]['widgets']);
}
}

View File

@@ -0,0 +1,67 @@
<?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\Widget\Renderer;
use App\Widget\Renderer\SimpleWidgetRenderer;
use App\Widget\Type\Counter;
use App\Widget\Type\More;
use App\Widget\Type\SimpleWidget;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
/**
* @covers \App\Widget\Renderer\SimpleWidgetRenderer
* @covers \App\Widget\Renderer\AbstractTwigRenderer
*/
class SimpleWidgetRendererTest extends TestCase
{
public function testSupports()
{
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock();
$sut = new SimpleWidgetRenderer($twig);
self::assertTrue($sut->supports(new SimpleWidget()));
}
/**
* @dataProvider getSimpleWidgetsData
*/
public function testRenderWithCounter(SimpleWidget $widget, $template, $color)
{
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->setMethods(['render'])->getMock();
$twig->expects($this->once())->method('render')->willReturnCallback(function ($name, $options) {
return json_encode([$name, $options]);
});
$sut = new SimpleWidgetRenderer($twig);
$data = uniqid(get_class($widget));
$widget->setData($data);
$result = $sut->render($widget, ['color' => $color]);
$result = json_decode($result, true);
self::assertEquals($template, $result[0]);
self::assertArrayHasKey('data', $result[1]);
self::assertEquals($data, $result[1]['data']);
self::assertArrayHasKey('title', $result[1]);
self::assertArrayHasKey('options', $result[1]);
self::assertIsArray($result[1]['options']);
self::assertArrayHasKey('color', $result[1]['options']);
self::assertEquals($color, $result[1]['options']['color']);
}
public function getSimpleWidgetsData()
{
return [
[new SimpleWidget(), 'widget/widget-simplewidget.html.twig', 'yellow'],
[new Counter(), 'widget/widget-counter.html.twig', 'asdfgh'],
[new More(), 'widget/widget-more.html.twig', '#123456'],
];
}
}

View File

@@ -0,0 +1,67 @@
<?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\Widget\Type;
use App\Widget\Type\AbstractContainer;
use App\Widget\Type\More;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Widget\Type\AbstractContainer
*/
abstract class AbstractContainerTest extends TestCase
{
abstract public function createSut(): AbstractContainer;
public function testDefaultValues()
{
$sut = $this->createSut();
self::assertInstanceOf(AbstractContainer::class, $sut);
self::assertEquals('', $sut->getId());
self::assertEquals('', $sut->getTitle());
self::assertEquals(0, $sut->getOrder());
self::assertEquals([], $sut->getOptions());
self::assertEquals([], $sut->getWidgets());
self::assertEquals([], $sut->getData());
}
public function testSetter()
{
$widget = (new More())->setTitle('bar')->setId('foo');
$sut = $this->createSut();
$sut->setTitle('hello-world');
$sut->setOrder(13);
$sut->addWidget($widget);
self::assertEquals('hello-world', $sut->getTitle());
self::assertEquals('hello-world', $sut->getId());
self::assertCount(1, $sut->getWidgets());
self::assertCount(1, $sut->getData());
self::assertEquals([$widget], $sut->getWidgets());
self::assertEquals([$widget], $sut->getData());
self::assertEquals(13, $sut->getOrder());
self::assertEquals('bar', $sut->getWidgets()[0]->getTitle());
}
/**
* @expectedException \BadMethodCallException
*/
public function testSetOptionNotImplemented()
{
$sut = $this->createSut();
$sut->setOption('dfsdf', []);
}
}

View File

@@ -0,0 +1,69 @@
<?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\Widget\Type;
use App\Widget\Type\AbstractWidgetType;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Widget\Type\AbstractWidgetType
*/
abstract class AbstractWidgetTypeTest extends TestCase
{
abstract public function createSut(): AbstractWidgetType;
abstract public function getDefaultOptions(): array;
public function testDefaultValues()
{
$sut = $this->createSut();
self::assertInstanceOf(AbstractWidgetType::class, $sut);
self::assertEquals('', $sut->getId());
self::assertEquals('', $sut->getTitle());
self::assertEquals($this->getDefaultOptions(), $sut->getOptions());
self::assertNull($sut->getData());
self::assertEquals('bar', $sut->getOption('foo', 'bar'));
}
public function testFluentInterface()
{
$sut = $this->createSut();
self::assertInstanceOf(AbstractWidgetType::class, $sut->setOptions([]));
self::assertInstanceOf(AbstractWidgetType::class, $sut->setId(''));
self::assertInstanceOf(AbstractWidgetType::class, $sut->setTitle(''));
self::assertInstanceOf(AbstractWidgetType::class, $sut->setData(''));
}
public function testSetter()
{
$sut = $this->createSut();
// options
$sut->setOption('föööö', 'trääääää');
self::assertEquals('trääääää', $sut->getOption('föööö', 'tröööö'));
self::assertEquals('trääääää', $sut->getOption('föööö', 'tröööö'));
self::assertEquals(array_merge($this->getDefaultOptions(), ['föööö' => 'trääääää']), $sut->getOptions());
$sut->setOptions(['blub' => 'blab', 'dataType' => 'money']);
self::assertEquals(['blub' => 'blab', 'dataType' => 'money', 'föööö' => 'trääääää'], $sut->getOptions());
// id
$sut->setId('cvbnmyx');
self::assertEquals('cvbnmyx', $sut->getId());
// data
$sut->setData('slkudfhalksjdhfkljsahdf');
self::assertEquals('slkudfhalksjdhfkljsahdf', $sut->getData());
$data = new \stdClass();
$sut->setData($data);
self::assertSame($data, $sut->getData());
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Widget\Type\AbstractContainer;
use App\Widget\Type\CompoundChart;
/**
* @covers \App\Widget\Type\CompoundChart
* @covers \App\Widget\Type\AbstractContainer
*/
class CompoundChartTest extends AbstractContainerTest
{
public function createSut(): AbstractContainer
{
return new CompoundChart();
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Widget\Type\AbstractContainer;
use App\Widget\Type\CompoundRow;
/**
* @covers \App\Widget\Type\CompoundRow
* @covers \App\Widget\Type\AbstractContainer
*/
class CompoundRowTest extends AbstractContainerTest
{
public function createSut(): AbstractContainer
{
return new CompoundRow();
}
}

View File

@@ -0,0 +1,36 @@
<?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\Widget\Type;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\Counter;
use App\Widget\Type\SimpleWidget;
/**
* @covers \App\Widget\Type\Counter
*/
class CounterTest extends AbstractWidgetTypeTest
{
public function createSut(): AbstractWidgetType
{
return new Counter();
}
public function getDefaultOptions(): array
{
return ['dataType' => 'int'];
}
public function testExtendsSimpleWidget()
{
$sut = $this->createSut();
self::assertInstanceOf(SimpleWidget::class, $sut);
}
}

View File

@@ -0,0 +1,117 @@
<?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\Widget\Type;
use App\Entity\User;
use App\Model\Statistic\Day;
use App\Repository\TimesheetRepository;
use App\Security\CurrentUser;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\DailyWorkingTimeChart;
use App\Widget\Type\SimpleWidget;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Widget\Type\DailyWorkingTimeChart
* @covers \App\Widget\Type\SimpleWidget
* @covers \App\Widget\Type\AbstractWidgetType
* @covers \App\Repository\TimesheetRepository
*/
class DailyWorkingTimeChartTest extends TestCase
{
public function createSut(): AbstractWidgetType
{
$repository = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
$user = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->setMethods(['getUser'])->getMock();
$user->expects($this->once())->method('getUser')->willReturn(new User());
return new DailyWorkingTimeChart($repository, $user);
}
public function testExtendsSimpleWidget()
{
$sut = $this->createSut();
self::assertInstanceOf(SimpleWidget::class, $sut);
}
public function testDefaultValues()
{
$sut = $this->createSut();
self::assertInstanceOf(AbstractWidgetType::class, $sut);
self::assertEquals('DailyWorkingTimeChart', $sut->getId());
self::assertEquals('stats.yourWorkingHours', $sut->getTitle());
self::assertEquals('monday this week 00:00:00', $sut->getOption('begin', 'xxx'));
self::assertEquals('sunday this week 23:59:59', $sut->getOption('end', 'xxx'));
self::assertEquals('', $sut->getOption('color', 'xxx'));
self::assertInstanceOf(User::class, $sut->getOption('user', 'xxx'));
self::assertEquals('bar', $sut->getOption('type', 'xxx'));
}
public function testFluentInterface()
{
$sut = $this->createSut();
self::assertInstanceOf(AbstractWidgetType::class, $sut->setOptions([]));
self::assertInstanceOf(AbstractWidgetType::class, $sut->setId(''));
self::assertInstanceOf(AbstractWidgetType::class, $sut->setTitle(''));
self::assertInstanceOf(AbstractWidgetType::class, $sut->setData(''));
}
public function testSetter()
{
$sut = $this->createSut();
// options
$sut->setOption('föööö', 'trääääää');
self::assertEquals('trääääää', $sut->getOption('föööö', 'tröööö'));
// check default values
self::assertEquals('xxxxx', $sut->getOption('blub', 'xxxxx'));
self::assertEquals('xxxxx', $sut->getOption('dataType', 'xxxxx'));
$sut->setOptions(['blub' => 'blab', 'dataType' => 'money']);
// check option still exists
self::assertEquals('trääääää', $sut->getOption('föööö', 'tröööö'));
// check options are now existing
self::assertEquals('blab', $sut->getOption('blub', 'xxxxx'));
self::assertEquals('money', $sut->getOption('dataType', 'xxxxx'));
// id
$sut->setId('cvbnmyx');
self::assertEquals('cvbnmyx', $sut->getId());
}
public function testGetOptions()
{
$sut = $this->createSut();
$options = $sut->getOptions(['type' => 'xxx']);
self::assertStringStartsWith('DailyWorkingTimeChart_', $options['id']);
self::assertEquals('bar', $options['type']);
}
public function testGetData()
{
$repository = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->setMethods(['getDailyData'])->getMock();
$repository->expects($this->once())->method('getDailyData')->willReturnCallback(function ($user, $begin, $end) {
return [
['year' => '2019', 'month' => '1', 'day' => 1, 'rate' => 13.75, 'duration' => 1234]
];
});
$user = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->setMethods(['getUser'])->getMock();
$user->expects($this->once())->method('getUser')->willReturn((new User())->setUsername('tralalala'));
$sut = new DailyWorkingTimeChart($repository, $user);
$data = $sut->getData([]);
self::assertCount(7, $data);
foreach ($data as $statObj) {
self::assertInstanceOf(Day::class, $statObj);
}
}
}

View File

@@ -0,0 +1,36 @@
<?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\Widget\Type;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\More;
use App\Widget\Type\SimpleWidget;
/**
* @covers \App\Widget\Type\More
*/
class MoreTest extends AbstractWidgetTypeTest
{
public function createSut(): AbstractWidgetType
{
return new More();
}
public function getDefaultOptions(): array
{
return ['dataType' => 'int'];
}
public function testExtendsSimpleWidget()
{
$sut = $this->createSut();
self::assertInstanceOf(SimpleWidget::class, $sut);
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\SimpleWidget;
/**
* @covers \App\Widget\Type\SimpleWidget
*/
class SimpleWidgetTest extends AbstractWidgetTypeTest
{
public function createSut(): AbstractWidgetType
{
return new SimpleWidget();
}
public function getDefaultOptions(): array
{
return [];
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget\Type;
use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\YearChart;
/**
* @covers \App\Widget\Type\YearChart
*/
class YearChartTest extends AbstractWidgetTypeTest
{
public function createSut(): AbstractWidgetType
{
return new YearChart();
}
public function getDefaultOptions(): array
{
return [];
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Widget;
use App\Widget\WidgetException;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Widget\WidgetException
*/
class WidgetExceptionTest extends TestCase
{
public function testConstruct()
{
$ex = new WidgetException();
self::assertInstanceOf(\Exception::class, $ex);
}
}

View File

@@ -0,0 +1,77 @@
<?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\Widget;
use App\Repository\WidgetRepository;
use App\Widget\Renderer\SimpleWidgetRenderer;
use App\Widget\Type\More;
use App\Widget\WidgetService;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
* @covers \App\Widget\WidgetService
*/
class WidgetServiceTest extends TestCase
{
public function testConstruct()
{
$repository = $this->getMockBuilder(WidgetRepository::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetService($repository, []);
self::assertFalse($sut->hasWidget('sdfsdf'));
self::assertCount(0, $sut->getRenderer());
$sut = new WidgetService($repository, [
new SimpleWidgetRenderer(new Environment(new FilesystemLoader()))
]);
self::assertCount(1, $sut->getRenderer());
}
public function testFindRenderer()
{
$repository = $this->getMockBuilder(WidgetRepository::class)->disableOriginalConstructor()->getMock();
$renderer = new SimpleWidgetRenderer(new Environment(new FilesystemLoader()));
$sut = new WidgetService($repository, [$renderer]);
$sut->addRenderer(new SimpleWidgetRenderer(new Environment(new FilesystemLoader())));
self::assertCount(2, $sut->getRenderer());
$found = $sut->findRenderer(new More());
self::assertSame($renderer, $found);
}
/**
* @expectedException \App\Widget\WidgetException
* @expectedExceptionMessage No renderer available for widget "App\Widget\Type\More"
*/
public function testFindRendererThrowsException()
{
$repository = $this->getMockBuilder(WidgetRepository::class)->disableOriginalConstructor()->getMock();
$sut = new WidgetService($repository, []);
$sut->findRenderer(new More());
}
public function testHasAndGetWidget()
{
$widget = new More();
$repository = $this->getMockBuilder(WidgetRepository::class)->disableOriginalConstructor()->setMethods(['has', 'get'])->getMock();
$repository->expects($this->once())->method('has')->willReturn(true);
$repository->expects($this->once())->method('get')->willReturn($widget);
$sut = new WidgetService($repository, []);
self::assertTrue($sut->hasWidget('sdfsdf'));
self::assertSame($widget, $sut->getWidget('sdfsdf'));
}
}

View File

@@ -257,10 +257,6 @@
<source>dashboard.all</source> <source>dashboard.all</source>
<target>جميع المستخدمين</target> <target>جميع المستخدمين</target>
</trans-unit> </trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>مدير</target>
</trans-unit>
<!-- <!--
Widgets Widgets

View File

@@ -308,10 +308,6 @@
<source>dashboard.all</source> <source>dashboard.all</source>
<target>Alle Benutzer</target> <target>Alle Benutzer</target>
</trans-unit> </trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Administrator</target>
</trans-unit>
<!-- <!--
Widgets Widgets

View File

@@ -308,10 +308,6 @@
<source>dashboard.all</source> <source>dashboard.all</source>
<target>All user</target> <target>All user</target>
</trans-unit> </trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Administrator</target>
</trans-unit>
<!-- <!--
Widgets Widgets

View File

@@ -305,10 +305,6 @@
<source>dashboard.all</source> <source>dashboard.all</source>
<target>Resumen de todos los usuarios</target> <target>Resumen de todos los usuarios</target>
</trans-unit> </trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Administrador</target>
</trans-unit>
<!-- <!--
Widgets Widgets

View File

@@ -308,10 +308,6 @@
<source>dashboard.all</source> <source>dashboard.all</source>
<target>Tous les usagers</target> <target>Tous les usagers</target>
</trans-unit> </trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Administrateur</target>
</trans-unit>
<!-- <!--
Widgets Widgets

View File

@@ -277,10 +277,6 @@
<source>dashboard.all</source> <source>dashboard.all</source>
<target>Minden felhasználó</target> <target>Minden felhasználó</target>
</trans-unit> </trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Adminisztrátor</target>
</trans-unit>
<!-- <!--
Widgets Widgets

View File

@@ -257,10 +257,6 @@
<source>dashboard.all</source> <source>dashboard.all</source>
<target>Tutti gli utenti</target> <target>Tutti gli utenti</target>
</trans-unit> </trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Amministratore</target>
</trans-unit>
<!-- <!--
Widgets Widgets

View File

@@ -297,10 +297,6 @@
<source>dashboard.all</source> <source>dashboard.all</source>
<target>全ユーザーの統計情報</target> <target>全ユーザーの統計情報</target>
</trans-unit> </trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>管理情報</target>
</trans-unit>
<!-- <!--
Widgets Widgets

View File

@@ -257,10 +257,6 @@
<source>dashboard.all</source> <source>dashboard.all</source>
<target>Todos usuários</target> <target>Todos usuários</target>
</trans-unit> </trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Administrador</target>
</trans-unit>
<!-- <!--
Widgets Widgets

View File

@@ -257,10 +257,6 @@
<source>dashboard.all</source> <source>dashboard.all</source>
<target>Все пользователи</target> <target>Все пользователи</target>
</trans-unit> </trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target>Администратор</target>
</trans-unit>
<!-- <!--
Widgets Widgets

View File

@@ -290,10 +290,6 @@
<source>dashboard.all</source> <source>dashboard.all</source>
<target state="translated">Alla användare</target> <target state="translated">Alla användare</target>
</trans-unit> </trans-unit>
<trans-unit id="dashboard.admin">
<source>dashboard.admin</source>
<target state="translated">Administratör</target>
</trans-unit>
<!-- <!--
Widgets Widgets