User profile layout (#2402)

This commit is contained in:
Kevin Papst
2021-03-06 12:34:34 +01:00
committed by GitHub
parent d3099aca39
commit 5583caa9e4
30 changed files with 550 additions and 268 deletions

View File

@@ -37,6 +37,22 @@ td {
}
/* Profile */
.avatar-xs {
width: 20px;
height: 20px;
}
.avatar-sm {
width: 25px;
height: 25px;
}
.avatar-md {
width: 30px;
height: 30px;
}
.avatar-lg {
width: 40px;
height: 40px;
}
.box-profile {
img.img-circle {
width: 100px;

View File

@@ -7,7 +7,7 @@ admin_lte:
# default image for missing user avatar images
default_avatar: build/images/default_avatar.png
# the color skin
skin: skin-green
skin: skin-blue
# if activated, the boxed_layout will be deactivated
fixed_layout: true
# boxed layout (true) or full-screen (false)

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,7 @@
"build/app.b2c3295c.js"
],
"css": [
"build/app.cafebaaa.css"
"build/app.fe8aa88b.css"
]
},
"invoice": {
@@ -54,7 +54,7 @@
"build/0.79dbdbb9.js": "sha384-U2Ao0ORAZ8PCeDmyRsqQFET3hc7pfUBimq0PrqFdG4/s0Bdi+qBj4TJK3o70bCd5",
"build/1.32489d92.js": "sha384-wVkjh5FzjFhMV4S4uNP23E/OLBOf+Zi7t3lpm9eWzoMr/tm2pydT+q0Op1XHuoUP",
"build/app.b2c3295c.js": "sha384-lqEgWnKWvGpWM7J8WsMYvPZv8+EYdwCG5T9pHtwdLerTh9wlmYyXGfFCMbc/iVS6",
"build/app.cafebaaa.css": "sha384-gTa4tNAiWKmWXUNGKUz45SW9E2Etr68bq3XC+Y+a+VC5EOfB3s9pbNGlwp6yEinW",
"build/app.fe8aa88b.css": "sha384-xmgEZuf0VPsvPdzU/yqK8qL6W+d+d6LJn0e0oD66LYnCxs5I44d1mp1bAtRN905Q",
"build/invoice.74279541.js": "sha384-2BXic5Sgorf2tXai6zSAN4wLY2dbg06L03/xMKW6itMcszvtnRArKzfBh6DNcF3f",
"build/invoice.13d8ef4e.css": "sha384-B6RN/wZJToSBCZk2JeLokIqWEhbh+Eb9arYbt9dM+YoC2Z6PnCeTwTqSGyexWWJh",
"build/invoice-pdf.0efd7a97.js": "sha384-bSdIeRCtEJiYYuc2reb0e5CpJ1Kbd1lQNEkElMTiq1SX0IINzdwJJYf6WnCcHrNC",

View File

@@ -2,7 +2,7 @@
"build/0.79dbdbb9.js": "build/0.79dbdbb9.js",
"build/1.32489d92.js": "build/1.32489d92.js",
"build/2.7ab75d0a.js": "build/2.7ab75d0a.js",
"build/app.css": "build/app.cafebaaa.css",
"build/app.css": "build/app.fe8aa88b.css",
"build/app.js": "build/app.b2c3295c.js",
"build/calendar.css": "build/calendar.1408f57e.css",
"build/calendar.js": "build/calendar.070aab88.js",

View File

@@ -61,6 +61,7 @@ final class ReportingController extends AbstractController
private function canSelectUser(): bool
{
// also found in App\EventSubscriber\Actions\UserSubscriber
if (!$this->isGranted('view_other_timesheet')) {
return false;
}

View File

@@ -23,7 +23,6 @@ class DoctrineCompilerPass implements CompilerPassInterface
*/
private $allowedEngines = [
'mysql' => 'mysql',
'mysqli' => 'mysql',
'sqlite' => 'sqlite',
];

View File

@@ -394,7 +394,7 @@ class Configuration implements ConfigurationInterface
->setDeprecated('The node "%node%" at path "%path%" is deprecated, please use "kimai.timesheet.active_entries.soft_limit" instead.')
->end()
->scalarNode('box_color')
->defaultValue('green')
->defaultValue('blue')
->setDeprecated('The node "%node%" at path "%path%" was removed, please delete it from your config.')
->end()
->scalarNode('select_type')

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\Event;
use App\Entity\User;
class PageActionsEvent extends ThemeEvent
{
private $action;
public function __construct(User $user, array $payload, string $action)
{
if (!\array_key_exists('actions', $payload)) {
$payload['actions'] = [];
}
parent::__construct($user, $payload);
$this->action = $action;
}
public function getActionName(): string
{
return $this->action;
}
public function getActions(): array
{
return $this->payload['actions'];
}
public function setActions(array $actions): void
{
$this->payload['actions'] = $actions;
}
}

View File

@@ -12,7 +12,7 @@ namespace App\Event;
use App\Entity\User;
use Symfony\Contracts\EventDispatcher\Event;
final class ThemeEvent extends Event
class ThemeEvent extends Event
{
public const JAVASCRIPT = 'app.theme.javascript';
public const STYLESHEET = 'app.theme.css';
@@ -25,15 +25,15 @@ final class ThemeEvent extends Event
/**
* @var User|null
*/
protected $user;
private $user;
/**
* @var string
*/
protected $content = '';
private $content = '';
/**
* @var mixed
*/
protected $payload = null;
protected $payload;
public function __construct(?User $user = null, $payload = null)
{
@@ -41,13 +41,8 @@ final class ThemeEvent extends Event
$this->payload = $payload;
}
/**
* @deprecated since 1.0
*/
public function getUser(): ?User
{
@trigger_error('Using ThemeEvent::getUser() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
return $this->user;
}

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\EventSubscriber\Actions;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
abstract class AbstractActionsSubscriber implements EventSubscriberInterface
{
private $auth;
private $urlGenerator;
public function __construct(AuthorizationCheckerInterface $security, UrlGeneratorInterface $urlGenerator)
{
$this->auth = $security;
$this->urlGenerator = $urlGenerator;
}
protected function isGranted($attributes, $subject = null): bool
{
return $this->auth->isGranted($attributes, $subject);
}
protected function path(string $route, array $parameters = []): string
{
return $this->urlGenerator->generate($route, $parameters);
}
}

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\EventSubscriber\Actions;
use App\Entity\User;
use App\Event\PageActionsEvent;
class UserSubscriber extends AbstractActionsSubscriber
{
public static function getSubscribedEvents(): array
{
return [
'actions.user' => ['onActions', 1000],
];
}
public function onActions(PageActionsEvent $event)
{
$payload = $event->getPayload();
if (!isset($payload['user'])) {
return;
}
/** @var User $user */
$user = $payload['user'];
if ($user->getId() === null) {
return;
}
$actions = $event->getActions();
if ($this->isGranted('view', $user)) {
$actions['profile-stats'] = ['icon' => 'avatar', 'url' => $this->path('user_profile', ['username' => $user->getUsername()]), 'translation_domain' => 'actions'];
}
if (\count($actions) > 0) {
$actions['divider'] = null;
}
$subActions = [];
if ($this->isGranted('edit', $user)) {
$subActions['edit'] = ['url' => $this->path('user_profile_edit', ['username' => $user->getUsername()]), 'title' => 'edit', 'translation_domain' => 'actions'];
}
if ($this->isGranted('preferences', $user)) {
$subActions['settings'] = ['url' => $this->path('user_profile_preferences', ['username' => $user->getUsername()]), 'title' => 'settings', 'translation_domain' => 'actions'];
}
if ($this->isGranted('password', $user)) {
$subActions['password'] = ['url' => $this->path('user_profile_password', ['username' => $user->getUsername()]), 'title' => 'profile.password'];
}
if ($this->isGranted('api-token', $user)) {
$subActions['api-token'] = ['url' => $this->path('user_profile_api_token', ['username' => $user->getUsername()]), 'title' => 'profile.api-token'];
}
if ($this->isGranted('teams', $user)) {
$subActions['teams'] = ['url' => $this->path('user_profile_teams', ['username' => $user->getUsername()]), 'title' => 'profile.teams'];
}
if ($this->isGranted('roles', $user)) {
$subActions['roles'] = ['url' => $this->path('user_profile_roles', ['username' => $user->getUsername()]), 'title' => 'profile.roles'];
}
if (\count($subActions) > 0) {
$actions['edit'] = ['children' => $subActions, 'title' => 'edit'];
$actions['divider2'] = null;
}
$viewOther = $this->isGranted('view_other_timesheet');
if ($this->isGranted('view_reporting')) {
if ($viewOther || ($event->getUser()->getId() === $user->getId())) {
$actions['menu.reporting'] = ['url' => $this->path('report_user_month', ['user' => $user->getId()]), 'icon' => 'reporting'];
}
}
if ($viewOther && $user->isEnabled()) {
$actions['timesheet'] = $this->path('admin_timesheet', ['users[]' => $user->getId()]);
}
$view = $payload['view'] ?? null;
if ($view === 'index' && $this->isGranted('delete', $user)) {
$actions['trash'] = ['url' => $this->path('admin_user_delete', ['id' => $user->getId()]), 'class' => 'modal-ajax-form'];
}
$payload['actions'] = array_merge($payload['actions'], $actions);
$event->setPayload($payload);
}
}

View File

@@ -24,6 +24,7 @@ final class IconExtension extends AbstractExtension
'audit' => 'fas fa-history',
'avatar' => 'fas fa-user',
'back' => 'fas fa-long-arrow-alt-left',
'barcode' => 'fas fa-barcode',
'calendar' => 'far fa-calendar-alt',
'clock' => 'far fa-clock',
'comment' => 'far fa-comment',
@@ -51,6 +52,7 @@ final class IconExtension extends AbstractExtension
'left' => 'fas fa-chevron-left',
'list' => 'fas fa-list',
'locked' => 'fas fa-lock',
'login' => 'fas fa-sign-in-alt',
'logout' => 'fas fa-sign-out-alt',
'mail' => 'fas fa-envelope-open',
'mail-sent' => 'fas fa-paper-plane',

View File

@@ -10,6 +10,7 @@
namespace App\Twig\Runtime;
use App\Entity\User;
use App\Event\PageActionsEvent;
use App\Event\ThemeEvent;
use App\Event\ThemeJavascriptTranslationsEvent;
use Symfony\Bridge\Twig\AppVariable;
@@ -51,6 +52,23 @@ final class ThemeExtension implements RuntimeExtensionInterface
return $themeEvent;
}
public function actions(User $user, string $action, array $payload): ThemeEvent
{
if (!\array_key_exists('actions', $payload)) {
$payload['actions'] = [];
}
$themeEvent = new PageActionsEvent($user, $payload, $action);
$eventName = 'actions.' . $action;
if ($this->eventDispatcher->hasListeners($eventName)) {
$this->eventDispatcher->dispatch($themeEvent, $eventName);
}
return $themeEvent;
}
public function getJavascriptTranslations(): array
{
$event = new ThemeJavascriptTranslationsEvent();

View File

@@ -28,6 +28,7 @@ class RuntimeExtensions extends AbstractExtension
{
return [
new TwigFunction('trigger', [ThemeExtension::class, 'trigger'], ['needs_environment' => true]),
new TwigFunction('actions', [ThemeExtension::class, 'actions']),
new TwigFunction('javascript_translations', [ThemeExtension::class, 'getJavascriptTranslations']),
new TwigFunction('timesheet_exporter', [ExporterExtension::class, 'getTimesheetExporter']),
new TwigFunction('active_timesheets', [TimesheetExtension::class, 'activeEntries']),

View File

@@ -3,28 +3,26 @@
{% endmacro %}
{%- macro page_actions(tools) -%}
{% import _self as macro %}
<div class="breadcrumb">
<div class="box-tools">
{{ macro.button_group(tools) }}
{{ _self.button_group(tools) }}
</div>
</div>
{%- endmacro -%}
{%- macro entity_actions(tools) -%}
{% import _self as macro %}
<div class="breadcrumb">
<div class="box-tools">
<div class="btn-group">
{% set actions = {} %}
{%- for icon, values in tools %}
{% if icon == 'back' %}
{{ macro.action_button(icon, values) }}
{{ _self.action_button(icon, values) }}
{% else %}
{% set actions = actions|merge({(icon): values}) %}
{% endif %}
{% endfor %}
{{ macro.table_actions(actions, '') }}
{{ _self.table_actions(actions, '') }}
</div>
</div>
</div>
@@ -35,21 +33,18 @@
{% endmacro %}
{% macro label_boolean(visible) %}
{% import _self as macro %}
{% if visible %}
{{ macro.label('yes'|trans, 'success') }}
{{ _self.label('yes'|trans, 'success') }}
{% else %}
{{ macro.label('no'|trans, 'default') }}
{{ _self.label('no'|trans, 'default') }}
{% endif %}
{% endmacro %}
{% macro label_visible(visible) %}
{% import _self as macro %}
{{ macro.label_boolean(visible) }}
{{ _self.label_boolean(visible) }}
{% endmacro %}
{% macro label_role(role) %}
{% import _self as macro %}
{% set color = 'primary' %}
{% if role == 'ROLE_SUPER_ADMIN' %}
{% set color = 'danger' %}
@@ -60,7 +55,7 @@
{% elseif role == 'ROLE_USER' %}
{% set color = 'gray' %}
{% endif %}
{{ macro.label(role|trans, color) }}
{{ _self.label(role|trans, color) }}
{% endmacro %}
{% macro username(user) %}
@@ -68,13 +63,11 @@
{% endmacro %}
{% macro label_user(user) %}
{% import _self as macro %}
{{ macro.label(user.displayName, 'primary') }}
{{ _self.label(user.displayName, 'primary') }}
{% endmacro %}
{% macro label_team(team, class) %}
{% import _self as macro %}
{{ macro.label(team.name, class|default('primary')) }}
{{ _self.label(team.name, class|default('primary')) }}
{% endmacro %}
{% macro user_avatar(user, tooltip, class) %}
@@ -87,7 +80,6 @@
{% endmacro %}
{% macro label_activity(activity, url) %}
{% import _self as macro %}
{% set isVisible = activity.visible %}
{% set color = activity.color %}
{% if color is empty and activity.project is not null %}
@@ -99,22 +91,19 @@
{% set isVisible = activity.project.customer.visible %}
{% endif %}
{% endif %}
{{ macro.label_color_dot('activity', isVisible, activity.name, url, color) }}
{{ _self.label_color_dot('activity', isVisible, activity.name, url, color) }}
{% endmacro %}
{% macro label_project(project, url) %}
{% import _self as macro %}
{% set isVisible = false %}
{% if project.visible and project.customer.visible %}
{% set isVisible = true %}
{% endif %}
{{ macro.label_color_dot('project', isVisible, project.name, url, (project.color ?? project.customer.color)) }}
{{ _self.label_color_dot('project', isVisible, project.name, url, (project.color ?? project.customer.color)) }}
{% endmacro %}
{% macro label_customer(customer, url) %}
{% import _self as macro %}
{% set isVisible = customer.visible %}
{{ macro.label_color_dot('customer', isVisible, customer.name, url, customer.color) }}
{{ _self.label_color_dot('customer', customer.visible, customer.name, url, customer.color) }}
{% endmacro %}
{% macro label_color_dot(type, isVisible, name, url, color) %}
@@ -137,11 +126,10 @@
{% endmacro %}
{% macro badge_team_access(teams) %}
{% import _self as macro %}
{% if teams|length > 0 %}
{{ macro.badge_counter(teams|length) }}
{{ _self.badge_counter(teams|length) }}
{% else %}
{{ macro.icon('unlocked') }}
{{ _self.icon('unlocked') }}
{% endif %}
{% endmacro %}
@@ -218,7 +206,6 @@
{% endmacro %}
{% macro table_actions(actions, class) %}
{%- import _self as macro -%}
{% if actions|length >= 1 %}
{% if class is null %}
{% set class = 'btn-sm' %}
@@ -249,9 +236,17 @@
{% set trash = values %}
{% else %}
{% set divider = false %}
<li>
{{ macro.action_button(icon, values, false) }}
</li>
{% if values.children is defined %}
{%- for childIcon, childValues in values.children %}
<li>
{{ _self.action_button(childIcon, childValues, false) }}
</li>
{% endfor %}
{% else %}
<li>
{{ _self.action_button(icon, values, false) }}
</li>
{% endif %}
{% endif %}
{% endif %}
{% endfor -%}
@@ -260,7 +255,7 @@
<li class="divider"></li>
{% endif %}
<li class="delete">
{{ macro.action_button('trash', trash, false) }}
{{ _self.action_button('trash', trash, false) }}
</li>
{% endif -%}
{% endapply %}
@@ -269,8 +264,37 @@
{% endif %}
{% endmacro %}
{% macro list_group_actions(actions, highlightUrl) %}
{% if actions|length > 0 %}
<div class="list-group">
{% for icon, values in actions %}
{% if 'divider' in icon and values is null %}
</div><div class="list-group">
{# <a class="list-group-item disabled list-group-divider"></a> #}
{% else %}
{% if values is not iterable %}
{% set values = {'url': values} %}
{% endif %}
{% if values['children'] is defined %}
{{ _self.list_group_actions(values['children'], highlightUrl) }}
{% else %}
{% if values['title'] is not defined %}
{% set values = values|merge({'title': icon|trans({}, 'actions')}) %}
{% endif %}
{% set class = 'list-group-item' %}
{% if highlightUrl == values.url %}
{% set class = class ~ ' active' %}
{% endif %}
{% set values = values|merge({'class': class}) %}
{{ _self.action_button(icon, values, false) }}
{% endif %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endmacro %}
{% macro action_button(icon, values, type) %}
{%- import _self as macro -%}
{%- apply spaceless -%}
{% set id = null %}
{% set onclick = null %}
@@ -281,6 +305,7 @@
{% set title = null %}
{% set disabled = false %}
{% set attr = {} %}
{% set translation_domain = 'messages' %}
{% if type is same as (false) %}
{% set class = "" %}
{% elseif type is null %}
@@ -289,7 +314,7 @@
{% set class = "btn btn-" ~ type ~ " btn-" ~ icon ~ " " %}
{% endif %}
{% if not values is iterable %}
{% if values is not iterable %}
{% set url = values %}
{% if 'onclick:' in url %}
{% set onclick = url|replace({'onclick:': ''}) %}
@@ -314,6 +339,7 @@
{% set title = values.title ?? null %}
{% set class = class ~ ( values.class | default("")) %}
{% set attr = values.attr ?? {} %}
{% set translation_domain = values.translation_domain ?? translation_domain %}
{% endif %}
{% if disabled is same as (true) %}
@@ -332,30 +358,33 @@
{{ ' ' ~ 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|trans({}, translation_domain) }}{% else %}{{ _self.icon(icon) }}{% endif %}</a>
{% endapply %}
{% endmacro %}
{% macro button_group(actions, type) %}
{%- import _self as macro -%}
<div class="btn-group">
{%- apply spaceless -%}
{%- for icon,values in actions %}
{% if values.children is defined %}
<div class="btn-group">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
{{ macro.icon(icon) }}&nbsp;
<span class="caret"></span>
<span class="sr-only">{{ 'label.toggle_dropdown'|trans }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
{% for childIcon,childValues in values.children %}
<li>{{ macro.action_button(childIcon, childValues, false) }}</li>
{% endfor %}
</ul>
</div>
{% if 'divider' in icon and values is null %}
{# what to do here ? #}
{% else %}
{{ macro.action_button(icon, values, type) }}
{% if values.children is defined %}
<div class="btn-group">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
{{ _self.icon(icon) }}&nbsp;
<span class="caret"></span>
<span class="sr-only">{{ 'label.toggle_dropdown'|trans }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
{% for childIcon,childValues in values.children %}
<li>{{ _self.action_button(childIcon, childValues, false) }}</li>
{% endfor %}
</ul>
</div>
{% else %}
{{ _self.action_button(icon, values, type) }}
{% endif %}
{% endif %}
{% endfor -%}
{% endapply %}
@@ -416,7 +445,8 @@
{% endif %}
{% endmacro %}
{% macro team_list(teams, showTitle) %}
{% macro team_list(teams, showTitle, collapseAt) %}
{% set collapseAt = collapseAt ?? 5 %}
{% if showTitle is null %}
{% set showTitle = true %}
{% endif %}
@@ -447,12 +477,12 @@
{{ macro.user_avatar(user) }}
{% set counter = counter + 1 %}
{% endif %}
{% if userTeamCount > 5 and counter == 4 and loop.index != userTeamCount %}
<a href="#" onclick="$('#{{ teamHiddenId }}').toggleClass('hidden');$(this).hide();return false;" class="badge">{{ 'label.plus_more'|trans({'%count%': (userTeamCount - 5)}) }}</a>
{% if userTeamCount > collapseAt and counter == (collapseAt - 1) and loop.index != userTeamCount %}
<a href="#" onclick="$('#{{ teamHiddenId }}').toggleClass('hidden');$(this).hide();return false;" class="badge">{{ 'label.plus_more'|trans({'%count%': (userTeamCount - collapseAt)}) }}</a>
<span class="hidden" id="{{ teamHiddenId }}">
{% set counter = counter + 1 %}
{% endif %}
{% if userTeamCount > 5 and counter != 4 and loop.index == userTeamCount %}
{% if userTeamCount > collapseAt and counter != (collapseAt - 1) and loop.index == userTeamCount %}
</span>
{% endif %}
{% endfor %}

View File

@@ -28,80 +28,17 @@
{% endmacro %}
{% macro user_permissions(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if view != 'index' and is_granted('role_permissions') %}
{% set actions = actions|merge({'permissions': path('admin_user_permissions')}) %}
{% endif %}
{% if view != 'role' and is_granted('role_permissions') %}
{% set actions = actions|merge({'create': {'url': path('admin_user_roles'), 'class': 'modal-ajax-form'}}) %}
{% endif %}
{% set actions = actions|merge({'help': {'url': 'permissions.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.user_permissions', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% deprecated 'The "user_permissions" macro from user/action.html.twig is deprecated since 1.14 and will be removed with 2.0. Use "permission/actions.html.twig" instead.' %}
{% import "permission/actions.html.twig" as realMacro %}
{{ realMacro.user_permissions(view) }}
{% endmacro %}
{% macro user(user, view, options) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if user.id is not empty %}
{% set view_other = is_granted('view_other_timesheet') %}
{% if is_granted('view', user) %}
{% set actions = actions|merge({'profile-stats': {'url': path('user_profile', {'username' : user.username})}}) %}
{% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}
{% set subActions = {} %}
{% if is_granted('edit', user) %}
{% set subActions = subActions|merge({'edit': path('user_profile_edit', {'username' : user.username})}) %}
{% endif %}
{% if is_granted('preferences', user) %}
{% set subActions = subActions|merge({'settings': {'url': path('user_profile_preferences', {'username' : user.username})}}) %}
{% endif %}
{% if is_granted('password', user) %}
{% set subActions = subActions|merge({'password': {'url': path('user_profile_password', {'username' : user.username}), 'title': ('profile.password'|trans)}}) %}
{% endif %}
{% if is_granted('api-token', user) %}
{% set subActions = subActions|merge({'api-token': {'url': path('user_profile_api_token', {'username' : user.username}), 'title': ('profile.api-token'|trans)}}) %}
{% endif %}
{% if is_granted('teams', user) %}
{% set subActions = subActions|merge({'teams': {'url': path('user_profile_teams', {'username' : user.username}), 'title': ('profile.teams'|trans)}}) %}
{% endif %}
{% if is_granted('roles', user) %}
{% set subActions = subActions|merge({'roles': {'url': path('user_profile_roles', {'username' : user.username}), 'title': ('profile.roles'|trans)}}) %}
{% endif %}
{% if subActions|length > 0 %}
{% set actions = actions|merge(subActions) %}
{% set actions = actions|merge({'divider2': null}) %}
{% endif %}
{% if is_granted('view_reporting') %}
{% if view_other or app.user.id == user.id %}
{% set actions = actions|merge({'report': path('report_user_month', {'user': user.id})}) %}
{% endif %}
{% endif %}
{% if view_other and user.enabled %}
{% set actions = actions|merge({'timesheet': path('admin_timesheet', {'users': [user.id]})}) %}
{% endif %}
{% if view == 'index' and is_granted('delete', user) %}
{% set actions = actions|merge({'trash': {'url': path('admin_user_delete', {'id': user.id}), 'class': 'modal-ajax-form'}}) %}
{% endif %}
{% endif %}
{% if options.back is defined %}
{% set actions = actions|merge({'back': options.back}) %}
{% endif %}
{% set event = trigger('actions.user', {'actions': actions, 'view': view, 'user': user}) %}
{% set event = actions(app.user, 'user', {'view': view, 'user': user}) %}
{% if view == 'index' %}
{{ widgets.table_actions(event.payload.actions) }}
{{ widgets.table_actions(event.actions) }}
{% else %}
{{ widgets.entity_actions(event.payload.actions) }}
{{ widgets.page_actions(event.actions) }}
{% endif %}
{% endmacro %}

View File

@@ -1,6 +1,6 @@
{% extends 'user/layout.html.twig' %}
{% block main %}
{% block profile_content %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}

View File

@@ -1,6 +1,7 @@
{% extends 'user/layout.html.twig' %}
{% block main %}
{% block profile_content %}
{{ form_start(form) }}
{% for section in sections %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}

View File

@@ -5,8 +5,9 @@
{% import "user/actions.html.twig" as actions %}
{% set columns = {
'avatar': {'class': 'alwaysVisible w-min', 'title': null, 'orderBy': false},
'alias': {'class': 'alwaysVisible'},
'title': {'class': 'hidden-xs hidden-sm'},
'title': {'class': 'hidden-xs hidden-sm hidden'},
'email': {'class': 'hidden-xs hidden-sm hidden', 'orderBy': false},
'lastLogin': {'class': 'hidden-xs hidden-sm hidden', 'orderBy': false},
'roles': {'class': 'hidden-sm', 'orderBy': false},
@@ -27,9 +28,10 @@
{% block page_title %}{{ 'admin_user.title'|trans }}{% endblock %}
{% block page_search %}{{ toolbar.dropDownSearch(toolbarForm) }}{% endblock %}
{% block page_actions %}{{ actions.users('index') }}{% endblock %}
{% block page_icon %}{{ 'user'|icon }}{% endblock %}
{% block main_before %}
{{ tables.data_table_column_modal(tableName, columns) }}
{{ tables.data_table_column_modal(tableName, columns, 'kimai.userUpdate') }}
{% endblock %}
{% block main %}
@@ -41,7 +43,8 @@
{% for entry in entries %}
<tr{% if is_granted('view', entry) %} class="open-edit alternative-link" data-href="{{ path('user_profile', {'username': entry.username}) }}"{% endif %}>
<td class="{{ tables.data_table_column_class(tableName, columns, 'alias') }}">{{ widgets.user_avatar(entry) }} {{ widgets.username(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'avatar') }}">{{ widgets.user_avatar(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'alias') }}">{{ widgets.username(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'title') }}">{{ entry.title }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'email') }}">{{ entry.email }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'lastLogin') }}">{% if entry.lastLogin is not null %}{{ entry.lastLogin|date_full }}{% endif %}</td>

View File

@@ -1,111 +1,31 @@
{% extends 'base.html.twig' %}
{% import "user/actions.html.twig" as actions %}
{% import "macros/widgets.html.twig" as widgets %}
{% block page_title %}{% if not user.alias is empty %}{{ user.alias }}{% else %}{{ user.username }}{% endif %}{% endblock %}
{% block page_subtitle %}{% if not user.alias is empty %}{{ user.username }}{% endif %}{% endblock %}
{% block page_title %}{{ 'admin_user.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.user(user, tab) }}{% endblock %}
{% block page_icon %}{{ 'user'|icon }}{% endblock %}
{% block main %}
<div class="row">
<div class="col-md-8">
<div class="col-xs-12 col-sm-9 col-md-9 col-lg-10">
{% block profile_intro %}
<div class="panel panel-default">
<div class="panel-body">
{{ widgets.user_avatar(user, false, 'avatar-md') }}
{{ user.displayName }}
{% if user.title is not empty %} &ndash; {{ user.title }} {% endif %}
</div>
</div>
{% endblock %}
{% block profile_content %}{% endblock %}
</div>
<div class="col-md-4">
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_body_class %}box-profile{% endblock %}
{% block box_body %}
<div class="text-center">
{{ widgets.user_avatar(user) }}
<h3 class="profile-username">{{ widgets.username(user) }}</h3>
<p class="text-muted">{{ user.title }}</p>
</div>
{% set seeOwnRate = is_granted('view_rate_own_timesheet') %}
<table class="table">
<tr>
<th>{{ 'stats.durationMonth'|trans }}</th>
<td class="text-nowrap pull-right">{{ stats.durationThisMonth|duration }}</td>
</tr>
{% if seeOwnRate %}
<tr>
<th>{{ 'stats.amountMonth'|trans }}</th>
{# TODO which currency shall we use here? #}
<td class="text-nowrap pull-right">{{ stats.amountThisMonth|money }}</td>
</tr>
{% endif %}
<tr>
<th>{{ 'stats.durationTotal'|trans }}</th>
<td class="text-nowrap pull-right">{{ stats.durationTotal|duration }}</td>
</tr>
{% if seeOwnRate %}
<tr>
<th>{{ 'stats.amountTotal'|trans }}</th>
{# TODO which currency shall we use here? #}
<td class="text-nowrap pull-right">{{ stats.amountTotal|money }}</td>
</tr>
{% endif %}
</table>
{% endblock %}
{% endembed %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% block box_title %}{{ 'profile.about_me'|trans }}{% endblock %}
{% block box_body %}
{# colors = purple, blue, aqua, red, green #}
<table class="table no-border">
<tr>
<th>{{ 'label.id'|trans }}</th>
<td class="text-nowrap pull-right">{{ user.id }}</td>
</tr>
<tr>
<th>{{ 'label.username'|trans }}</th>
<td class="text-nowrap pull-right">{{ user.username }}</td>
</tr>
<tr>
<th>{{ 'profile.first_entry'|trans }}</th>
<td class="text-nowrap pull-right">{{ stats.firstEntry|date_short }}</td>
</tr>
<tr>
<th>{{ 'profile.registration_date'|trans }}</th>
<td class="text-nowrap pull-right">{{ user.registeredAt|date_short }}</td>
</tr>
{% if is_granted('hourly-rate', user) %}
<tr>
<th>{{ 'label.hourlyRate'|trans }}</th>
<td class="text-nowrap pull-right">{{ user.preferenceValue('hourly_rate') }}</td>
</tr>
{% endif %}
</table>
{% endblock %}
{% endembed %}
{% if user.teams is not empty and (is_granted('teams', user) or (app.user == user and is_granted('view_team_member'))) %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ 'label.my_teams'|trans }}{% endblock %}
{% block box_tools %}
{% if is_granted('roles', user) %}
<a class="btn-box-tool" href="{{ path('user_profile_teams', {'username': user.username}) }}"><i class="{{ 'edit'|icon }}"></i></a>
{% endif %}
{% endblock %}
{% block box_body %}
{{ widgets.team_list(user.teams) }}
{% endblock %}
{% endembed %}
{% endif %}
<div class="col-xs-12 col-sm-3 col-md-3 col-lg-2">
<div class="hidden-xs">
{% set event = actions(app.user, 'user', {'view': tab, 'user': user}) %}
{{ widgets.list_group_actions(event.actions, app.request.requestUri) }}
</div>
{% block profile_navbar %}{% endblock %}
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.teamUpdate', true);
});
</script>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends 'user/layout.html.twig' %}
{% block main %}
{% block profile_content %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}

View File

@@ -11,8 +11,16 @@
{{ encore_entry_script_tags('chart') }}
{% endblock %}
{% block profile_navbar %}
{{ _self.about_me(user, stats) }}
{% endblock %}
{% block profile_intro %}{% endblock %}
{% block profile_content %}
{{ _self.profile_box_horizontal(user, stats) }}
{% if years is empty %}
{{ widgets.nothing_found() }}
{% endif %}
@@ -49,14 +57,13 @@
}
}
}
{%- if is_granted('view_reporting') and (app.user.id == user.id or is_granted('view_other_timesheet')) -%}
{%- if user.enabled and is_granted('view_reporting') and (app.user.id == user.id or is_granted('view_other_timesheet')) -%}
,
onClick: function(event, elements) {
var element = elements[0];
var month = this.data.datasets[0].monthData[element._index];
var formattedMonth = moment(month).format('{{ stat_date_format }}');
var reportUrl = '{{ path('report_user_month', {'user': user.id, 'date': 'XXXXX'})|raw }}'.replace('XXXXX', formattedMonth);
document.location = reportUrl;
document.location = '{{ path('report_user_month', {'user': user.id, 'date': 'XXXXX'})|raw }}'.replace('XXXXX', formattedMonth);
}
{% endif %}
};
@@ -116,4 +123,147 @@
{% endfor %}
{% if user.teams is not empty and (is_granted('teams', user) or (app.user == user and is_granted('view_team_member'))) %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ 'label.my_teams'|trans }}{% endblock %}
{% block box_body %}
{{ widgets.team_list(user.teams) }}
{% endblock %}
{% endembed %}
{% endif %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.teamUpdate', true);
});
</script>
{% endblock %}
{% macro about_me(user, stats) %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}
<a href="mailto:{{ user.email }}" title="{{ 'label.email'|trans }}">{{ widgets.icon('mail') }}</a>
<span data-toggle="tooltip" title="{{ 'label.id'|trans }}: {{ user.id }}">{{ 'profile.about_me'|trans }}</span>
{% endblock %}
{% block box_body %}
<p>
<strong>{{ 'profile.first_entry'|trans }}</strong><br>
{# FIXME use a configuration for it #}
{{ stats.firstEntry|date_short }}
</p>
<p>
<strong>{{ 'profile.registration_date'|trans }}</strong><br>
{{ user.registeredAt|date_short }}
</p>
{% if is_granted('hourly-rate', user) and user.preferenceValue('hourly_rate') is not null %}
<p>
<strong>{{ 'label.hourlyRate'|trans }}</strong><br>
{{ user.preferenceValue('hourly_rate') }}
</p>
{% endif %}
{% endblock %}
{% endembed %}
{% endmacro %}
{% macro profile_box_vertical(user, stats) %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_body_class %}box-profile box-user-profile{% endblock %}
{% block box_body %}
<div class="text-center">
{{ widgets.user_avatar(user) }}
<h3 class="profile-username">{{ widgets.username(user) }}</h3>
<p class="text-muted">{{ user.title }}</p>
</div>
{% set seeOwnRate = is_granted('view_rate_own_timesheet') %}
<table class="table">
<tr>
<th>{{ 'stats.durationMonth'|trans }}</th>
<td class="text-nowrap pull-right">{{ stats.durationThisMonth|duration }}</td>
</tr>
{% if seeOwnRate %}
<tr>
<th>{{ 'stats.amountMonth'|trans }}</th>
{# TODO which currency shall we use here? #}
<td class="text-nowrap pull-right">{{ stats.amountThisMonth|money }}</td>
</tr>
{% endif %}
<tr>
<th>{{ 'stats.durationTotal'|trans }}</th>
<td class="text-nowrap pull-right">{{ stats.durationTotal|duration }}</td>
</tr>
{% if seeOwnRate %}
<tr>
<th>{{ 'stats.amountTotal'|trans }}</th>
{# TODO which currency shall we use here? #}
<td class="text-nowrap pull-right">{{ stats.amountTotal|money }}</td>
</tr>
{% endif %}
</table>
{% endblock %}
{% endembed %}
{% endmacro %}
{% macro profile_box_horizontal(user, stats) %}
{% import "@AdminLTE/Macros/default.html.twig" as macro %}
{% import "macros/widgets.html.twig" as widgets %}
{% set color = kimai_context.chart.background_color %}
{% set event = trigger('user.profile_background', {'color': color, 'font': color|font_contrast, 'user': user, 'image': null}) %}
{% set config = event.payload %}
<div class="box box-widget widget-user box-user-profile">
<div class="widget-user-header" style="{% if config.image is not null %}background: url('{{ config.image }}') center center;{% else %}background-color: {{ config.color }}{% endif %}">
<h3 class="widget-user-username" style="color: {{ config.font }}">
{{ widgets.username(user) }}
</h3>
<h5 class="widget-user-desc" style="color: {{ config.font }}">{{ user.title }}</h5>
</div>
<div class="widget-user-image">
{{ widgets.user_avatar(user) }}
</div>
<div class="box-footer">
{% if stats is not null %}
{% set seeOwnRate = is_granted('view_rate_own_timesheet') %}
{% set columnLength = seeOwnRate ? 3 : 6 %}
<div class="row">
<div class="col-sm-{{ columnLength }} border-right">
<div class="description-block">
<h5 class="description-header">{{ stats.durationThisMonth|duration }}</h5>
<span class="description-text">{{ 'stats.durationMonth'|trans }}</span>
</div>
</div>
{% if seeOwnRate %}
<div class="col-sm-{{ columnLength }} border-right">
<div class="description-block">
<h5 class="description-header">{{ stats.amountThisMonth|money }}</h5>
<span class="description-text">{{ 'stats.amountMonth'|trans }}</span>
</div>
</div>
{% endif %}
<div class="col-sm-{{ columnLength }} border-right">
<div class="description-block">
<h5 class="description-header">{{ stats.durationTotal|duration }}</h5>
<span class="description-text">{{ 'stats.durationTotal'|trans }}</span>
</div>
</div>
{% if seeOwnRate %}
<div class="col-sm-{{ columnLength }} border-right">
<div class="description-block">
<h5 class="description-header">{{ stats.amountTotal|money }}</h5>
<span class="description-text">{{ 'stats.amountTotal'|trans }}</span>
</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endmacro %}

View File

@@ -1,7 +1,7 @@
{% extends 'user/layout.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block main %}
{% block profile_content %}
<div class="row">
<div class="col-md-12">

View File

@@ -74,7 +74,7 @@ class ProfileControllerTest extends ControllerBaseTest
protected function assertHasProfileBox(HttpKernelBrowser $client, string $username)
{
$profileBox = $client->getCrawler()->filter('div.box-body.box-profile');
$profileBox = $client->getCrawler()->filter('div.box-user-profile');
$this->assertEquals(1, $profileBox->count());
$profileAvatar = $profileBox->filter('img.img-circle');
$this->assertEquals(1, $profileAvatar->count());
@@ -87,8 +87,7 @@ class ProfileControllerTest extends ControllerBaseTest
{
$content = $client->getResponse()->getContent();
$this->assertStringContainsString('<h3 class="box-title">About me</h3>', $content);
$this->assertStringContainsString('<td class="text-nowrap pull-right">' . $username . '</td>', $content);
$this->assertStringContainsString('About me', $content);
}
public function getTabTestData()

View File

@@ -153,7 +153,7 @@ class AppExtensionTest extends TestCase
],
'kimai.theme' => [
'active_warning' => 3,
'box_color' => 'green',
'box_color' => 'blue',
'select_type' => 'selectpicker',
'show_about' => true,
'chart' => [

View File

@@ -340,7 +340,7 @@ class ConfigurationTest extends TestCase
],
'theme' => [
'active_warning' => 3,
'box_color' => 'green',
'box_color' => 'blue',
'select_type' => 'selectpicker',
'auto_reload_datatable' => false,
'show_about' => true,

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\User;
use App\Event\PageActionsEvent;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\PageActionsEvent
*/
class PageActionsEventTest extends TestCase
{
public function testDefaultValues()
{
$user = new User();
$user->setAlias('foo');
$sut = new PageActionsEvent($user, [], 'foo');
$this->assertSame($user, $sut->getUser());
$this->assertEquals([], $sut->getActions());
$this->assertEquals(['actions' => []], $sut->getPayload());
$sut = new PageActionsEvent($user, ['hello' => 'world'], 'foo');
$this->assertSame($user, $sut->getUser());
$this->assertEquals([], $sut->getActions());
$this->assertEquals(['hello' => 'world', 'actions' => []], $sut->getPayload());
}
public function testSetActions()
{
$sut = new PageActionsEvent(new User(), ['hello' => 'world'], 'foo');
$sut->setActions(['foo' => ['url' => 'bar']]);
$this->assertEquals(['foo' => ['url' => 'bar']], $sut->getActions());
$this->assertEquals(['hello' => 'world', 'actions' => ['foo' => ['url' => 'bar']]], $sut->getPayload());
}
}

View File

@@ -18,6 +18,14 @@ use PHPUnit\Framework\TestCase;
*/
class ThemeEventTest extends TestCase
{
public function testEmpty()
{
$sut = new ThemeEvent();
$this->assertNull($sut->getUser());
$this->assertNull($sut->getPayload());
$this->assertEquals('', $sut->getContent());
}
public function testDefaultValues()
{
$user = new User();
@@ -25,21 +33,7 @@ class ThemeEventTest extends TestCase
$sut = new ThemeEvent($user);
$this->assertNull($sut->getPayload());
$this->assertEquals('', $sut->getContent());
}
/**
* @group legacy
*/
public function testDeprecation()
{
$user = new User();
$user->setAlias('foo');
$sut = new ThemeEvent($user);
$this->assertEquals($user, $sut->getUser());
$this->assertSame($user, $sut->getUser());
}
public function testGetterAndSetter()

View File

@@ -37,7 +37,7 @@ class RuntimeExtensionsTest extends TestCase
public function testGetFunctions()
{
$expected = ['trigger', 'javascript_translations', 'timesheet_exporter', 'active_timesheets', 'encore_entry_css_source', 'render_widget'];
$expected = ['trigger', 'actions', 'javascript_translations', 'timesheet_exporter', 'active_timesheets', 'encore_entry_css_source', 'render_widget'];
$i = 0;
$sut = new RuntimeExtensions();