mirror of
https://github.com/kevinpapst/kimai2.git
synced 2025-03-16 14:03:30 +00:00
Release 2.18 (#4878)
This commit is contained in:
parent
8792a1df09
commit
987b46bf8f
46 changed files with 1768 additions and 814 deletions
.php-cs-fixer.dist.php
assets/js
KimaiLoader.js
composer.lockforms
public/build
src
templates
tests
API
APIControllerBaseTest.phpApiDocControllerTest.phpConfigurationControllerTest.phpTagControllerTest.php
Configuration
Controller
Entity
Form/DataTransformer
phpstan.neontranslations
|
@ -9,6 +9,7 @@ COMMENT;
|
|||
|
||||
$fixer = new PhpCsFixer\Config();
|
||||
$fixer
|
||||
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
|
||||
->setRiskyAllowed(true)
|
||||
->setRules([
|
||||
'encoding' => true,
|
||||
|
|
|
@ -42,6 +42,7 @@ import KimaiNotification from "./plugins/KimaiNotification";
|
|||
import KimaiHotkeys from "./plugins/KimaiHotkeys";
|
||||
import KimaiRemoteModal from "./plugins/KimaiRemoteModal";
|
||||
import KimaiUser from "./plugins/KimaiUser";
|
||||
import KimaiAutocompleteTags from "./forms/KimaiAutocompleteTags";
|
||||
|
||||
export default class KimaiLoader {
|
||||
|
||||
|
@ -70,6 +71,7 @@ export default class KimaiLoader {
|
|||
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepicker="on"]'));
|
||||
kimai.registerPlugin(new KimaiDatePicker('input[data-datepicker="on"]'));
|
||||
kimai.registerPlugin(new KimaiAutocomplete());
|
||||
kimai.registerPlugin(new KimaiAutocompleteTags());
|
||||
kimai.registerPlugin(new KimaiTimesheetForm());
|
||||
kimai.registerPlugin(new KimaiTeamForm());
|
||||
kimai.registerPlugin(new KimaiCopyDataForm());
|
||||
|
|
|
@ -6,13 +6,12 @@
|
|||
*/
|
||||
|
||||
import TomSelect from 'tom-select';
|
||||
import KimaiFormPlugin from "./KimaiFormPlugin";
|
||||
import KimaiFormTomselectPlugin from "./KimaiFormTomselectPlugin";
|
||||
|
||||
/**
|
||||
* Supporting auto-complete fields via API.
|
||||
* Used for timesheet tagging in toolbar and edit dialogs.
|
||||
*/
|
||||
export default class KimaiAutocomplete extends KimaiFormPlugin {
|
||||
export default class KimaiAutocomplete extends KimaiFormTomselectPlugin {
|
||||
|
||||
init()
|
||||
{
|
||||
|
@ -28,11 +27,23 @@ export default class KimaiAutocomplete extends KimaiFormPlugin {
|
|||
return true;
|
||||
}
|
||||
|
||||
activateForm(form)
|
||||
{
|
||||
loadData(apiUrl, query, callback) {
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
|
||||
API.get(apiUrl, {'name': query}, (data) => {
|
||||
let results = [];
|
||||
for (let item of data) {
|
||||
results.push({text: item.name, value: item.name});
|
||||
}
|
||||
callback(results);
|
||||
}, () => {
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
activateForm(form)
|
||||
{
|
||||
[].slice.call(form.querySelectorAll(this.selector)).map((node) => {
|
||||
const apiUrl = node.dataset['autocompleteUrl'];
|
||||
let minChars = 3;
|
||||
|
@ -40,7 +51,7 @@ export default class KimaiAutocomplete extends KimaiFormPlugin {
|
|||
minChars = parseInt(node.dataset['minimumCharacter']);
|
||||
}
|
||||
|
||||
new TomSelect(node, {
|
||||
let options = {
|
||||
// see https://github.com/orchidjs/tom-select/issues/543#issuecomment-1664342257
|
||||
onItemAdd: function(){
|
||||
// remove remaining characters from input after selecting an item
|
||||
|
@ -58,36 +69,21 @@ export default class KimaiAutocomplete extends KimaiFormPlugin {
|
|||
return query.length >= minChars;
|
||||
},
|
||||
load: (query, callback) => {
|
||||
API.get(apiUrl, {'name': query}, (data) => {
|
||||
const results = [].slice.call(data).map((result) => {
|
||||
return {text: result, value: result};
|
||||
});
|
||||
callback(results);
|
||||
}, () => {
|
||||
callback();
|
||||
});
|
||||
this.loadData(apiUrl, query, callback);
|
||||
},
|
||||
render: {
|
||||
// eslint-disable-next-line
|
||||
not_loading: (data, escape) => {
|
||||
// no default content
|
||||
},
|
||||
option_create: (data, escape) => {
|
||||
const name = escape(data.input);
|
||||
if (name.length < 3) {
|
||||
return null;
|
||||
}
|
||||
const tpl = this.translate('select.search.create');
|
||||
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>')
|
||||
return '<div class="create">' + tplReplaced + '</div>';
|
||||
},
|
||||
no_results: (data, escape) => {
|
||||
const tpl = this.translate('select.search.notfound');
|
||||
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>')
|
||||
return '<div class="no-results">' + tplReplaced + '</div>';
|
||||
},
|
||||
};
|
||||
|
||||
let render = {
|
||||
// eslint-disable-next-line
|
||||
not_loading: (data, escape) => {
|
||||
// no default content
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const rendererType = (node.dataset['renderer'] !== undefined) ? node.dataset['renderer'] : 'default';
|
||||
options.render = {...render, ...this.getRenderer(rendererType)};
|
||||
|
||||
new TomSelect(node, options);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
34
assets/js/forms/KimaiAutocompleteTags.js
Normal file
34
assets/js/forms/KimaiAutocompleteTags.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import KimaiAutocomplete from "./KimaiAutocomplete";
|
||||
|
||||
/**
|
||||
* Used for timesheet tagging in toolbar and edit dialogs.
|
||||
*/
|
||||
export default class KimaiAutocompleteTags extends KimaiAutocomplete {
|
||||
|
||||
init()
|
||||
{
|
||||
this.selector = '[data-form-widget="tags"]';
|
||||
}
|
||||
|
||||
loadData(apiUrl, query, callback) {
|
||||
/** @type {KimaiAPI} API */
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
|
||||
API.get(apiUrl, {'name': query}, (data) => {
|
||||
let results = [];
|
||||
for (let item of data) {
|
||||
results.push({text: item.name, value: item.name, color: item['color-safe']});
|
||||
}
|
||||
callback(results);
|
||||
}, () => {
|
||||
callback();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -10,9 +10,9 @@
|
|||
*/
|
||||
|
||||
import TomSelect from 'tom-select';
|
||||
import KimaiFormPlugin from "./KimaiFormPlugin";
|
||||
import KimaiFormTomselectPlugin from "./KimaiFormTomselectPlugin";
|
||||
|
||||
export default class KimaiFormSelect extends KimaiFormPlugin {
|
||||
export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
|
||||
|
||||
constructor(selector, apiSelects)
|
||||
{
|
||||
|
@ -82,30 +82,20 @@ export default class KimaiFormSelect extends KimaiFormPlugin {
|
|||
plugins: plugins,
|
||||
// if there are more than X entries, the other ones are hidden and can only be found
|
||||
// by typing some characters to trigger the internal option search
|
||||
// see App\Form\Type\TagsType::MAX_AMOUNT_SELECT
|
||||
maxOptions: 500,
|
||||
sortField:[{field: '$order'}, {field: '$score'}],
|
||||
};
|
||||
|
||||
let render = {
|
||||
option_create: (data, escape) => {
|
||||
const name = escape(data.input);
|
||||
if (name.length < 3) {
|
||||
return null;
|
||||
}
|
||||
const tpl = this.translate('select.search.create');
|
||||
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>');
|
||||
return '<div class="create">' + tplReplaced + '</div>';
|
||||
},
|
||||
no_results: (data, escape) => {
|
||||
const tpl = this.translate('select.search.notfound');
|
||||
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>');
|
||||
return '<div class="no-results">' + tplReplaced + '</div>';
|
||||
},
|
||||
onOptionAdd: (value) => {
|
||||
node.dispatchEvent(new CustomEvent('create', {detail: {'value': value}}));
|
||||
},
|
||||
};
|
||||
|
||||
const rendererType = (node.dataset['renderer'] !== undefined) ? node.dataset['renderer'] : 'default';
|
||||
options.render = {...render, ...this.getRenderer(rendererType)};
|
||||
|
||||
if (node.dataset['create'] !== undefined) {
|
||||
options = {...options, ...{
|
||||
persist: true,
|
||||
|
@ -124,44 +114,6 @@ export default class KimaiFormSelect extends KimaiFormPlugin {
|
|||
}};
|
||||
}
|
||||
|
||||
if (node.dataset['renderer'] !== undefined && node.dataset['renderer'] === 'color') {
|
||||
options.render = {...render, ...{
|
||||
option: function(data, escape) {
|
||||
let item = '<div class="list-group-item border-0 p-1 ps-2 text-nowrap">';
|
||||
if (data.color !== undefined) {
|
||||
item += '<span style="background-color:' + data.color + '" class="color-choice-item"> </span>';
|
||||
} else {
|
||||
item += '<span class="color-choice-item"> </span>';
|
||||
}
|
||||
item += escape(data.text) + '</div>';
|
||||
return item;
|
||||
},
|
||||
item: function(data, escape) {
|
||||
let item = '<div class="text-nowrap">';
|
||||
if (data.color !== undefined) {
|
||||
item += '<span style="background-color:' + data.color + '" class="color-choice-item"> </span>';
|
||||
} else {
|
||||
item += '<span class="color-choice-item"> </span>';
|
||||
}
|
||||
item += escape(data.text) + '</div>';
|
||||
return item;
|
||||
}
|
||||
}};
|
||||
} else {
|
||||
options.render = {...render, ...{
|
||||
// the empty entry would collapse and only show as a tiny 5px line if there is no content inside
|
||||
option: function(data, escape) {
|
||||
let text = data.text;
|
||||
if (text === null || text.trim() === '') {
|
||||
text = ' ';
|
||||
} else {
|
||||
text = escape(text);
|
||||
}
|
||||
return '<div>' + text + '</div>';
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
const select = new TomSelect(node, options);
|
||||
node.addEventListener('data-reloaded', (event) => {
|
||||
select.clear(true);
|
||||
|
|
80
assets/js/forms/KimaiFormTomselectPlugin.js
Normal file
80
assets/js/forms/KimaiFormTomselectPlugin.js
Normal file
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiFormPlugin: base class for all none ID plugin that handle forms
|
||||
*/
|
||||
|
||||
import KimaiFormPlugin from './KimaiFormPlugin';
|
||||
|
||||
export default class KimaiFormTomselectPlugin extends KimaiFormPlugin {
|
||||
|
||||
/**
|
||||
* @param {string} rendererType
|
||||
* @return array
|
||||
*/
|
||||
getRenderer(rendererType)
|
||||
{
|
||||
// default renderer
|
||||
|
||||
let render = {
|
||||
option_create: (data, escape) => {
|
||||
const name = escape(data.input);
|
||||
if (name.length < 3) {
|
||||
return null;
|
||||
}
|
||||
const tpl = this.translate('select.search.create');
|
||||
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>')
|
||||
return '<div class="create">' + tplReplaced + '</div>';
|
||||
},
|
||||
no_results: (data, escape) => {
|
||||
const tpl = this.translate('select.search.notfound');
|
||||
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>')
|
||||
return '<div class="no-results">' + tplReplaced + '</div>';
|
||||
},
|
||||
};
|
||||
|
||||
if (rendererType === 'color') {
|
||||
render = {...render, ...{
|
||||
option: function(data, escape) {
|
||||
let item = '<div class="list-group-item border-0 p-1 ps-2 text-nowrap">';
|
||||
// if no color is set, do NOT add an empty placeholder
|
||||
if (data.color !== undefined) {
|
||||
item += '<span style="background-color:' + data.color + '" class="color-choice-item"> </span>';
|
||||
}
|
||||
item += escape(data.text) + '</div>';
|
||||
return item;
|
||||
},
|
||||
item: function(data, escape) {
|
||||
let item = '<div class="text-nowrap">';
|
||||
// if no color is set, do NOT add an empty placeholder
|
||||
if (data.color !== undefined) {
|
||||
item += '<span style="background-color:' + data.color + '" class="color-choice-item"> </span>';
|
||||
}
|
||||
item += escape(data.text) + '</div>';
|
||||
return item;
|
||||
}
|
||||
}};
|
||||
} else {
|
||||
render = {...render, ...{
|
||||
// the empty entry would collapse and only show as a tiny 5px line if there is no content inside
|
||||
option: function(data, escape) {
|
||||
let text = data.text;
|
||||
if (text === null || text.trim() === '') {
|
||||
text = ' ';
|
||||
} else {
|
||||
text = escape(text);
|
||||
}
|
||||
return '<div>' + text + '</div>';
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
return render;
|
||||
}
|
||||
|
||||
}
|
1847
composer.lock
generated
1847
composer.lock
generated
File diff suppressed because it is too large
Load diff
2
public/build/app.41121fe4.js
Normal file
2
public/build/app.41121fe4.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -3,7 +3,7 @@
|
|||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.74179306.js",
|
||||
"/build/app.cde83469.js"
|
||||
"/build/app.41121fe4.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.ee4f7e19.css"
|
||||
|
@ -72,7 +72,7 @@
|
|||
},
|
||||
"integrity": {
|
||||
"/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG",
|
||||
"/build/app.cde83469.js": "sha384-829jYMX4FgmjcB0IV2HWYYOEviCyDD55lOX4fXXWUmUgDuDXXFZMyPFEEcK93dWR",
|
||||
"/build/app.41121fe4.js": "sha384-4v3lVToJIcpMJ++4WaIZ5r9rXR5Vb4waSoHcEzx3vOfcLL+1+TN70di7XPzYSW7I",
|
||||
"/build/app.ee4f7e19.css": "sha384-7E3ujVsOCkGatHu6khaWWqYhPkc85k22zuXZOwOVo1MlWcPSEBhS65Y0qbeWXdAp",
|
||||
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
|
||||
"/build/app-rtl.db588f2b.css": "sha384-OwYS82WpkkoVqI38n3gGpvMTiTFT+dUscgVglMw2vcDvIxAe2B3IcXMjHoCGm/gl",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"build/app.css": "/build/app.ee4f7e19.css",
|
||||
"build/app.js": "/build/app.cde83469.js",
|
||||
"build/app.js": "/build/app.41121fe4.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.db588f2b.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.97153087.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
|
|
|
@ -26,6 +26,22 @@ final class ApiRequestMatcher implements RequestMatcherInterface
|
|||
return false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// the next two checks are primarily here to make sure to return proper error messages
|
||||
|
||||
// let's use this firewall if a Bearer token is set in the header
|
||||
// other cases like "bearer" are rejected earlier
|
||||
if (($auth = $request->headers->get('Authorization')) !== null && str_starts_with($auth, 'Bearer ')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// let's use this firewall if the deprecated username & token combination is available
|
||||
if ($request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
|
||||
$request->headers->has(TokenAuthenticator::HEADER_TOKEN)) {
|
||||
return true;
|
||||
}
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
// checking for a previous session allows us to skip the API firewall and token access handler
|
||||
// we simply re-use the existing session when doing API calls from the frontend.
|
||||
// it is not necessary to check headers. if there is no valid session, we should always use this firewall
|
||||
|
|
|
@ -31,7 +31,7 @@ final class ConfigurationController extends BaseApiController
|
|||
* Returns the timesheet configuration
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns the instance specific timesheet configuration', content: new OA\JsonContent(ref: new Model(type: TimesheetConfig::class)))]
|
||||
#[Route(methods: ['GET'], path: '/config/timesheet')]
|
||||
#[Route(path: '/config/timesheet', methods: ['GET'])]
|
||||
public function timesheetConfigAction(SystemConfiguration $configuration): Response
|
||||
{
|
||||
$model = new TimesheetConfig();
|
||||
|
@ -46,4 +46,17 @@ final class ConfigurationController extends BaseApiController
|
|||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured color codes and names
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns the configured color codes and names', content: new OA\JsonContent(type: 'object', example: ['Red' => '#ff0000'], additionalProperties: new OA\AdditionalProperties(type: 'string')))]
|
||||
#[Route(path: '/config/colors', methods: ['GET'])]
|
||||
public function colorConfigAction(SystemConfiguration $configuration): Response
|
||||
{
|
||||
$view = new View($configuration->getThemeColors(), 200);
|
||||
$view->getContext()->setGroups(['Default']);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ final class TagController extends BaseApiController
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch all existing tags
|
||||
* Deprecated: Fetch tags by filter as string collection
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns the collection of all existing tags as string array', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'string')))]
|
||||
#[Route(methods: ['GET'], name: 'get_tags')]
|
||||
|
@ -56,6 +56,27 @@ final class TagController extends BaseApiController
|
|||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tags by filter as entities
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Find the collection of all matching tags', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TagEntity')))]
|
||||
#[Route(path: '/find', name: 'get_tags_full', methods: ['GET'])]
|
||||
#[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')]
|
||||
public function findTags(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$filter = $paramFetcher->get('name');
|
||||
|
||||
$data = [];
|
||||
if (\is_string($filter)) {
|
||||
$data = $this->repository->findAllTags($filter);
|
||||
}
|
||||
|
||||
$view = new View($data, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new tag
|
||||
*/
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
namespace App\API;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\AccessToken;
|
||||
use App\Entity\User;
|
||||
use App\Event\PrepareUserEvent;
|
||||
|
@ -18,6 +17,7 @@ use App\Form\API\UserApiEditForm;
|
|||
use App\Repository\AccessTokenRepository;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\User\UserService;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
|
@ -27,8 +27,6 @@ use OpenApi\Attributes as OA;
|
|||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
|
@ -45,8 +43,6 @@ final class UserController extends BaseApiController
|
|||
public function __construct(
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly UserRepository $repository,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly SystemConfiguration $configuration
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -139,13 +135,9 @@ final class UserController extends BaseApiController
|
|||
#[OA\Post(description: 'Creates a new user and returns it afterwards')]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserCreateForm'))]
|
||||
#[Route(methods: ['POST'], path: '', name: 'post_user')]
|
||||
public function postAction(Request $request): Response
|
||||
public function postAction(Request $request, UserService $userService): Response
|
||||
{
|
||||
$user = new User();
|
||||
$user->setEnabled(true);
|
||||
$user->setRoles([User::DEFAULT_ROLE]);
|
||||
$user->setTimezone($this->configuration->getUserDefaultTimezone());
|
||||
$user->setLanguage($this->configuration->getUserDefaultLanguage());
|
||||
$user = $userService->createNewUser();
|
||||
|
||||
$form = $this->createForm(UserApiCreateForm::class, $user, [
|
||||
'include_roles' => $this->isGranted('roles', $user),
|
||||
|
@ -156,24 +148,11 @@ final class UserController extends BaseApiController
|
|||
$form->submit($request->request->all());
|
||||
|
||||
if ($form->isValid()) {
|
||||
$plainPassword = $user->getPlainPassword();
|
||||
if ($plainPassword === null) {
|
||||
throw new BadRequestHttpException('Password cannot be empty');
|
||||
}
|
||||
$password = $this->passwordHasher->hashPassword($user, $plainPassword);
|
||||
$user->setPassword($password);
|
||||
|
||||
if ($user->getPlainApiToken() !== null) {
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, $user->getPlainApiToken()));
|
||||
}
|
||||
|
||||
$this->repository->saveUser($user);
|
||||
$user = $userService->saveNewUser($user);
|
||||
|
||||
$view = new View($user, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
$user->eraseCredentials();
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
|
|
|
@ -21,10 +21,10 @@ use Symfony\Component\HttpFoundation\Request;
|
|||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
|
||||
|
||||
#[AsCommand(name: 'kimai:user:login-link', description: 'Create a URL that can be used to login as that user', hidden: true)]
|
||||
/**
|
||||
* @CloudRequired
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:user:login-link', description: 'Create a URL that can be used to login as that user', hidden: true)]
|
||||
final class UserLoginLinkCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
|
||||
namespace App\Configuration;
|
||||
|
||||
use App\Constants;
|
||||
|
||||
final class SystemConfiguration
|
||||
{
|
||||
private bool $initialized = false;
|
||||
|
@ -479,6 +481,9 @@ final class SystemConfiguration
|
|||
return (bool) $this->find('theme.avatar_url');
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal will be made private soon after 2.18.0 - do ot access this method directly, but through getThemeColors()
|
||||
*/
|
||||
public function getThemeColorChoices(): string
|
||||
{
|
||||
$config = $this->find('theme.color_choices');
|
||||
|
@ -489,6 +494,40 @@ final class SystemConfiguration
|
|||
return 'Silver|#c0c0c0';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getThemeColors(): array
|
||||
{
|
||||
$config = explode(',', $this->getThemeColorChoices());
|
||||
|
||||
$colors = [];
|
||||
foreach ($config as $item) {
|
||||
if (empty($item)) {
|
||||
continue;
|
||||
}
|
||||
$item = explode('|', $item);
|
||||
$key = $item[0];
|
||||
$value = $key;
|
||||
|
||||
if (\count($item) > 1) {
|
||||
$value = $item[1];
|
||||
}
|
||||
|
||||
if (empty($key)) {
|
||||
$key = $value;
|
||||
}
|
||||
|
||||
if ($value === Constants::DEFAULT_COLOR) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$colors[$key] = $value;
|
||||
}
|
||||
|
||||
return array_unique($colors);
|
||||
}
|
||||
|
||||
// ========== Projects ==========
|
||||
|
||||
public function isProjectCopyTeamsOnCreate(): bool
|
||||
|
|
|
@ -17,11 +17,11 @@ class Constants
|
|||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.17.0';
|
||||
public const VERSION = '2.18.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 21700;
|
||||
public const VERSION_ID = 21800;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
|
|
@ -456,19 +456,22 @@ final class ActivityController extends AbstractController
|
|||
}
|
||||
|
||||
/**
|
||||
* @param Activity $activity
|
||||
* @return FormInterface<ActivityEditForm>
|
||||
*/
|
||||
private function createEditForm(Activity $activity): FormInterface
|
||||
{
|
||||
$currency = $this->configuration->getCustomerDefaultCurrency();
|
||||
$url = $this->generateUrl('admin_activity_create');
|
||||
if ($activity->getProject()?->getId() !== null) {
|
||||
$url = $this->generateUrl('admin_activity_create_with_project', ['project' => $activity->getProject()->getId()]);
|
||||
}
|
||||
|
||||
if ($activity->getId() !== null) {
|
||||
$url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]);
|
||||
if (null !== $activity->getProject()) {
|
||||
$currency = $activity->getProject()->getCustomer()->getCurrency();
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $activity->getProject()) {
|
||||
$currency = $activity->getProject()->getCustomer()->getCurrency();
|
||||
}
|
||||
|
||||
return $this->createForm(ActivityEditForm::class, $activity, [
|
||||
|
|
|
@ -47,7 +47,7 @@ final class SamlController extends AbstractController
|
|||
$session->remove($authErrorKey);
|
||||
}
|
||||
|
||||
if ($error) {
|
||||
if ($error !== null) {
|
||||
if (\is_object($error) && method_exists($error, 'getMessage')) {
|
||||
$error = $error->getMessage();
|
||||
}
|
||||
|
@ -60,13 +60,12 @@ final class SamlController extends AbstractController
|
|||
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
}
|
||||
|
||||
$url = $this->authFactory->create()->login($redirectTarget);
|
||||
$url = $this->authFactory->create()->login($redirectTarget, [], false, false, true);
|
||||
|
||||
if ($url === null) {
|
||||
throw new \RuntimeException('SAML login failed');
|
||||
}
|
||||
|
||||
// this line is not (yet) reached, as the previous call will exit
|
||||
return $this->redirect($url);
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Utils\Color;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
@ -22,6 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('name')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Serializer\VirtualProperty('ColorSafe', exp: 'object.getColorSafe()', options: [new Serializer\SerializedName('color-safe'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Default'])])]
|
||||
class Tag
|
||||
{
|
||||
/**
|
||||
|
@ -95,4 +97,9 @@ class Tag
|
|||
{
|
||||
return $this->getName();
|
||||
}
|
||||
|
||||
public function getColorSafe(): string
|
||||
{
|
||||
return $this->getColor() ?? (new Color())->getRandom($this->getName());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -298,6 +298,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.15
|
||||
*/
|
||||
#[Serializer\VirtualProperty]
|
||||
#[Serializer\SerializedName('apiToken')]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
|
|
|
@ -37,7 +37,22 @@ final class TagArrayToStringTransformer implements DataTransformerInterface
|
|||
return '';
|
||||
}
|
||||
|
||||
return implode(', ', $value);
|
||||
if (!\is_array($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($value as $item) {
|
||||
if ($item instanceof Tag) {
|
||||
$result[] = $item->getName();
|
||||
} elseif (\is_string($item)) {
|
||||
$result[] = $item;
|
||||
} else {
|
||||
throw new TransformationFailedException('Tags must only contain a Tag or a string.');
|
||||
}
|
||||
}
|
||||
|
||||
return implode(',', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -55,6 +70,7 @@ final class TagArrayToStringTransformer implements DataTransformerInterface
|
|||
if ('' === $value || null === $value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!\is_array($value)) {
|
||||
$names = array_filter(array_unique(array_map('trim', explode(',', $value))));
|
||||
} else {
|
||||
|
@ -68,18 +84,12 @@ final class TagArrayToStringTransformer implements DataTransformerInterface
|
|||
}
|
||||
|
||||
$tagName = trim($tagName);
|
||||
$tag = null;
|
||||
|
||||
if (is_numeric($tagName)) {
|
||||
$tag = $this->tagRepository->find($tagName);
|
||||
}
|
||||
|
||||
if ($tag === null) {
|
||||
$tag = $this->tagRepository->findTagByName($tagName);
|
||||
}
|
||||
// do not check for numeric values as ID, this form type only submits tag names
|
||||
$tag = $this->tagRepository->findTagByName($tagName);
|
||||
|
||||
// get the current tags and find the new ones that should be created
|
||||
if ($this->create && $tag === null) {
|
||||
if ($tag === null && $this->create) {
|
||||
$tag = new Tag();
|
||||
$tag->setName(mb_substr($tagName, 0, 100));
|
||||
$this->tagRepository->saveTag($tag);
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
namespace App\Form\Type;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Constants;
|
||||
use App\Utils\Color;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\DataTransformerInterface;
|
||||
|
@ -22,7 +21,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||
|
||||
final class ColorChoiceType extends AbstractType implements DataTransformerInterface
|
||||
{
|
||||
public function __construct(private SystemConfiguration $systemConfiguration)
|
||||
public function __construct(private readonly SystemConfiguration $systemConfiguration)
|
||||
{
|
||||
}
|
||||
|
||||
|
@ -36,7 +35,7 @@ final class ColorChoiceType extends AbstractType implements DataTransformerInter
|
|||
$options = [
|
||||
'documentation' => [
|
||||
'type' => 'string',
|
||||
'description' => sprintf('The hexadecimal color code (default: %s)', Constants::DEFAULT_COLOR),
|
||||
'description' => 'The hexadecimal color code (default: auto-calculated by name)',
|
||||
],
|
||||
'label' => 'color',
|
||||
'empty_data' => null,
|
||||
|
@ -50,7 +49,7 @@ final class ColorChoiceType extends AbstractType implements DataTransformerInter
|
|||
];
|
||||
|
||||
$choices = [];
|
||||
$colors = $this->convertStringToColorArray($this->systemConfiguration->getThemeColorChoices());
|
||||
$colors = $this->systemConfiguration->getThemeColors();
|
||||
|
||||
foreach ($colors as $name => $color) {
|
||||
$choices[$name] = $color;
|
||||
|
@ -69,44 +68,9 @@ final class ColorChoiceType extends AbstractType implements DataTransformerInter
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $config
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function convertStringToColorArray(string $config): array
|
||||
public function transform(mixed $value): mixed
|
||||
{
|
||||
$config = explode(',', $config);
|
||||
|
||||
$colors = [];
|
||||
foreach ($config as $item) {
|
||||
if (empty($item)) {
|
||||
continue;
|
||||
}
|
||||
$item = explode('|', $item);
|
||||
$key = $item[0];
|
||||
$value = $key;
|
||||
|
||||
if (\count($item) > 1) {
|
||||
$value = $item[1];
|
||||
}
|
||||
|
||||
if (empty($key)) {
|
||||
$key = $value;
|
||||
}
|
||||
|
||||
if ($value === Constants::DEFAULT_COLOR) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$colors[$key] = $value;
|
||||
}
|
||||
|
||||
return array_unique($colors);
|
||||
}
|
||||
|
||||
public function transform(mixed $data): mixed
|
||||
{
|
||||
return $data;
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function reverseTransform(mixed $value): mixed
|
||||
|
|
|
@ -54,11 +54,12 @@ final class TagsInputType extends AbstractType
|
|||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
$view->vars['attr'] = array_merge($view->vars['attr'], [
|
||||
'data-autocomplete-url' => $this->router->generate('get_tags'),
|
||||
'data-autocomplete-url' => $this->router->generate('get_tags_full'),
|
||||
'data-minimum-character' => 3,
|
||||
'class' => 'form-select',
|
||||
'autocomplete' => 'off',
|
||||
'data-form-widget' => 'autocomplete'
|
||||
'data-form-widget' => 'tags',
|
||||
'data-renderer' => 'color',
|
||||
]);
|
||||
|
||||
if ($options['allow_create']) {
|
||||
|
|
|
@ -12,7 +12,6 @@ namespace App\Form\Type;
|
|||
use App\Entity\Tag;
|
||||
use App\Repository\Query\TagFormTypeQuery;
|
||||
use App\Repository\TagRepository;
|
||||
use App\Utils\Color;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
@ -87,17 +86,11 @@ final class TagsSelectType extends AbstractType
|
|||
return $tag->getId();
|
||||
},
|
||||
'choice_attr' => function (Tag $tag) {
|
||||
$color = $tag->getColor();
|
||||
if ($color === null) {
|
||||
$color = (new Color())->getRandom($tag->getName());
|
||||
}
|
||||
|
||||
return ['data-color' => $color];
|
||||
return ['data-color' => $tag->getColorSafe()];
|
||||
},
|
||||
'choice_label' => function (Tag $tag) {
|
||||
return $tag->getName();
|
||||
},
|
||||
'attr' => ['data-renderer' => 'color'],
|
||||
]);
|
||||
|
||||
$resolver->setDefault('query_builder', function (Options $options) {
|
||||
|
@ -119,6 +112,10 @@ final class TagsSelectType extends AbstractType
|
|||
'data-create' => 'post_tag',
|
||||
]);
|
||||
}
|
||||
|
||||
$view->vars['attr'] = array_merge($view->vars['attr'], [
|
||||
'data-renderer' => 'color',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
|
|
|
@ -16,6 +16,11 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
|||
|
||||
final class TagsType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* See KimaiFormSelect.js (maxOptions) as well.
|
||||
*/
|
||||
public const MAX_AMOUNT_SELECT = 500;
|
||||
|
||||
private ?int $count = null;
|
||||
|
||||
public function __construct(
|
||||
|
@ -37,7 +42,7 @@ final class TagsType extends AbstractType
|
|||
$this->count = $this->repository->count([]);
|
||||
}
|
||||
|
||||
if ($this->count > TagRepository::MAX_AMOUNT_SELECT) {
|
||||
if ($this->count > self::MAX_AMOUNT_SELECT) {
|
||||
return TagsInputType::class;
|
||||
}
|
||||
|
||||
|
|
|
@ -19,15 +19,10 @@ use Doctrine\ORM\EntityRepository;
|
|||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* @extends \Doctrine\ORM\EntityRepository<Tag>
|
||||
* @extends EntityRepository<Tag>
|
||||
*/
|
||||
class TagRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* See KimaiFormSelect.js (maxOptions) as well.
|
||||
*/
|
||||
public const MAX_AMOUNT_SELECT = 500;
|
||||
|
||||
public function saveTag(Tag $tag): void
|
||||
{
|
||||
$entityManager = $this->getEntityManager();
|
||||
|
@ -64,18 +59,11 @@ class TagRepository extends EntityRepository
|
|||
return $this->findOneBy(['name' => $tagName, 'visible' => $visible]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all visible tag names in alphabetical order.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function findAllTagNames(?string $filter = null): array
|
||||
private function findAllTagsQuery(?string $filter = null): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('t');
|
||||
|
||||
$qb
|
||||
->select('t.name')
|
||||
->addOrderBy('t.name', 'ASC');
|
||||
$qb->addOrderBy('t.name', 'ASC');
|
||||
|
||||
$qb->andWhere($qb->expr()->eq('t.visible', ':visible'));
|
||||
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
|
||||
|
@ -85,7 +73,27 @@ class TagRepository extends EntityRepository
|
|||
$qb->setParameter('filter', '%' . $filter . '%');
|
||||
}
|
||||
|
||||
return array_column($qb->getQuery()->getScalarResult(), 'name');
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all visible tag names in alphabetical order.
|
||||
*
|
||||
* @return array<Tag>
|
||||
*/
|
||||
public function findAllTags(?string $filter = null): array
|
||||
{
|
||||
return $this->findAllTagsQuery($filter)->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all visible tag names in alphabetical order.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function findAllTagNames(?string $filter = null): array
|
||||
{
|
||||
return array_column($this->findAllTagsQuery($filter)->select('t.name')->getQuery()->getScalarResult(), 'name');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -48,6 +48,10 @@ final class ApiVoter extends Voter
|
|||
return false;
|
||||
}
|
||||
|
||||
return $this->permissionManager->hasRolePermission($user, 'api_access');
|
||||
if ($token->hasAttribute('api-token')) {
|
||||
return $this->permissionManager->hasRolePermission($user, 'api_access');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,15 +6,21 @@
|
|||
{% embed '@theme/embeds/card.html.twig' %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_attributes %}id="quick_entry_box"{% endblock %}
|
||||
{% block box_class %}mt-2{% endblock %}
|
||||
{% block box_before %}
|
||||
{{ form_start(form, {attr: {id: 'quick-entries-form', class: 'form-dataTable quick-entries'}}) }}
|
||||
{{ form_widget(form._token) }}
|
||||
{% endblock %}
|
||||
{% block box_after %}{{ form_end(form) }}{% endblock %}
|
||||
{% block box_title %}
|
||||
{{ form_widget(form.date) }}
|
||||
{{ form_errors(form) }}
|
||||
{% endblock %}
|
||||
{% block box_after %}
|
||||
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
|
||||
<button type="button" class="btn btn-success add-item-link" data-collection-prototype="{{ form.rows.vars.id }}" data-collection-holder="ts-collection">
|
||||
{{ icon('create', true) }}
|
||||
{{ 'action.add'|trans }}
|
||||
</button>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
{# "table-responsive" does not work, because that would render dropdowns at the bottom behind the container #}
|
||||
{% block box_body_class %}p-0 maybe-table-responsive{% endblock %}
|
||||
{% block box_body %}
|
||||
|
@ -46,13 +52,6 @@
|
|||
</tfoot>
|
||||
</table>
|
||||
{% endblock %}
|
||||
{% block box_footer %}
|
||||
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
|
||||
<button type="button" class="btn btn-success add-item-link" data-collection-prototype="{{ form.rows.vars.id }}" data-collection-holder="ts-collection">
|
||||
{{ icon('create', true) }}
|
||||
{{ 'action.add'|trans }}
|
||||
</button>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -31,35 +31,35 @@
|
|||
{% endblock %}
|
||||
{% block box_footer %}
|
||||
<div class="row">
|
||||
<div class="col-sm-3 col-xs-6">
|
||||
<div class="col-sm-3 col-xs-6 mt-1 mb-2 mb-md-0">
|
||||
<div class="text-center">
|
||||
<h5>{{ data.day|duration }}</h5>
|
||||
<h5 class="mb-1">{{ data.day|duration }}</h5>
|
||||
<span>{{ 'stats.workingTimeToday'|trans({'%day%': 'now'|date_short}) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3 col-xs-6">
|
||||
<div class="col-sm-3 col-xs-6 mt-1 mb-2 mb-md-0">
|
||||
<div class="text-center">
|
||||
<h5>{{ data.week|duration }}</h5>
|
||||
<h5 class="mb-1">{{ data.week|duration }}</h5>
|
||||
<span>{{ 'stats.workingTimeWeek'|trans({'%week%': data.begin|date_format('W')}) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3 col-xs-6">
|
||||
<div class="col-sm-3 col-xs-6 mt-1 mb-2 mb-md-0">
|
||||
<div class="text-center">
|
||||
<h5>{{ data.month|duration }}</h5>
|
||||
<h5 class="mb-1">{{ data.month|duration }}</h5>
|
||||
<span>{{ 'stats.workingTimeMonth'|trans({'%month%': data.thisMonth|month_name, '%year%': data.thisMonth|date_format('Y')}) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if data.financial is not null %}
|
||||
<div class="col-sm-3 col-xs-6">
|
||||
<div class="col-sm-3 col-xs-6 mt-1 mb-2 mb-md-0">
|
||||
<div class="text-center">
|
||||
<h5>{{ data.financial|duration }}</h5>
|
||||
<h5 class="mb-1">{{ data.financial|duration }}</h5>
|
||||
<span>{{ 'stats.workingTimeFinancialYear'|trans }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-sm-3 col-xs-6">
|
||||
<div class="col-sm-3 col-xs-6 mt-1 mb-2 mb-md-0">
|
||||
<div class="text-center">
|
||||
<h5>{{ data.year|duration }}</h5>
|
||||
<h5 class="mb-1">{{ data.year|duration }}</h5>
|
||||
<span>{{ 'stats.workingTimeYear'|trans({'%year%': data.thisMonth|date_format('Y')}) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -287,6 +287,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'color' => '@string',
|
||||
'color-safe' => 'string',
|
||||
'visible' => 'bool',
|
||||
];
|
||||
|
||||
|
|
|
@ -64,6 +64,7 @@ class ApiDocControllerTest extends ControllerBaseTest
|
|||
'/api/activities/{id}/rates',
|
||||
'/api/activities/{id}/rates/{rateId}',
|
||||
'/api/config/timesheet',
|
||||
'/api/config/colors',
|
||||
'/api/customers',
|
||||
'/api/customers/{id}',
|
||||
'/api/customers/{id}/meta',
|
||||
|
@ -78,6 +79,7 @@ class ApiDocControllerTest extends ControllerBaseTest
|
|||
'/api/version',
|
||||
'/api/plugins',
|
||||
'/api/tags',
|
||||
'/api/tags/find',
|
||||
'/api/tags/{id}',
|
||||
'/api/teams',
|
||||
'/api/teams/{id}',
|
||||
|
|
|
@ -25,7 +25,9 @@ class ConfigurationControllerTest extends APIControllerBaseTest
|
|||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->assertAccessIsGranted($client, '/api/config/timesheet', 'GET');
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
$content = $client->getResponse()->getContent();
|
||||
$this->assertIsString($content);
|
||||
$result = json_decode($content, true);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertNotEmpty($result);
|
||||
|
@ -37,4 +39,53 @@ class ConfigurationControllerTest extends APIControllerBaseTest
|
|||
|
||||
$this->assertEquals($expectedKeys, $actual, 'Config structure does not match');
|
||||
}
|
||||
|
||||
public function testIsColorsSecure(): void
|
||||
{
|
||||
$this->assertUrlIsSecured('/api/config/colors');
|
||||
}
|
||||
|
||||
public function testGetColors(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->assertAccessIsGranted($client, '/api/config/colors', 'GET');
|
||||
$content = $client->getResponse()->getContent();
|
||||
$this->assertIsString($content);
|
||||
$actual = json_decode($content, true);
|
||||
|
||||
$this->assertIsArray($actual);
|
||||
$this->assertNotEmpty($actual);
|
||||
$expected = [
|
||||
'Silver' => '#c0c0c0',
|
||||
'Gray' => '#808080',
|
||||
'Black' => '#000000',
|
||||
'Maroon' => '#800000',
|
||||
'Brown' => '#a52a2a',
|
||||
'Red' => '#ff0000',
|
||||
'Orange' => '#ffa500',
|
||||
'Gold' => '#ffd700',
|
||||
'Yellow' => '#ffff00',
|
||||
'Peach' => '#ffdab9',
|
||||
'Khaki' => '#f0e68c',
|
||||
'Olive' => '#808000',
|
||||
'Lime' => '#00ff00',
|
||||
'Jelly' => '#9acd32',
|
||||
'Green' => '#008000',
|
||||
'Teal' => '#008080',
|
||||
'Aqua' => '#00ffff',
|
||||
'LightBlue' => '#add8e6',
|
||||
'DeepSky' => '#00bfff',
|
||||
'Dodger' => '#1e90ff',
|
||||
'Blue' => '#0000ff',
|
||||
'Navy' => '#000080',
|
||||
'Purple' => '#800080',
|
||||
'Fuchsia' => '#ff00ff',
|
||||
'Violet' => '#ee82ee',
|
||||
'Rose' => '#ffe4e1',
|
||||
'Lavender' => '#E6E6FA',
|
||||
];
|
||||
$this->assertCount(\count($expected), $actual);
|
||||
|
||||
$this->assertEquals($expected, $actual, 'Color structure does not match');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,6 +54,21 @@ class TagControllerTest extends APIControllerBaseTest
|
|||
$this->assertEquals('Test', $result[9]);
|
||||
}
|
||||
|
||||
public function testFindCollection(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->importTagFixtures();
|
||||
$this->assertAccessIsGranted($client, '/api/tags/find', 'GET', ['name' => '2018']);
|
||||
$content = $client->getResponse()->getContent();
|
||||
$this->assertNotFalse($content);
|
||||
$result = json_decode($content, true);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertNotEmpty($result);
|
||||
$this->assertEquals(3, \count($result));
|
||||
self::assertApiResponseTypeStructure('TagEntity', $result[0]);
|
||||
}
|
||||
|
||||
public function testEmptyCollection(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
|
|
@ -137,6 +137,7 @@ class SystemConfigurationTest extends TestCase
|
|||
$this->assertFalse($sut->find('timesheet.rules.allow_future_times'));
|
||||
$this->assertEquals(99, $sut->find('timesheet.active_entries.hard_limit'));
|
||||
$this->assertEquals('Maroon|#800000,Brown|#a52a2a,Red|#ff0000,Orange|#ffa500,#ffffff,,|#000000', $sut->getThemeColorChoices());
|
||||
$this->assertEquals(['Maroon' => '#800000', 'Brown' => '#a52a2a', 'Red' => '#ff0000', 'Orange' => '#ffa500', '#ffffff' => '#ffffff', '#000000' => '#000000'], $sut->getThemeColors());
|
||||
}
|
||||
|
||||
public function testDefaultWithLoader(): void
|
||||
|
@ -160,6 +161,7 @@ class SystemConfigurationTest extends TestCase
|
|||
$this->assertFalse($sut->find('timesheet.rules.allow_future_times'));
|
||||
$this->assertTrue($sut->isSamlActive());
|
||||
$this->assertEquals('Silver|#c0c0c0', $sut->getThemeColorChoices());
|
||||
$this->assertEquals(['Silver' => '#c0c0c0'], $sut->getThemeColors());
|
||||
$this->assertEquals('2020-03-27', $sut->getFinancialYearStart());
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ use App\Entity\Tag;
|
|||
use App\Entity\Timesheet;
|
||||
use App\Entity\TimesheetMeta;
|
||||
use App\Entity\User;
|
||||
use App\Repository\TagRepository;
|
||||
use App\Form\Type\TagsType;
|
||||
use App\Tests\DataFixtures\ActivityFixtures;
|
||||
use App\Tests\DataFixtures\TagFixtures;
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
|
@ -51,7 +51,7 @@ class TimesheetControllerTest extends ControllerBaseTest
|
|||
$start = new \DateTime('first day of this month');
|
||||
|
||||
$fixture = new TagFixtures();
|
||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
||||
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||
$fixture->addTagNameToCreate('bar');
|
||||
$this->importFixture($fixture);
|
||||
|
||||
|
@ -531,7 +531,7 @@ class TimesheetControllerTest extends ControllerBaseTest
|
|||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
$fixture = new TagFixtures();
|
||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
||||
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||
$fixture->addTagNameToCreate('two');
|
||||
$this->importFixture($fixture);
|
||||
|
||||
|
@ -636,7 +636,7 @@ class TimesheetControllerTest extends ControllerBaseTest
|
|||
$id = $timesheets[0]->getId();
|
||||
|
||||
$fixture = new TagFixtures();
|
||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
||||
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||
$this->importFixture($fixture);
|
||||
|
||||
$this->request($client, '/timesheet/' . $id . '/edit');
|
||||
|
@ -717,7 +717,7 @@ class TimesheetControllerTest extends ControllerBaseTest
|
|||
$this->importFixture($fixture);
|
||||
|
||||
$fixture = new TagFixtures();
|
||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
||||
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||
$this->importFixture($fixture);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/timesheet/');
|
||||
|
|
|
@ -12,7 +12,7 @@ namespace App\Tests\Controller;
|
|||
use App\Entity\Timesheet;
|
||||
use App\Entity\TimesheetMeta;
|
||||
use App\Entity\User;
|
||||
use App\Repository\TagRepository;
|
||||
use App\Form\Type\TagsType;
|
||||
use App\Tests\DataFixtures\TagFixtures;
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
|
@ -194,7 +194,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
|
|||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
$fixture = new TagFixtures();
|
||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
||||
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||
$this->importFixture($fixture);
|
||||
|
||||
$this->request($client, '/team/timesheet/create_mu');
|
||||
|
@ -272,7 +272,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
|
|||
$id = $timesheets[0]->getId();
|
||||
|
||||
$fixture = new TagFixtures();
|
||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
||||
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||
$this->importFixture($fixture);
|
||||
|
||||
$this->request($client, '/team/timesheet/' . $id . '/edit');
|
||||
|
@ -355,7 +355,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
|
|||
$this->importFixture($fixture);
|
||||
|
||||
$fixture = new TagFixtures();
|
||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
||||
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||
$this->importFixture($fixture);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/team/timesheet/');
|
||||
|
|
|
@ -35,8 +35,11 @@ class TagTest extends TestCase
|
|||
|
||||
$sut->setName(null);
|
||||
$this->assertNull($sut->getName());
|
||||
$this->assertNull($sut->getColor());
|
||||
$this->assertIsString($sut->getColorSafe());
|
||||
|
||||
$sut->setColor('#fffccc');
|
||||
$this->assertEquals('#fffccc', $sut->getColor());
|
||||
$this->assertEquals('#fffccc', $sut->getColorSafe());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ use App\Entity\Tag;
|
|||
use App\Form\DataTransformer\TagArrayToStringTransformer;
|
||||
use App\Repository\TagRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Form\Exception\TransformationFailedException;
|
||||
|
||||
/**
|
||||
* @covers \App\Form\DataTransformer\TagArrayToStringTransformer
|
||||
|
@ -23,6 +24,7 @@ class TagArrayToStringTransformerTest extends TestCase
|
|||
{
|
||||
$results = [
|
||||
(new Tag())->setName('foo'),
|
||||
'test',
|
||||
(new Tag())->setName('bar'),
|
||||
];
|
||||
|
||||
|
@ -32,10 +34,26 @@ class TagArrayToStringTransformerTest extends TestCase
|
|||
|
||||
$this->assertEquals('', $sut->transform([]));
|
||||
$this->assertEquals('', $sut->transform(null));
|
||||
$this->assertEquals('', $sut->transform(new \stdClass())); // @phpstan-ignore-line
|
||||
|
||||
$actual = $sut->transform($results);
|
||||
$actual = $sut->transform($results); // @phpstan-ignore-line
|
||||
|
||||
$this->assertEquals('foo, bar', $actual);
|
||||
$this->assertEquals('foo,test,bar', $actual);
|
||||
}
|
||||
|
||||
public function testTransformFails(): void
|
||||
{
|
||||
$this->expectException(TransformationFailedException::class);
|
||||
$results = [
|
||||
(new Tag())->setName('foo'),
|
||||
new \stdClass(),
|
||||
(new Tag())->setName('bar'),
|
||||
];
|
||||
|
||||
$repository = $this->getMockBuilder(TagRepository::class)->disableOriginalConstructor()->getMock();
|
||||
|
||||
$sut = new TagArrayToStringTransformer($repository, true);
|
||||
$sut->transform($results); // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
public function testReverseTransform(): void
|
||||
|
@ -55,7 +73,7 @@ class TagArrayToStringTransformerTest extends TestCase
|
|||
$this->assertEquals([], $sut->reverseTransform(''));
|
||||
$this->assertEquals([], $sut->reverseTransform(null));
|
||||
|
||||
$actual = $sut->reverseTransform('foo, bar, hello');
|
||||
$actual = $sut->reverseTransform('foo, bar , hello ');
|
||||
|
||||
$this->assertEquals(array_merge($results, [(new Tag())->setName('hello')]), $actual);
|
||||
}
|
||||
|
|
|
@ -243,11 +243,6 @@ parameters:
|
|||
count: 1
|
||||
path: API/ApiDocControllerTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
|
||||
count: 1
|
||||
path: API/ConfigurationControllerTest.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method addSubscriber\\(\\) on object\\|null\\.$#"
|
||||
count: 1
|
||||
|
@ -1473,11 +1468,6 @@ parameters:
|
|||
count: 1
|
||||
path: DataFixtures/TestFixture.php
|
||||
|
||||
-
|
||||
message: "#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\\.$#"
|
||||
count: 1
|
||||
path: DataFixtures/TimesheetFixtures.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\DataFixtures\\\\TimesheetFixtures\\:\\:createTimesheetEntry\\(\\) has parameter \\$tagArray with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
|
|
|
@ -1756,6 +1756,10 @@
|
|||
<source>absence_comment_mandatory</source>
|
||||
<target>Abwesenheit: Kommentar ist Pflichtfeld</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="M_WE1pa" resname="booking_allow_only_work_days">
|
||||
<source>booking_allow_only_work_days</source>
|
||||
<target>Erlaube Zeiteinträge nur an Tagen, für die im Arbeitsvertrag Sollstunden hinterlegt sind</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="q4ooRfF" resname="expires">
|
||||
<source>Expiry date</source>
|
||||
<target>Ablaufdatum</target>
|
||||
|
|
|
@ -1756,6 +1756,10 @@
|
|||
<source>absence_comment_mandatory</source>
|
||||
<target>Absence: Comment is a mandatory field</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="M_WE1pa" resname="booking_allow_only_work_days">
|
||||
<source>booking_allow_only_work_days</source>
|
||||
<target>Allow time entries only for days for which expected hours are defined in the employment contract</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="q4ooRfF" resname="expires">
|
||||
<source>Expiry date</source>
|
||||
<target>Expiry date</target>
|
||||
|
|
|
@ -150,6 +150,10 @@
|
|||
<source>The period of absence must not extend beyond the turn of the year.</source>
|
||||
<target>Der Zeitraum der Abwesenheit darf nicht über einen Jahreswechsel hinweg gehen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="fvxWW3V" resname="The CSRF token is invalid. Please try to resubmit the form.">
|
||||
<source>The CSRF token is invalid. Please try to resubmit the form.</source>
|
||||
<target>Bitte senden Sie das Formular erneut ab. Sollte das Problem weiterhin bestehen, laden Sie die bitte die Seite neu.</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
|
|
@ -150,6 +150,10 @@
|
|||
<source>The period of absence must not extend beyond the turn of the year.</source>
|
||||
<target>The period of absence must not extend beyond the turn of the year.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="fvxWW3V" resname="The CSRF token is invalid. Please try to resubmit the form.">
|
||||
<source>The CSRF token is invalid. Please try to resubmit the form.</source>
|
||||
<target>Please try to resubmit the form. If the problem persists, refresh your browser.</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
|
Loading…
Reference in a new issue