Release 2.18 (#4878)
This commit is contained in:
@@ -9,6 +9,7 @@ COMMENT;
|
|||||||
|
|
||||||
$fixer = new PhpCsFixer\Config();
|
$fixer = new PhpCsFixer\Config();
|
||||||
$fixer
|
$fixer
|
||||||
|
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
|
||||||
->setRiskyAllowed(true)
|
->setRiskyAllowed(true)
|
||||||
->setRules([
|
->setRules([
|
||||||
'encoding' => true,
|
'encoding' => true,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import KimaiNotification from "./plugins/KimaiNotification";
|
|||||||
import KimaiHotkeys from "./plugins/KimaiHotkeys";
|
import KimaiHotkeys from "./plugins/KimaiHotkeys";
|
||||||
import KimaiRemoteModal from "./plugins/KimaiRemoteModal";
|
import KimaiRemoteModal from "./plugins/KimaiRemoteModal";
|
||||||
import KimaiUser from "./plugins/KimaiUser";
|
import KimaiUser from "./plugins/KimaiUser";
|
||||||
|
import KimaiAutocompleteTags from "./forms/KimaiAutocompleteTags";
|
||||||
|
|
||||||
export default class KimaiLoader {
|
export default class KimaiLoader {
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ export default class KimaiLoader {
|
|||||||
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepicker="on"]'));
|
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepicker="on"]'));
|
||||||
kimai.registerPlugin(new KimaiDatePicker('input[data-datepicker="on"]'));
|
kimai.registerPlugin(new KimaiDatePicker('input[data-datepicker="on"]'));
|
||||||
kimai.registerPlugin(new KimaiAutocomplete());
|
kimai.registerPlugin(new KimaiAutocomplete());
|
||||||
|
kimai.registerPlugin(new KimaiAutocompleteTags());
|
||||||
kimai.registerPlugin(new KimaiTimesheetForm());
|
kimai.registerPlugin(new KimaiTimesheetForm());
|
||||||
kimai.registerPlugin(new KimaiTeamForm());
|
kimai.registerPlugin(new KimaiTeamForm());
|
||||||
kimai.registerPlugin(new KimaiCopyDataForm());
|
kimai.registerPlugin(new KimaiCopyDataForm());
|
||||||
|
|||||||
@@ -6,13 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import TomSelect from 'tom-select';
|
import TomSelect from 'tom-select';
|
||||||
import KimaiFormPlugin from "./KimaiFormPlugin";
|
import KimaiFormTomselectPlugin from "./KimaiFormTomselectPlugin";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supporting auto-complete fields via API.
|
* 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()
|
init()
|
||||||
{
|
{
|
||||||
@@ -28,11 +27,23 @@ export default class KimaiAutocomplete extends KimaiFormPlugin {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
activateForm(form)
|
loadData(apiUrl, query, callback) {
|
||||||
{
|
|
||||||
/** @type {KimaiAPI} API */
|
/** @type {KimaiAPI} API */
|
||||||
const API = this.getContainer().getPlugin('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) => {
|
[].slice.call(form.querySelectorAll(this.selector)).map((node) => {
|
||||||
const apiUrl = node.dataset['autocompleteUrl'];
|
const apiUrl = node.dataset['autocompleteUrl'];
|
||||||
let minChars = 3;
|
let minChars = 3;
|
||||||
@@ -40,7 +51,7 @@ export default class KimaiAutocomplete extends KimaiFormPlugin {
|
|||||||
minChars = parseInt(node.dataset['minimumCharacter']);
|
minChars = parseInt(node.dataset['minimumCharacter']);
|
||||||
}
|
}
|
||||||
|
|
||||||
new TomSelect(node, {
|
let options = {
|
||||||
// see https://github.com/orchidjs/tom-select/issues/543#issuecomment-1664342257
|
// see https://github.com/orchidjs/tom-select/issues/543#issuecomment-1664342257
|
||||||
onItemAdd: function(){
|
onItemAdd: function(){
|
||||||
// remove remaining characters from input after selecting an item
|
// remove remaining characters from input after selecting an item
|
||||||
@@ -58,36 +69,21 @@ export default class KimaiAutocomplete extends KimaiFormPlugin {
|
|||||||
return query.length >= minChars;
|
return query.length >= minChars;
|
||||||
},
|
},
|
||||||
load: (query, callback) => {
|
load: (query, callback) => {
|
||||||
API.get(apiUrl, {'name': query}, (data) => {
|
this.loadData(apiUrl, query, callback);
|
||||||
const results = [].slice.call(data).map((result) => {
|
|
||||||
return {text: result, value: result};
|
|
||||||
});
|
|
||||||
callback(results);
|
|
||||||
}, () => {
|
|
||||||
callback();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
render: {
|
};
|
||||||
|
|
||||||
|
let render = {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
not_loading: (data, escape) => {
|
not_loading: (data, escape) => {
|
||||||
// no default content
|
// no default content
|
||||||
},
|
},
|
||||||
option_create: (data, escape) => {
|
};
|
||||||
const name = escape(data.input);
|
|
||||||
if (name.length < 3) {
|
const rendererType = (node.dataset['renderer'] !== undefined) ? node.dataset['renderer'] : 'default';
|
||||||
return null;
|
options.render = {...render, ...this.getRenderer(rendererType)};
|
||||||
}
|
|
||||||
const tpl = this.translate('select.search.create');
|
new TomSelect(node, options);
|
||||||
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>';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 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)
|
constructor(selector, apiSelects)
|
||||||
{
|
{
|
||||||
@@ -82,30 +82,20 @@ export default class KimaiFormSelect extends KimaiFormPlugin {
|
|||||||
plugins: plugins,
|
plugins: plugins,
|
||||||
// if there are more than X entries, the other ones are hidden and can only be found
|
// 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
|
// by typing some characters to trigger the internal option search
|
||||||
|
// see App\Form\Type\TagsType::MAX_AMOUNT_SELECT
|
||||||
maxOptions: 500,
|
maxOptions: 500,
|
||||||
sortField:[{field: '$order'}, {field: '$score'}],
|
sortField:[{field: '$order'}, {field: '$score'}],
|
||||||
};
|
};
|
||||||
|
|
||||||
let render = {
|
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) => {
|
onOptionAdd: (value) => {
|
||||||
node.dispatchEvent(new CustomEvent('create', {detail: {'value': 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) {
|
if (node.dataset['create'] !== undefined) {
|
||||||
options = {...options, ...{
|
options = {...options, ...{
|
||||||
persist: true,
|
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);
|
const select = new TomSelect(node, options);
|
||||||
node.addEventListener('data-reloaded', (event) => {
|
node.addEventListener('data-reloaded', (event) => {
|
||||||
select.clear(true);
|
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": {
|
"app": {
|
||||||
"js": [
|
"js": [
|
||||||
"/build/runtime.74179306.js",
|
"/build/runtime.74179306.js",
|
||||||
"/build/app.cde83469.js"
|
"/build/app.41121fe4.js"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
"/build/app.ee4f7e19.css"
|
"/build/app.ee4f7e19.css"
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
},
|
},
|
||||||
"integrity": {
|
"integrity": {
|
||||||
"/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG",
|
"/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.ee4f7e19.css": "sha384-7E3ujVsOCkGatHu6khaWWqYhPkc85k22zuXZOwOVo1MlWcPSEBhS65Y0qbeWXdAp",
|
||||||
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
|
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
|
||||||
"/build/app-rtl.db588f2b.css": "sha384-OwYS82WpkkoVqI38n3gGpvMTiTFT+dUscgVglMw2vcDvIxAe2B3IcXMjHoCGm/gl",
|
"/build/app-rtl.db588f2b.css": "sha384-OwYS82WpkkoVqI38n3gGpvMTiTFT+dUscgVglMw2vcDvIxAe2B3IcXMjHoCGm/gl",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"build/app.css": "/build/app.ee4f7e19.css",
|
"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.css": "/build/app-rtl.db588f2b.css",
|
||||||
"build/app-rtl.js": "/build/app-rtl.97153087.js",
|
"build/app-rtl.js": "/build/app-rtl.97153087.js",
|
||||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ final class ApiRequestMatcher implements RequestMatcherInterface
|
|||||||
return false;
|
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
|
// 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.
|
// 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
|
// 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
|
* 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)))]
|
#[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
|
public function timesheetConfigAction(SystemConfiguration $configuration): Response
|
||||||
{
|
{
|
||||||
$model = new TimesheetConfig();
|
$model = new TimesheetConfig();
|
||||||
@@ -46,4 +46,17 @@ final class ConfigurationController extends BaseApiController
|
|||||||
|
|
||||||
return $this->viewHandler->handle($view);
|
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')))]
|
#[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')]
|
#[Route(methods: ['GET'], name: 'get_tags')]
|
||||||
@@ -56,6 +56,27 @@ final class TagController extends BaseApiController
|
|||||||
return $this->viewHandler->handle($view);
|
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
|
* Creates a new tag
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
|
|
||||||
namespace App\API;
|
namespace App\API;
|
||||||
|
|
||||||
use App\Configuration\SystemConfiguration;
|
|
||||||
use App\Entity\AccessToken;
|
use App\Entity\AccessToken;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Event\PrepareUserEvent;
|
use App\Event\PrepareUserEvent;
|
||||||
@@ -18,6 +17,7 @@ use App\Form\API\UserApiEditForm;
|
|||||||
use App\Repository\AccessTokenRepository;
|
use App\Repository\AccessTokenRepository;
|
||||||
use App\Repository\Query\UserQuery;
|
use App\Repository\Query\UserQuery;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
|
use App\User\UserService;
|
||||||
use App\Utils\SearchTerm;
|
use App\Utils\SearchTerm;
|
||||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||||
@@ -27,8 +27,6 @@ use OpenApi\Attributes as OA;
|
|||||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
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\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
@@ -45,8 +43,6 @@ final class UserController extends BaseApiController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ViewHandlerInterface $viewHandler,
|
private readonly ViewHandlerInterface $viewHandler,
|
||||||
private readonly UserRepository $repository,
|
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\Post(description: 'Creates a new user and returns it afterwards')]
|
||||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserCreateForm'))]
|
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserCreateForm'))]
|
||||||
#[Route(methods: ['POST'], path: '', name: 'post_user')]
|
#[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 = $userService->createNewUser();
|
||||||
$user->setEnabled(true);
|
|
||||||
$user->setRoles([User::DEFAULT_ROLE]);
|
|
||||||
$user->setTimezone($this->configuration->getUserDefaultTimezone());
|
|
||||||
$user->setLanguage($this->configuration->getUserDefaultLanguage());
|
|
||||||
|
|
||||||
$form = $this->createForm(UserApiCreateForm::class, $user, [
|
$form = $this->createForm(UserApiCreateForm::class, $user, [
|
||||||
'include_roles' => $this->isGranted('roles', $user),
|
'include_roles' => $this->isGranted('roles', $user),
|
||||||
@@ -156,24 +148,11 @@ final class UserController extends BaseApiController
|
|||||||
$form->submit($request->request->all());
|
$form->submit($request->request->all());
|
||||||
|
|
||||||
if ($form->isValid()) {
|
if ($form->isValid()) {
|
||||||
$plainPassword = $user->getPlainPassword();
|
$user = $userService->saveNewUser($user);
|
||||||
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);
|
|
||||||
|
|
||||||
$view = new View($user, 200);
|
$view = new View($user, 200);
|
||||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||||
|
|
||||||
$user->eraseCredentials();
|
|
||||||
|
|
||||||
return $this->viewHandler->handle($view);
|
return $this->viewHandler->handle($view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
|
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
|
* @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
|
final class UserLoginLinkCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
namespace App\Configuration;
|
namespace App\Configuration;
|
||||||
|
|
||||||
|
use App\Constants;
|
||||||
|
|
||||||
final class SystemConfiguration
|
final class SystemConfiguration
|
||||||
{
|
{
|
||||||
private bool $initialized = false;
|
private bool $initialized = false;
|
||||||
@@ -479,6 +481,9 @@ final class SystemConfiguration
|
|||||||
return (bool) $this->find('theme.avatar_url');
|
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
|
public function getThemeColorChoices(): string
|
||||||
{
|
{
|
||||||
$config = $this->find('theme.color_choices');
|
$config = $this->find('theme.color_choices');
|
||||||
@@ -489,6 +494,40 @@ final class SystemConfiguration
|
|||||||
return 'Silver|#c0c0c0';
|
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 ==========
|
// ========== Projects ==========
|
||||||
|
|
||||||
public function isProjectCopyTeamsOnCreate(): bool
|
public function isProjectCopyTeamsOnCreate(): bool
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ class Constants
|
|||||||
/**
|
/**
|
||||||
* The current release version
|
* The current release version
|
||||||
*/
|
*/
|
||||||
public const VERSION = '2.17.0';
|
public const VERSION = '2.18.0';
|
||||||
/**
|
/**
|
||||||
* The current release: major * 10000 + minor * 100 + patch
|
* The current release: major * 10000 + minor * 100 + patch
|
||||||
*/
|
*/
|
||||||
public const VERSION_ID = 21700;
|
public const VERSION_ID = 21800;
|
||||||
/**
|
/**
|
||||||
* The software name
|
* The software name
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -456,20 +456,23 @@ final class ActivityController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Activity $activity
|
|
||||||
* @return FormInterface<ActivityEditForm>
|
* @return FormInterface<ActivityEditForm>
|
||||||
*/
|
*/
|
||||||
private function createEditForm(Activity $activity): FormInterface
|
private function createEditForm(Activity $activity): FormInterface
|
||||||
{
|
{
|
||||||
$currency = $this->configuration->getCustomerDefaultCurrency();
|
$currency = $this->configuration->getCustomerDefaultCurrency();
|
||||||
$url = $this->generateUrl('admin_activity_create');
|
$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) {
|
if ($activity->getId() !== null) {
|
||||||
$url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]);
|
$url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
if (null !== $activity->getProject()) {
|
if (null !== $activity->getProject()) {
|
||||||
$currency = $activity->getProject()->getCustomer()->getCurrency();
|
$currency = $activity->getProject()->getCustomer()->getCurrency();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return $this->createForm(ActivityEditForm::class, $activity, [
|
return $this->createForm(ActivityEditForm::class, $activity, [
|
||||||
'action' => $url,
|
'action' => $url,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ final class SamlController extends AbstractController
|
|||||||
$session->remove($authErrorKey);
|
$session->remove($authErrorKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($error) {
|
if ($error !== null) {
|
||||||
if (\is_object($error) && method_exists($error, 'getMessage')) {
|
if (\is_object($error) && method_exists($error, 'getMessage')) {
|
||||||
$error = $error->getMessage();
|
$error = $error->getMessage();
|
||||||
}
|
}
|
||||||
@@ -60,13 +60,12 @@ final class SamlController extends AbstractController
|
|||||||
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
|
$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) {
|
if ($url === null) {
|
||||||
throw new \RuntimeException('SAML login failed');
|
throw new \RuntimeException('SAML login failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// this line is not (yet) reached, as the previous call will exit
|
|
||||||
return $this->redirect($url);
|
return $this->redirect($url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Utils\Color;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
@@ -22,6 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||||
#[UniqueEntity('name')]
|
#[UniqueEntity('name')]
|
||||||
#[Serializer\ExclusionPolicy('all')]
|
#[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
|
class Tag
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -95,4 +97,9 @@ class Tag
|
|||||||
{
|
{
|
||||||
return $this->getName();
|
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;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated since 2.15
|
||||||
|
*/
|
||||||
#[Serializer\VirtualProperty]
|
#[Serializer\VirtualProperty]
|
||||||
#[Serializer\SerializedName('apiToken')]
|
#[Serializer\SerializedName('apiToken')]
|
||||||
#[Serializer\Groups(['Default'])]
|
#[Serializer\Groups(['Default'])]
|
||||||
|
|||||||
@@ -37,7 +37,22 @@ final class TagArrayToStringTransformer implements DataTransformerInterface
|
|||||||
return '';
|
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) {
|
if ('' === $value || null === $value) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!\is_array($value)) {
|
if (!\is_array($value)) {
|
||||||
$names = array_filter(array_unique(array_map('trim', explode(',', $value))));
|
$names = array_filter(array_unique(array_map('trim', explode(',', $value))));
|
||||||
} else {
|
} else {
|
||||||
@@ -68,18 +84,12 @@ final class TagArrayToStringTransformer implements DataTransformerInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tagName = trim($tagName);
|
$tagName = trim($tagName);
|
||||||
$tag = null;
|
|
||||||
|
|
||||||
if (is_numeric($tagName)) {
|
// do not check for numeric values as ID, this form type only submits tag names
|
||||||
$tag = $this->tagRepository->find($tagName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tag === null) {
|
|
||||||
$tag = $this->tagRepository->findTagByName($tagName);
|
$tag = $this->tagRepository->findTagByName($tagName);
|
||||||
}
|
|
||||||
|
|
||||||
// get the current tags and find the new ones that should be created
|
// get the current tags and find the new ones that should be created
|
||||||
if ($this->create && $tag === null) {
|
if ($tag === null && $this->create) {
|
||||||
$tag = new Tag();
|
$tag = new Tag();
|
||||||
$tag->setName(mb_substr($tagName, 0, 100));
|
$tag->setName(mb_substr($tagName, 0, 100));
|
||||||
$this->tagRepository->saveTag($tag);
|
$this->tagRepository->saveTag($tag);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
namespace App\Form\Type;
|
namespace App\Form\Type;
|
||||||
|
|
||||||
use App\Configuration\SystemConfiguration;
|
use App\Configuration\SystemConfiguration;
|
||||||
use App\Constants;
|
|
||||||
use App\Utils\Color;
|
use App\Utils\Color;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\DataTransformerInterface;
|
use Symfony\Component\Form\DataTransformerInterface;
|
||||||
@@ -22,7 +21,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||||||
|
|
||||||
final class ColorChoiceType extends AbstractType implements DataTransformerInterface
|
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 = [
|
$options = [
|
||||||
'documentation' => [
|
'documentation' => [
|
||||||
'type' => 'string',
|
'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',
|
'label' => 'color',
|
||||||
'empty_data' => null,
|
'empty_data' => null,
|
||||||
@@ -50,7 +49,7 @@ final class ColorChoiceType extends AbstractType implements DataTransformerInter
|
|||||||
];
|
];
|
||||||
|
|
||||||
$choices = [];
|
$choices = [];
|
||||||
$colors = $this->convertStringToColorArray($this->systemConfiguration->getThemeColorChoices());
|
$colors = $this->systemConfiguration->getThemeColors();
|
||||||
|
|
||||||
foreach ($colors as $name => $color) {
|
foreach ($colors as $name => $color) {
|
||||||
$choices[$name] = $color;
|
$choices[$name] = $color;
|
||||||
@@ -69,44 +68,9 @@ final class ColorChoiceType extends AbstractType implements DataTransformerInter
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function transform(mixed $value): mixed
|
||||||
* @param string $config
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function convertStringToColorArray(string $config): array
|
|
||||||
{
|
{
|
||||||
$config = explode(',', $config);
|
return $value;
|
||||||
|
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reverseTransform(mixed $value): mixed
|
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
|
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||||
{
|
{
|
||||||
$view->vars['attr'] = array_merge($view->vars['attr'], [
|
$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,
|
'data-minimum-character' => 3,
|
||||||
'class' => 'form-select',
|
'class' => 'form-select',
|
||||||
'autocomplete' => 'off',
|
'autocomplete' => 'off',
|
||||||
'data-form-widget' => 'autocomplete'
|
'data-form-widget' => 'tags',
|
||||||
|
'data-renderer' => 'color',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($options['allow_create']) {
|
if ($options['allow_create']) {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ namespace App\Form\Type;
|
|||||||
use App\Entity\Tag;
|
use App\Entity\Tag;
|
||||||
use App\Repository\Query\TagFormTypeQuery;
|
use App\Repository\Query\TagFormTypeQuery;
|
||||||
use App\Repository\TagRepository;
|
use App\Repository\TagRepository;
|
||||||
use App\Utils\Color;
|
|
||||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
@@ -87,17 +86,11 @@ final class TagsSelectType extends AbstractType
|
|||||||
return $tag->getId();
|
return $tag->getId();
|
||||||
},
|
},
|
||||||
'choice_attr' => function (Tag $tag) {
|
'choice_attr' => function (Tag $tag) {
|
||||||
$color = $tag->getColor();
|
return ['data-color' => $tag->getColorSafe()];
|
||||||
if ($color === null) {
|
|
||||||
$color = (new Color())->getRandom($tag->getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['data-color' => $color];
|
|
||||||
},
|
},
|
||||||
'choice_label' => function (Tag $tag) {
|
'choice_label' => function (Tag $tag) {
|
||||||
return $tag->getName();
|
return $tag->getName();
|
||||||
},
|
},
|
||||||
'attr' => ['data-renderer' => 'color'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$resolver->setDefault('query_builder', function (Options $options) {
|
$resolver->setDefault('query_builder', function (Options $options) {
|
||||||
@@ -119,6 +112,10 @@ final class TagsSelectType extends AbstractType
|
|||||||
'data-create' => 'post_tag',
|
'data-create' => 'post_tag',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$view->vars['attr'] = array_merge($view->vars['attr'], [
|
||||||
|
'data-renderer' => 'color',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getParent(): string
|
public function getParent(): string
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
|||||||
|
|
||||||
final class TagsType extends AbstractType
|
final class TagsType extends AbstractType
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* See KimaiFormSelect.js (maxOptions) as well.
|
||||||
|
*/
|
||||||
|
public const MAX_AMOUNT_SELECT = 500;
|
||||||
|
|
||||||
private ?int $count = null;
|
private ?int $count = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -37,7 +42,7 @@ final class TagsType extends AbstractType
|
|||||||
$this->count = $this->repository->count([]);
|
$this->count = $this->repository->count([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->count > TagRepository::MAX_AMOUNT_SELECT) {
|
if ($this->count > self::MAX_AMOUNT_SELECT) {
|
||||||
return TagsInputType::class;
|
return TagsInputType::class;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,15 +19,10 @@ use Doctrine\ORM\EntityRepository;
|
|||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends \Doctrine\ORM\EntityRepository<Tag>
|
* @extends EntityRepository<Tag>
|
||||||
*/
|
*/
|
||||||
class TagRepository extends EntityRepository
|
class TagRepository extends EntityRepository
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* See KimaiFormSelect.js (maxOptions) as well.
|
|
||||||
*/
|
|
||||||
public const MAX_AMOUNT_SELECT = 500;
|
|
||||||
|
|
||||||
public function saveTag(Tag $tag): void
|
public function saveTag(Tag $tag): void
|
||||||
{
|
{
|
||||||
$entityManager = $this->getEntityManager();
|
$entityManager = $this->getEntityManager();
|
||||||
@@ -64,18 +59,11 @@ class TagRepository extends EntityRepository
|
|||||||
return $this->findOneBy(['name' => $tagName, 'visible' => $visible]);
|
return $this->findOneBy(['name' => $tagName, 'visible' => $visible]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function findAllTagsQuery(?string $filter = null): QueryBuilder
|
||||||
* Find all visible tag names in alphabetical order.
|
|
||||||
*
|
|
||||||
* @return array<string>
|
|
||||||
*/
|
|
||||||
public function findAllTagNames(?string $filter = null): array
|
|
||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('t');
|
$qb = $this->createQueryBuilder('t');
|
||||||
|
|
||||||
$qb
|
$qb->addOrderBy('t.name', 'ASC');
|
||||||
->select('t.name')
|
|
||||||
->addOrderBy('t.name', 'ASC');
|
|
||||||
|
|
||||||
$qb->andWhere($qb->expr()->eq('t.visible', ':visible'));
|
$qb->andWhere($qb->expr()->eq('t.visible', ':visible'));
|
||||||
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
|
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
|
||||||
@@ -85,7 +73,27 @@ class TagRepository extends EntityRepository
|
|||||||
$qb->setParameter('filter', '%' . $filter . '%');
|
$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 false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($token->hasAttribute('api-token')) {
|
||||||
return $this->permissionManager->hasRolePermission($user, 'api_access');
|
return $this->permissionManager->hasRolePermission($user, 'api_access');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,21 @@
|
|||||||
{% embed '@theme/embeds/card.html.twig' %}
|
{% embed '@theme/embeds/card.html.twig' %}
|
||||||
{% import "macros/widgets.html.twig" as widgets %}
|
{% import "macros/widgets.html.twig" as widgets %}
|
||||||
{% block box_attributes %}id="quick_entry_box"{% endblock %}
|
{% block box_attributes %}id="quick_entry_box"{% endblock %}
|
||||||
|
{% block box_class %}mt-2{% endblock %}
|
||||||
{% block box_before %}
|
{% block box_before %}
|
||||||
{{ form_start(form, {attr: {id: 'quick-entries-form', class: 'form-dataTable quick-entries'}}) }}
|
{{ form_start(form, {attr: {id: 'quick-entries-form', class: 'form-dataTable quick-entries'}}) }}
|
||||||
{{ form_widget(form._token) }}
|
{{ form_widget(form._token) }}
|
||||||
{% endblock %}
|
|
||||||
{% block box_after %}{{ form_end(form) }}{% endblock %}
|
|
||||||
{% block box_title %}
|
|
||||||
{{ form_widget(form.date) }}
|
{{ form_widget(form.date) }}
|
||||||
{{ form_errors(form) }}
|
{{ form_errors(form) }}
|
||||||
{% endblock %}
|
{% 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 #}
|
{# "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_class %}p-0 maybe-table-responsive{% endblock %}
|
||||||
{% block box_body %}
|
{% block box_body %}
|
||||||
@@ -46,13 +52,6 @@
|
|||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
{% endblock %}
|
{% 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 %}
|
{% endembed %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -31,35 +31,35 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block box_footer %}
|
{% block box_footer %}
|
||||||
<div class="row">
|
<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">
|
<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>
|
<span>{{ 'stats.workingTimeToday'|trans({'%day%': 'now'|date_short}) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<span>{{ 'stats.workingTimeWeek'|trans({'%week%': data.begin|date_format('W')}) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<span>{{ 'stats.workingTimeMonth'|trans({'%month%': data.thisMonth|month_name, '%year%': data.thisMonth|date_format('Y')}) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if data.financial is not null %}
|
{% 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">
|
<div class="text-center">
|
||||||
<h5>{{ data.financial|duration }}</h5>
|
<h5 class="mb-1">{{ data.financial|duration }}</h5>
|
||||||
<span>{{ 'stats.workingTimeFinancialYear'|trans }}</span>
|
<span>{{ 'stats.workingTimeFinancialYear'|trans }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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">
|
<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>
|
<span>{{ 'stats.workingTimeYear'|trans({'%year%': data.thisMonth|date_format('Y')}) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
|||||||
'id' => 'int',
|
'id' => 'int',
|
||||||
'name' => 'string',
|
'name' => 'string',
|
||||||
'color' => '@string',
|
'color' => '@string',
|
||||||
|
'color-safe' => 'string',
|
||||||
'visible' => 'bool',
|
'visible' => 'bool',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class ApiDocControllerTest extends ControllerBaseTest
|
|||||||
'/api/activities/{id}/rates',
|
'/api/activities/{id}/rates',
|
||||||
'/api/activities/{id}/rates/{rateId}',
|
'/api/activities/{id}/rates/{rateId}',
|
||||||
'/api/config/timesheet',
|
'/api/config/timesheet',
|
||||||
|
'/api/config/colors',
|
||||||
'/api/customers',
|
'/api/customers',
|
||||||
'/api/customers/{id}',
|
'/api/customers/{id}',
|
||||||
'/api/customers/{id}/meta',
|
'/api/customers/{id}/meta',
|
||||||
@@ -78,6 +79,7 @@ class ApiDocControllerTest extends ControllerBaseTest
|
|||||||
'/api/version',
|
'/api/version',
|
||||||
'/api/plugins',
|
'/api/plugins',
|
||||||
'/api/tags',
|
'/api/tags',
|
||||||
|
'/api/tags/find',
|
||||||
'/api/tags/{id}',
|
'/api/tags/{id}',
|
||||||
'/api/teams',
|
'/api/teams',
|
||||||
'/api/teams/{id}',
|
'/api/teams/{id}',
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ class ConfigurationControllerTest extends APIControllerBaseTest
|
|||||||
{
|
{
|
||||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||||
$this->assertAccessIsGranted($client, '/api/config/timesheet', 'GET');
|
$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->assertIsArray($result);
|
||||||
$this->assertNotEmpty($result);
|
$this->assertNotEmpty($result);
|
||||||
@@ -37,4 +39,53 @@ class ConfigurationControllerTest extends APIControllerBaseTest
|
|||||||
|
|
||||||
$this->assertEquals($expectedKeys, $actual, 'Config structure does not match');
|
$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]);
|
$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
|
public function testEmptyCollection(): void
|
||||||
{
|
{
|
||||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ class SystemConfigurationTest extends TestCase
|
|||||||
$this->assertFalse($sut->find('timesheet.rules.allow_future_times'));
|
$this->assertFalse($sut->find('timesheet.rules.allow_future_times'));
|
||||||
$this->assertEquals(99, $sut->find('timesheet.active_entries.hard_limit'));
|
$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,,|#000000', $sut->getThemeColorChoices());
|
||||||
|
$this->assertEquals(['Maroon' => '#800000', 'Brown' => '#a52a2a', 'Red' => '#ff0000', 'Orange' => '#ffa500', '#ffffff' => '#ffffff', '#000000' => '#000000'], $sut->getThemeColors());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDefaultWithLoader(): void
|
public function testDefaultWithLoader(): void
|
||||||
@@ -160,6 +161,7 @@ class SystemConfigurationTest extends TestCase
|
|||||||
$this->assertFalse($sut->find('timesheet.rules.allow_future_times'));
|
$this->assertFalse($sut->find('timesheet.rules.allow_future_times'));
|
||||||
$this->assertTrue($sut->isSamlActive());
|
$this->assertTrue($sut->isSamlActive());
|
||||||
$this->assertEquals('Silver|#c0c0c0', $sut->getThemeColorChoices());
|
$this->assertEquals('Silver|#c0c0c0', $sut->getThemeColorChoices());
|
||||||
|
$this->assertEquals(['Silver' => '#c0c0c0'], $sut->getThemeColors());
|
||||||
$this->assertEquals('2020-03-27', $sut->getFinancialYearStart());
|
$this->assertEquals('2020-03-27', $sut->getFinancialYearStart());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use App\Entity\Tag;
|
|||||||
use App\Entity\Timesheet;
|
use App\Entity\Timesheet;
|
||||||
use App\Entity\TimesheetMeta;
|
use App\Entity\TimesheetMeta;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Repository\TagRepository;
|
use App\Form\Type\TagsType;
|
||||||
use App\Tests\DataFixtures\ActivityFixtures;
|
use App\Tests\DataFixtures\ActivityFixtures;
|
||||||
use App\Tests\DataFixtures\TagFixtures;
|
use App\Tests\DataFixtures\TagFixtures;
|
||||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||||
@@ -51,7 +51,7 @@ class TimesheetControllerTest extends ControllerBaseTest
|
|||||||
$start = new \DateTime('first day of this month');
|
$start = new \DateTime('first day of this month');
|
||||||
|
|
||||||
$fixture = new TagFixtures();
|
$fixture = new TagFixtures();
|
||||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||||
$fixture->addTagNameToCreate('bar');
|
$fixture->addTagNameToCreate('bar');
|
||||||
$this->importFixture($fixture);
|
$this->importFixture($fixture);
|
||||||
|
|
||||||
@@ -531,7 +531,7 @@ class TimesheetControllerTest extends ControllerBaseTest
|
|||||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||||
|
|
||||||
$fixture = new TagFixtures();
|
$fixture = new TagFixtures();
|
||||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||||
$fixture->addTagNameToCreate('two');
|
$fixture->addTagNameToCreate('two');
|
||||||
$this->importFixture($fixture);
|
$this->importFixture($fixture);
|
||||||
|
|
||||||
@@ -636,7 +636,7 @@ class TimesheetControllerTest extends ControllerBaseTest
|
|||||||
$id = $timesheets[0]->getId();
|
$id = $timesheets[0]->getId();
|
||||||
|
|
||||||
$fixture = new TagFixtures();
|
$fixture = new TagFixtures();
|
||||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||||
$this->importFixture($fixture);
|
$this->importFixture($fixture);
|
||||||
|
|
||||||
$this->request($client, '/timesheet/' . $id . '/edit');
|
$this->request($client, '/timesheet/' . $id . '/edit');
|
||||||
@@ -717,7 +717,7 @@ class TimesheetControllerTest extends ControllerBaseTest
|
|||||||
$this->importFixture($fixture);
|
$this->importFixture($fixture);
|
||||||
|
|
||||||
$fixture = new TagFixtures();
|
$fixture = new TagFixtures();
|
||||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||||
$this->importFixture($fixture);
|
$this->importFixture($fixture);
|
||||||
|
|
||||||
$this->assertAccessIsGranted($client, '/timesheet/');
|
$this->assertAccessIsGranted($client, '/timesheet/');
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace App\Tests\Controller;
|
|||||||
use App\Entity\Timesheet;
|
use App\Entity\Timesheet;
|
||||||
use App\Entity\TimesheetMeta;
|
use App\Entity\TimesheetMeta;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Repository\TagRepository;
|
use App\Form\Type\TagsType;
|
||||||
use App\Tests\DataFixtures\TagFixtures;
|
use App\Tests\DataFixtures\TagFixtures;
|
||||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||||
use App\Timesheet\DateTimeFactory;
|
use App\Timesheet\DateTimeFactory;
|
||||||
@@ -194,7 +194,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
|
|||||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||||
|
|
||||||
$fixture = new TagFixtures();
|
$fixture = new TagFixtures();
|
||||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||||
$this->importFixture($fixture);
|
$this->importFixture($fixture);
|
||||||
|
|
||||||
$this->request($client, '/team/timesheet/create_mu');
|
$this->request($client, '/team/timesheet/create_mu');
|
||||||
@@ -272,7 +272,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
|
|||||||
$id = $timesheets[0]->getId();
|
$id = $timesheets[0]->getId();
|
||||||
|
|
||||||
$fixture = new TagFixtures();
|
$fixture = new TagFixtures();
|
||||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||||
$this->importFixture($fixture);
|
$this->importFixture($fixture);
|
||||||
|
|
||||||
$this->request($client, '/team/timesheet/' . $id . '/edit');
|
$this->request($client, '/team/timesheet/' . $id . '/edit');
|
||||||
@@ -355,7 +355,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
|
|||||||
$this->importFixture($fixture);
|
$this->importFixture($fixture);
|
||||||
|
|
||||||
$fixture = new TagFixtures();
|
$fixture = new TagFixtures();
|
||||||
$fixture->importAmount(TagRepository::MAX_AMOUNT_SELECT);
|
$fixture->importAmount(TagsType::MAX_AMOUNT_SELECT);
|
||||||
$this->importFixture($fixture);
|
$this->importFixture($fixture);
|
||||||
|
|
||||||
$this->assertAccessIsGranted($client, '/team/timesheet/');
|
$this->assertAccessIsGranted($client, '/team/timesheet/');
|
||||||
|
|||||||
@@ -35,8 +35,11 @@ class TagTest extends TestCase
|
|||||||
|
|
||||||
$sut->setName(null);
|
$sut->setName(null);
|
||||||
$this->assertNull($sut->getName());
|
$this->assertNull($sut->getName());
|
||||||
|
$this->assertNull($sut->getColor());
|
||||||
|
$this->assertIsString($sut->getColorSafe());
|
||||||
|
|
||||||
$sut->setColor('#fffccc');
|
$sut->setColor('#fffccc');
|
||||||
$this->assertEquals('#fffccc', $sut->getColor());
|
$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\Form\DataTransformer\TagArrayToStringTransformer;
|
||||||
use App\Repository\TagRepository;
|
use App\Repository\TagRepository;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Form\Exception\TransformationFailedException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @covers \App\Form\DataTransformer\TagArrayToStringTransformer
|
* @covers \App\Form\DataTransformer\TagArrayToStringTransformer
|
||||||
@@ -23,6 +24,7 @@ class TagArrayToStringTransformerTest extends TestCase
|
|||||||
{
|
{
|
||||||
$results = [
|
$results = [
|
||||||
(new Tag())->setName('foo'),
|
(new Tag())->setName('foo'),
|
||||||
|
'test',
|
||||||
(new Tag())->setName('bar'),
|
(new Tag())->setName('bar'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -32,10 +34,26 @@ class TagArrayToStringTransformerTest extends TestCase
|
|||||||
|
|
||||||
$this->assertEquals('', $sut->transform([]));
|
$this->assertEquals('', $sut->transform([]));
|
||||||
$this->assertEquals('', $sut->transform(null));
|
$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
|
public function testReverseTransform(): void
|
||||||
|
|||||||
@@ -243,11 +243,6 @@ parameters:
|
|||||||
count: 1
|
count: 1
|
||||||
path: API/ApiDocControllerTest.php
|
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\\.$#"
|
message: "#^Cannot call method addSubscriber\\(\\) on object\\|null\\.$#"
|
||||||
count: 1
|
count: 1
|
||||||
@@ -1473,11 +1468,6 @@ parameters:
|
|||||||
count: 1
|
count: 1
|
||||||
path: DataFixtures/TestFixture.php
|
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\\.$#"
|
message: "#^Method App\\\\Tests\\\\DataFixtures\\\\TimesheetFixtures\\:\\:createTimesheetEntry\\(\\) has parameter \\$tagArray with no value type specified in iterable type array\\.$#"
|
||||||
count: 1
|
count: 1
|
||||||
|
|||||||
@@ -1756,6 +1756,10 @@
|
|||||||
<source>absence_comment_mandatory</source>
|
<source>absence_comment_mandatory</source>
|
||||||
<target>Abwesenheit: Kommentar ist Pflichtfeld</target>
|
<target>Abwesenheit: Kommentar ist Pflichtfeld</target>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="q4ooRfF" resname="expires">
|
||||||
<source>Expiry date</source>
|
<source>Expiry date</source>
|
||||||
<target>Ablaufdatum</target>
|
<target>Ablaufdatum</target>
|
||||||
|
|||||||
@@ -1756,6 +1756,10 @@
|
|||||||
<source>absence_comment_mandatory</source>
|
<source>absence_comment_mandatory</source>
|
||||||
<target>Absence: Comment is a mandatory field</target>
|
<target>Absence: Comment is a mandatory field</target>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="q4ooRfF" resname="expires">
|
||||||
<source>Expiry date</source>
|
<source>Expiry date</source>
|
||||||
<target>Expiry date</target>
|
<target>Expiry date</target>
|
||||||
|
|||||||
@@ -150,6 +150,10 @@
|
|||||||
<source>The period of absence must not extend beyond the turn of the year.</source>
|
<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>
|
<target>Der Zeitraum der Abwesenheit darf nicht über einen Jahreswechsel hinweg gehen.</target>
|
||||||
</trans-unit>
|
</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>
|
</body>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|||||||
@@ -150,6 +150,10 @@
|
|||||||
<source>The period of absence must not extend beyond the turn of the year.</source>
|
<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>
|
<target>The period of absence must not extend beyond the turn of the year.</target>
|
||||||
</trans-unit>
|
</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>
|
</body>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|||||||
Reference in New Issue
Block a user