UI improvement (#1829)

* open "create new project/activity" form in modal
* re-use action translation for create action title
* do not pre-select first customer in list - fixes #1828
* sort user preferences in sections for better visual grouping
* show comment date only in hover of user avatar
This commit is contained in:
Kevin Papst
2020-07-17 20:32:11 +02:00
committed by GitHub
parent c843dba29a
commit 7907b43ef7
15 changed files with 149 additions and 42 deletions

View File

@@ -261,10 +261,20 @@ class ProfileController extends AbstractController
]); ]);
} }
$sections = [];
/** @var UserPreference $pref */
foreach ($profile->getPreferences() as $pref) {
if ($pref->isEnabled()) {
$sections[$pref->getSection()] = $pref->getSection();
}
}
return $this->render('user/form.html.twig', [ return $this->render('user/form.html.twig', [
'tab' => 'preferences', 'tab' => 'preferences',
'user' => $profile, 'user' => $profile,
'form' => $form->createView(), 'form' => $form->createView(),
'sections' => $sections
]); ]);
} }

View File

@@ -83,11 +83,12 @@ class UserPreference
* @var int * @var int
*/ */
private $order = 1000; private $order = 1000;
/** /**
* @return int * @var string
*/ */
public function getId() private $section = 'default';
public function getId(): ?int
{ {
return $this->id; return $this->id;
} }
@@ -264,4 +265,16 @@ class UserPreference
return $this; return $this;
} }
public function setSection(string $section): UserPreference
{
$this->section = $section;
return $this;
}
public function getSection(): string
{
return $this->section;
}
} }

View File

@@ -106,6 +106,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setName(UserPreference::HOURLY_RATE) ->setName(UserPreference::HOURLY_RATE)
->setValue(0) ->setValue(0)
->setOrder(100) ->setOrder(100)
->setSection('rate')
->setType(MoneyType::class) ->setType(MoneyType::class)
->setEnabled($enableHourlyRate) ->setEnabled($enableHourlyRate)
->setOptions($hourlyRateOptions) ->setOptions($hourlyRateOptions)
@@ -115,6 +116,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setName(UserPreference::INTERNAL_RATE) ->setName(UserPreference::INTERNAL_RATE)
->setValue(null) ->setValue(null)
->setOrder(101) ->setOrder(101)
->setSection('rate')
->setType(MoneyType::class) ->setType(MoneyType::class)
->setEnabled($enableHourlyRate) ->setEnabled($enableHourlyRate)
->setOptions(array_merge($hourlyRateOptions, ['label' => 'label.rate_internal', 'required' => false])) ->setOptions(array_merge($hourlyRateOptions, ['label' => 'label.rate_internal', 'required' => false]))
@@ -124,54 +126,63 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setName(UserPreference::TIMEZONE) ->setName(UserPreference::TIMEZONE)
->setValue($this->getDefaultTimezone()) ->setValue($this->getDefaultTimezone())
->setOrder(200) ->setOrder(200)
->setSection('locale')
->setType(TimezoneType::class), ->setType(TimezoneType::class),
(new UserPreference()) (new UserPreference())
->setName(UserPreference::LOCALE) ->setName(UserPreference::LOCALE)
->setValue($this->getDefaultLanguage()) ->setValue($this->getDefaultLanguage())
->setOrder(300) ->setOrder(300)
->setSection('locale')
->setType(LanguageType::class), ->setType(LanguageType::class),
(new UserPreference()) (new UserPreference())
->setName(UserPreference::SKIN) ->setName(UserPreference::SKIN)
->setValue($this->getDefaultTheme()) ->setValue($this->getDefaultTheme())
->setOrder(400) ->setOrder(400)
->setSection('theme')
->setType(SkinType::class), ->setType(SkinType::class),
(new UserPreference()) (new UserPreference())
->setName('theme.layout') ->setName('theme.layout')
->setValue('fixed') ->setValue('fixed')
->setOrder(450) ->setOrder(450)
->setSection('theme')
->setType(ThemeLayoutType::class), ->setType(ThemeLayoutType::class),
(new UserPreference()) (new UserPreference())
->setName('theme.collapsed_sidebar') ->setName('theme.collapsed_sidebar')
->setValue(false) ->setValue(false)
->setOrder(500) ->setOrder(500)
->setSection('theme')
->setType(CheckboxType::class), ->setType(CheckboxType::class),
(new UserPreference()) (new UserPreference())
->setName('calendar.initial_view') ->setName('calendar.initial_view')
->setValue(CalendarViewType::DEFAULT_VIEW) ->setValue(CalendarViewType::DEFAULT_VIEW)
->setOrder(600) ->setOrder(600)
->setSection('behaviour')
->setType(CalendarViewType::class), ->setType(CalendarViewType::class),
(new UserPreference()) (new UserPreference())
->setName('login.initial_view') ->setName('login.initial_view')
->setValue(InitialViewType::DEFAULT_VIEW) ->setValue(InitialViewType::DEFAULT_VIEW)
->setOrder(700) ->setOrder(700)
->setSection('behaviour')
->setType(InitialViewType::class), ->setType(InitialViewType::class),
(new UserPreference()) (new UserPreference())
->setName('timesheet.daily_stats') ->setName('timesheet.daily_stats')
->setValue(false) ->setValue(false)
->setOrder(800) ->setOrder(800)
->setSection('behaviour')
->setType(CheckboxType::class), ->setType(CheckboxType::class),
(new UserPreference()) (new UserPreference())
->setName('timesheet.export_decimal') ->setName('timesheet.export_decimal')
->setValue(false) ->setValue(false)
->setOrder(900) ->setOrder(900)
->setSection('behaviour')
->setType(CheckboxType::class), ->setType(CheckboxType::class),
]; ];
} }
@@ -195,6 +206,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setEnabled($preference->isEnabled()) ->setEnabled($preference->isEnabled())
->setOptions($preference->getOptions()) ->setOptions($preference->getOptions())
->setOrder($preference->getOrder()) ->setOrder($preference->getOrder())
->setSection($preference->getSection())
; ;
} else { } else {
$user->addPreference($preference); $user->addPreference($preference);

View File

@@ -78,6 +78,7 @@ class ProjectEditForm extends AbstractType
'required' => false, 'required' => false,
])) ]))
->add('customer', CustomerType::class, [ ->add('customer', CustomerType::class, [
'placeholder' => (null === $id && null === $customer) ? '' : false,
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) { 'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer); $query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user')); $query->setUser($builder->getOption('user'));

View File

@@ -32,11 +32,11 @@
{{ activity.comment|comment2html(true) }} {{ activity.comment|comment2html(true) }}
</div> </div>
{% endif %} {% endif %}
<table class="table table-hover"> <table class="table table-hover dataTable">
{% if not activity.visible %} {% if not activity.visible %}
<tr> <tr>
<th>{{ 'label.visible'|trans }}</th> <th>{{ 'label.visible'|trans }}</th>
<td> <td colspan="3">
{{ widgets.label_boolean(activity.visible) }} {{ widgets.label_boolean(activity.visible) }}
</td> </td>
</tr> </tr>
@@ -46,10 +46,11 @@
<th>{{ 'label.customer'|trans }}</th> <th>{{ 'label.customer'|trans }}</th>
<td> <td>
{{ widgets.label_customer(activity.project.customer) }} {{ widgets.label_customer(activity.project.customer) }}
{% if activity.project.customer.teams|length == 0 %} </td>
{{ widgets.icon('unlocked') }} <td class="w-min">
{% endif %} {{ widgets.badge_team_access(activity.project.customer.teams) }}
&nbsp; </td>
<td class="actions">
{{ customerActions.customer(activity.project.customer, 'custom') }} {{ customerActions.customer(activity.project.customer, 'custom') }}
</td> </td>
</tr> </tr>
@@ -57,10 +58,11 @@
<th>{{ 'label.project'|trans }}</th> <th>{{ 'label.project'|trans }}</th>
<td> <td>
{{ widgets.label_project(activity.project) }} {{ widgets.label_project(activity.project) }}
{% if activity.project.teams|length == 0 %} </td>
{{ widgets.icon('unlocked') }} <td class="w-min">
{% endif %} {{ widgets.badge_team_access(activity.project.teams) }}
&nbsp; </td>
<td class="actions">
{{ projectActions.project(activity.project, 'custom') }} {{ projectActions.project(activity.project, 'custom') }}
</td> </td>
</tr> </tr>
@@ -68,7 +70,7 @@
{% for metaField in activity.visibleMetaFields %} {% for metaField in activity.visibleMetaFields %}
<tr> <tr>
<th>{{ metaField.label|trans }}</th> <th>{{ metaField.label|trans }}</th>
<td>{{ widgets.form_type_value(metaField.type, metaField.value, activity) }}</td> <td colspan="3">{{ widgets.form_type_value(metaField.type, metaField.value, activity) }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>

View File

@@ -7,7 +7,7 @@
{% block main %} {% block main %}
{% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %} {% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
{% set formOptions = { {% set formOptions = {
'title': activity.name|default('create'|trans), 'title': activity.name|default('create-activity'|trans({}, 'actions')),
'form': form, 'form': form,
'back': path('admin_activity') 'back': path('admin_activity')
} %} } %}

View File

@@ -48,7 +48,7 @@
{% set actions = actions|merge({'timesheet': path('admin_timesheet', {'customers[]': customer.id})}) %} {% set actions = actions|merge({'timesheet': path('admin_timesheet', {'customers[]': customer.id})}) %}
{% endif %} {% endif %}
{% if customer.visible and is_granted('create_project') %} {% if customer.visible and is_granted('create_project') %}
{% set actions = actions|merge({'create-project': path('admin_project_create_with_customer', {'customer': customer.id})}) %} {% set actions = actions|merge({'create-project': {'url': path('admin_project_create_with_customer', {'customer': customer.id}), 'class': 'modal-ajax-form'}}) %}
{% endif %} {% endif %}
{% if view == 'index' and is_granted('delete', customer) %} {% if view == 'index' and is_granted('delete', customer) %}
{% set actions = actions|merge({'trash': {'url': path('admin_customer_delete', {'id': customer.id}), 'class': 'modal-ajax-form'}}) %} {% set actions = actions|merge({'trash': {'url': path('admin_customer_delete', {'id': customer.id}), 'class': 'modal-ajax-form'}}) %}

View File

@@ -27,7 +27,7 @@
{{ customer.comment|comment2html(true) }} {{ customer.comment|comment2html(true) }}
</div> </div>
{% endif %} {% endif %}
<table class="table table-hover"> <table class="table table-hover dataTable">
{% if not customer.visible %} {% if not customer.visible %}
<tr> <tr>
<th>{{ 'label.visible'|trans }}</th> <th>{{ 'label.visible'|trans }}</th>

View File

@@ -13,7 +13,7 @@
{% endif %} {% endif %}
{% for comment in comments %} {% for comment in comments %}
<div class="box-comment"> <div class="box-comment">
{{ widgets.user_avatar(comment.createdBy, false, 'img-sm') }} {{ widgets.user_avatar(comment.createdBy, comment.createdAt|date_full, 'img-sm') }}
<div class="comment-text"> <div class="comment-text">
<span class="username"> <span class="username">
{{ widgets.username(comment.createdBy) }} {{ widgets.username(comment.createdBy) }}
@@ -26,7 +26,6 @@
{% if route_delete is not null %} {% if route_delete is not null %}
<a href="{{ path(route_delete, {'id': comment.id}) }}" class="confirmation-link btn btn-default btn-xs" data-question="confirm.delete"><i class="{{ 'delete'|icon }}"></i></a> <a href="{{ path(route_delete, {'id': comment.id}) }}" class="confirmation-link btn btn-default btn-xs" data-question="confirm.delete"><i class="{{ 'delete'|icon }}"></i></a>
{% endif %} {% endif %}
<span class="hidden-xs">{{ comment.createdAt|date_full }}</span>
</span> </span>
</span> </span>
{{ comment.message|replace(replacer)|md2html }} {{ comment.message|replace(replacer)|md2html }}

View File

@@ -46,7 +46,7 @@
{% set actions = actions|merge({'timesheet': path('admin_timesheet', {'customers[]': project.customer.id, 'projects[]': project.id})}) %} {% set actions = actions|merge({'timesheet': path('admin_timesheet', {'customers[]': project.customer.id, 'projects[]': project.id})}) %}
{% endif %} {% endif %}
{% if project.visible and project.customer.visible and is_granted('create_activity') %} {% if project.visible and project.customer.visible and is_granted('create_activity') %}
{% set actions = actions|merge({'create-activity': path('admin_activity_create_with_project', {'project': project.id})}) %} {% set actions = actions|merge({'create-activity': {'url': path('admin_activity_create_with_project', {'project': project.id}), 'class': 'modal-ajax-form'}}) %}
{% endif %} {% endif %}
{% if (view == 'index' or view == 'custom') and is_granted('delete', project) %} {% if (view == 'index' or view == 'custom') and is_granted('delete', project) %}
{% set actions = actions|merge({'trash': {'url': path('admin_project_delete', {'id': project.id}), 'class': 'modal-ajax-form'}}) %} {% set actions = actions|merge({'trash': {'url': path('admin_project_delete', {'id': project.id}), 'class': 'modal-ajax-form'}}) %}

View File

@@ -27,11 +27,11 @@
{{ project.comment|comment2html(true) }} {{ project.comment|comment2html(true) }}
</div> </div>
{% endif %} {% endif %}
<table class="table table-hover"> <table class="table table-hover dataTable">
{% if not project.visible %} {% if not project.visible %}
<tr> <tr>
<th>{{ 'label.visible'|trans }}</th> <th>{{ 'label.visible'|trans }}</th>
<td> <td colspan="3">
{{ widgets.label_boolean(project.visible) }} {{ widgets.label_boolean(project.visible) }}
</td> </td>
</tr> </tr>
@@ -40,23 +40,24 @@
<th>{{ 'label.customer'|trans }}</th> <th>{{ 'label.customer'|trans }}</th>
<td> <td>
{{ widgets.label_customer(project.customer) }} {{ widgets.label_customer(project.customer) }}
{% if project.customer.teams|length == 0 %} </td>
{{ widgets.icon('unlocked') }} <td class="w-min">
{% endif %} {{ widgets.badge_team_access(project.customer.teams) }}
&nbsp; </td>
<td class="actions">
{{ customerActions.customer(project.customer, 'custom') }} {{ customerActions.customer(project.customer, 'custom') }}
</td> </td>
</tr> </tr>
{% if is_granted('details', project) %} {% if is_granted('details', project) %}
<tr> <tr>
<th>{{ 'label.orderNumber'|trans }}</th> <th>{{ 'label.orderNumber'|trans }}</th>
<td> <td colspan="3">
{{ project.orderNumber }} {{ project.orderNumber }}
</td> </td>
</tr> </tr>
<tr> <tr>
<th>{{ 'label.orderDate'|trans }}</th> <th>{{ 'label.orderDate'|trans }}</th>
<td> <td colspan="3">
{% if project.orderDate is not empty %} {% if project.orderDate is not empty %}
{{ project.orderDate|date_full }} {{ project.orderDate|date_full }}
{% else %} {% else %}
@@ -66,7 +67,7 @@
</tr> </tr>
<tr> <tr>
<th>{{ 'label.project_start'|trans }}</th> <th>{{ 'label.project_start'|trans }}</th>
<td> <td colspan="3">
{% if project.start is not empty %} {% if project.start is not empty %}
{{ project.start|date_full }} {{ project.start|date_full }}
{% else %} {% else %}
@@ -76,7 +77,7 @@
</tr> </tr>
<tr> <tr>
<th>{{ 'label.project_end'|trans }}</th> <th>{{ 'label.project_end'|trans }}</th>
<td> <td colspan="3">
{% if project.end is not empty %} {% if project.end is not empty %}
{{ project.end|date_full }} {{ project.end|date_full }}
{% else %} {% else %}
@@ -88,7 +89,7 @@
{% for metaField in project.visibleMetaFields %} {% for metaField in project.visibleMetaFields %}
<tr> <tr>
<th>{{ metaField.label|trans }}</th> <th>{{ metaField.label|trans }}</th>
<td>{{ widgets.form_type_value(metaField.type, metaField.value, project) }}</td> <td colspan="3">{{ widgets.form_type_value(metaField.type, metaField.value, project) }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>

View File

@@ -7,7 +7,7 @@
{% block main %} {% block main %}
{% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %} {% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
{% set formOptions = { {% set formOptions = {
'title': project.name|default('create'|trans), 'title': project.name|default('create-project'|trans({}, 'actions')),
'form': form, 'form': form,
'back': path('admin_project') 'back': path('admin_project')
} %} } %}

View File

@@ -1,15 +1,20 @@
{% extends 'user/layout.html.twig' %} {% extends 'user/layout.html.twig' %}
{% block main %} {% block main %}
{{ form_start(form) }}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %} {% for section, counter in sections %}
{% block box_title %}{{ ('profile.' ~ tab)|trans }}{% endblock %} {% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% block box_body %} {% block box_body %}
{{ form_start(form) }} {% for pref in form.children.preferences %}
{{ form_widget(form) }} {% if pref.vars.data.section == section %}
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" /> {{ form_row(pref) }}
{{ form_end(form) }} {% endif %}
{% endblock %} {% endfor %}
{% endembed %} {% endblock %}
{% endembed %}
{% endfor %}
{{ form_widget(form) }}
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
{{ form_end(form) }}
{% endblock %} {% endblock %}

View File

@@ -299,6 +299,7 @@ class ProjectControllerTest extends ControllerBaseTest
$client->submit($form, [ $client->submit($form, [
'project_edit_form' => [ 'project_edit_form' => [
'name' => 'Test 2', 'name' => 'Test 2',
'customer' => 1,
] ]
]); ]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/2/details')); $this->assertIsRedirect($client, $this->createUrl('/admin/project/2/details'));

View File

@@ -0,0 +1,63 @@
<?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\Entity;
use App\Entity\UserPreference;
use App\Form\Type\YesNoType;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
/**
* @covers \App\Entity\UserPreference
*/
class UserPreferenceTest extends TestCase
{
public function testDefaultValues()
{
$sut = new UserPreference();
self::assertTrue($sut->isEnabled());
self::assertEquals('default', $sut->getSection());
self::assertEquals(1000, $sut->getOrder());
self::assertNull($sut->getValue());
self::assertIsArray($sut->getConstraints());
self::assertEmpty($sut->getConstraints());
self::assertNull($sut->getId());
self::assertNull($sut->getLabel());
self::assertNull($sut->getName());
self::assertIsArray($sut->getOptions());
self::assertEmpty($sut->getOptions());
self::assertNull($sut->getType());
self::assertNull($sut->getUser());
}
public function testGetValueChangesReturnTypeOnOtherType()
{
$sut = new UserPreference();
$sut->setValue('1');
self::assertSame('1', $sut->getValue());
$sut->setType(IntegerType::class);
self::assertSame(1, $sut->getValue());
$sut->setType(YesNoType::class);
self::assertSame(true, $sut->getValue());
$sut->setValue('0');
$sut->setType(CheckboxType::class);
self::assertSame(false, $sut->getValue());
}
public function testGetLabelWithLabelOption()
{
$sut = new UserPreference();
$sut->setName('foo');
self::assertEquals('foo', $sut->getLabel());
$sut->setOptions(['label' => 'bar']);
self::assertEquals('bar', $sut->getLabel());
}
}