0
0
Fork 0
mirror of https://github.com/kevinpapst/kimai2.git synced 2025-03-16 14:03:30 +00:00

Release 2.18 ()

This commit is contained in:
Kevin Papst 2024-06-16 13:15:49 +02:00 committed by GitHub
parent 8792a1df09
commit 987b46bf8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1768 additions and 814 deletions

View file

@ -9,6 +9,7 @@ COMMENT;
$fixer = new PhpCsFixer\Config();
$fixer
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
->setRiskyAllowed(true)
->setRules([
'encoding' => true,

View file

@ -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());

View file

@ -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);
});
}

View 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();
});
}
}

View file

@ -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">&nbsp;</span>';
} else {
item += '<span class="color-choice-item">&nbsp;</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">&nbsp;</span>';
} else {
item += '<span class="color-choice-item">&nbsp;</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 = '&nbsp;';
} else {
text = escape(text);
}
return '<div>' + text + '</div>';
}
}};
}
const select = new TomSelect(node, options);
node.addEventListener('data-reloaded', (event) => {
select.clear(true);

View 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">&nbsp;</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">&nbsp;</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 = '&nbsp;';
} else {
text = escape(text);
}
return '<div>' + text + '</div>';
}
}};
}
return render;
}
}

1847
composer.lock generated

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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);
}
}

View file

@ -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
*/

View file

@ -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);
}

View file

@ -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(

View file

@ -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

View file

@ -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
*/

View file

@ -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, [

View file

@ -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);
}

View file

@ -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());
}
}

View file

@ -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'])]

View file

@ -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);

View file

@ -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

View file

@ -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']) {

View file

@ -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

View file

@ -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;
}

View file

@ -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');
}
/**

View file

@ -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;
}
}

View file

@ -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 %}

View file

@ -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>

View file

@ -287,6 +287,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'id' => 'int',
'name' => 'string',
'color' => '@string',
'color-safe' => 'string',
'visible' => 'bool',
];

View file

@ -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}',

View file

@ -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');
}
}

View file

@ -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);

View file

@ -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());
}

View file

@ -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/');

View file

@ -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/');

View file

@ -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());
}
}

View file

@ -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);
}

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>