Release 2.37 (#5546)

This commit is contained in:
Kevin Papst
2025-07-04 16:43:38 +02:00
committed by GitHub
parent 06b3060fe1
commit dfec807166
52 changed files with 602 additions and 352 deletions

View File

@@ -43,7 +43,10 @@ export default class KimaiDateNowForm extends KimaiFormPlugin {
const formElement = document.getElementById(linkTarget.dataset.target); const formElement = document.getElementById(linkTarget.dataset.target);
if (!formElement.disabled) { if (!formElement.disabled) {
formElement.value = this.getDateUtils().format(linkTarget.dataset.format, null); formElement.value = this.getDateUtils().format(linkTarget.dataset.format, null);
// this should usually work
formElement.dispatchEvent(new Event('change', {bubbles: true})); formElement.dispatchEvent(new Event('change', {bubbles: true}));
// this is required for Litepicker to pick the new date (with autoRefresh option)
formElement.dispatchEvent(new Event('keyup', {bubbles: true}));
} }
event.preventDefault(); event.preventDefault();

View File

@@ -82,6 +82,8 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
// see App\Form\Type\TagsType::MAX_AMOUNT_SELECT // see App\Form\Type\TagsType::MAX_AMOUNT_SELECT
maxOptions: 500, maxOptions: 500,
sortField:[{field: '$order'}, {field: '$score'}], sortField:[{field: '$order'}, {field: '$score'}],
// required so it works in table.responsive, but requires z-index 1056, because bootstrap modal would otherwise hide it
dropdownParent: 'body',
}; };
let render = { let render = {

View File

@@ -16,23 +16,151 @@ section.quick-entry-page {
display: none; display: none;
} }
/* shrinks the dropdown elements for project and activity to fit into the table, instead of adjusting to the longest option element */
.ts-wrapper { .ts-wrapper {
display: table; min-height: 36px;
table-layout: fixed; max-height: 36px;
max-width: 150px;
.ts-control {
white-space: nowrap;
.item {
overflow: hidden;
}
}
}
.table tbody td:not(.total) {
padding: 3px;
}
.duration-widget input.duration-input {
min-width: 40px;
} }
/* make the duration field and dropdown smaller */ /* make the duration field and dropdown smaller */
#quick_entry_box { #quick_entry_box {
.duration-widget { .duration-widget {
.input-group { .input-group {
min-width: 85px; max-width: 50px;
min-width: 50px;
} }
.btn-duration-preset { .btn-duration-preset {
padding: 7px 8px 7px 5px; display: none;
padding: 7px 5px 7px 2px;
} }
input { input.duration-input {
padding: 7px 3px 7px 7px; padding: 7px 5px;
max-width: 100%;
border-bottom-right-radius: var(--tblr-border-radius);
border-top-right-radius: var(--tblr-border-radius);
}
}
table.dataTable thead th,
table.dataTable td.total {
padding: .75rem 2px;
}
}
/* make sure dropdown never hides behind table responsive */
.dropdown-menu {
z-index: 1056;
}
}
@media (min-width: 900px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 170px;
}
}
}
@media (min-width: 1000px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 180px;
}
}
}
@media (min-width: 1200px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 210px;
}
.duration-widget {
max-width: 100%;
}
}
}
@media (min-width: 1250px) {
section.quick-entry-page {
#quick_entry_box {
.duration-widget {
.input-group {
max-width: 70px;
min-width: 70px;
}
.btn-duration-preset {
display: flex;
}
input.duration-input {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
}
table.dataTable thead th,
table.dataTable td.total {
padding: .75rem;
}
}
}
}
@media (min-width: 1300px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 230px;
}
}
}
@media (min-width: 1400px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 270px;
}
}
}
@media (min-width: 1500px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 300px;
}
}
}
@media (min-width: 1600px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 370px;
}
}
}
@media (min-width: 1800px) {
section.quick-entry-page {
.ts-wrapper {
max-width: 450px;
}
#quick_entry_box {
.duration-widget {
.input-group {
max-width: 100%;
}
} }
} }
} }

View File

@@ -1,7 +1,11 @@
/* bootstrap thead.sticky-top has z-index 1020 and tomselect dropdowns often hide behind that */ /*
1. bootstrap thead.sticky-top has z-index 1020 and tomselect dropdowns often hide behind that: use 1021
2. bootstrap modal uses 1055, tomselect with dropdownParent: 'body' (required for use in table.responsive, e.g quick.entry on small devices) needs 1056, so it works in modals
*/
.ts-dropdown { .ts-dropdown {
z-index: 1021; z-index: 1056;
} }
/* Tabler defines a min-width of 7rem, what makes many input fields become a two-line field /* Tabler defines a min-width of 7rem, what makes many input fields become a two-line field

466
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -71,7 +71,7 @@ tabler:
duration: far fa-hourglass duration: far fa-hourglass
edit: far fa-edit edit: far fa-edit
end: fas fa-stopwatch end: fas fa-stopwatch
export: fas fa-file-export export: fas fa-download
failure: fas fa-times failure: fas fa-times
fax: fas fa-fax fax: fas fa-fax
filter: fas fa-filter filter: fas fa-filter

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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,10 +3,10 @@
"app": { "app": {
"js": [ "js": [
"/build/runtime.6c399d29.js", "/build/runtime.6c399d29.js",
"/build/app.d4ea12cd.js" "/build/app.3d366594.js"
], ],
"css": [ "css": [
"/build/app.6e7b2701.css" "/build/app.7ffe3b24.css"
] ]
}, },
"app-rtl": { "app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.7a875ca7.js" "/build/app-rtl.7a875ca7.js"
], ],
"css": [ "css": [
"/build/app-rtl.bfe8d111.css" "/build/app-rtl.4f157a57.css"
] ]
}, },
"export-pdf": { "export-pdf": {
@@ -72,10 +72,10 @@
}, },
"integrity": { "integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC", "/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.d4ea12cd.js": "sha384-vv5bzRvK0YN29MXeYe0uKv/LdRlwzMBu+RMvBXpNGu6btx1WxFmDOF6QM+CDoDPB", "/build/app.3d366594.js": "sha384-Wa5ZJDt7hVmlqWA8ixxZ/w/9vh/NJQ74KK3FqAsT/PPWNayIcg0N5XOv/9GfspBk",
"/build/app.6e7b2701.css": "sha384-rrrlE5ISpDF53P81i2ce7AKONdm+b+I3mFacFPiJUohuMbMuHnUINac7YDkKD0/p", "/build/app.7ffe3b24.css": "sha384-G4p41N3MjNkWRsaJkUKgGj1Ukd18qMGXHPOaDH6DqE8AzHAJPa1sGskswGP5zg+N",
"/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/", "/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/",
"/build/app-rtl.bfe8d111.css": "sha384-Ty9pGDTW2pxsBS7vfmjpTNFTJS/9jM5V4QfVgLXjIXuuJo5vi8x4sKKBTlwnZuqo", "/build/app-rtl.4f157a57.css": "sha384-kV+s8JUXnXpA6O/j8m7QCvqA5Qb7BK4kk3l1Ojs84OIrg4ztcvf3h6cBb+g/dMfw",
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+", "/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg", "/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY", "/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY",

View File

@@ -1,7 +1,7 @@
{ {
"build/app.css": "/build/app.6e7b2701.css", "build/app.css": "/build/app.7ffe3b24.css",
"build/app.js": "/build/app.d4ea12cd.js", "build/app.js": "/build/app.3d366594.js",
"build/app-rtl.css": "/build/app-rtl.bfe8d111.css", "build/app-rtl.css": "/build/app-rtl.4f157a57.css",
"build/app-rtl.js": "/build/app-rtl.7a875ca7.js", "build/app-rtl.js": "/build/app-rtl.7a875ca7.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css", "build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.395749ab.js", "build/export-pdf.js": "/build/export-pdf.395749ab.js",

View File

@@ -20,19 +20,16 @@ use Symfony\Component\Console\Style\SymfonyStyle;
abstract class AbstractRoleCommand extends Command abstract class AbstractRoleCommand extends Command
{ {
public function __construct(private UserService $userService) public function __construct(private readonly UserService $userService)
{ {
parent::__construct(); parent::__construct();
} }
protected function configure(): void protected function configure(): void
{ {
$this $this->addArgument('username', InputArgument::REQUIRED, 'The username');
->setDefinition([ $this->addArgument('role', InputArgument::OPTIONAL, 'The role');
new InputArgument('username', InputArgument::REQUIRED, 'The username'), $this->addOption('super', null, InputOption::VALUE_NONE, 'Instead specifying role, use this to quickly add the super administrator role');
new InputArgument('role', InputArgument::OPTIONAL, 'The role'),
new InputOption('super', null, InputOption::VALUE_NONE, 'Instead specifying role, use this to quickly add the super administrator role'),
]);
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int

View File

@@ -26,7 +26,7 @@ abstract class AbstractUserCommand extends Command
$passwordQuestion = new Question('Please enter the password: '); $passwordQuestion = new Question('Please enter the password: ');
$passwordQuestion->setHidden(true); $passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(false); $passwordQuestion->setHiddenFallback(true);
$passwordQuestion->setValidator(function (?string $value) { $passwordQuestion->setValidator(function (?string $value) {
$password = trim($value); $password = trim($value);
if (empty($password)) { if (empty($password)) {

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:activate')] #[AsCommand(name: 'kimai:user:activate', description: 'Activate a user')]
final class ActivateUserCommand extends Command final class ActivateUserCommand extends Command
{ {
public function __construct(private UserService $userService) public function __construct(private UserService $userService)
@@ -28,10 +28,7 @@ final class ActivateUserCommand extends Command
protected function configure(): void protected function configure(): void
{ {
$this $this
->setDescription('Activate a user') ->addArgument('username', InputArgument::REQUIRED, 'The username')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
])
->setHelp( ->setHelp(
<<<'EOT' <<<'EOT'
The <info>kimai:user:activate</info> command activates a user (so they will be able to log in): The <info>kimai:user:activate</info> command activates a user (so they will be able to log in):

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:password')] #[AsCommand(name: 'kimai:user:password', description: 'Change the password of a user')]
final class ChangePasswordCommand extends AbstractUserCommand final class ChangePasswordCommand extends AbstractUserCommand
{ {
public function __construct(private UserService $userService) public function __construct(private UserService $userService)
@@ -29,11 +29,8 @@ final class ChangePasswordCommand extends AbstractUserCommand
protected function configure(): void protected function configure(): void
{ {
$this $this
->setDescription('Change the password of a user.') ->addArgument('username', InputArgument::REQUIRED, 'The username')
->setDefinition([ ->addArgument('password', InputArgument::OPTIONAL, 'The password')
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
new InputArgument('password', InputArgument::OPTIONAL, 'The password'),
])
->setHelp( ->setHelp(
<<<'EOT' <<<'EOT'
The <info>kimai:user:password</info> command changes the password of a user: The <info>kimai:user:password</info> command changes the password of a user:

View File

@@ -20,7 +20,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:create')] #[AsCommand(name: 'kimai:user:create', description: 'Create a new user')]
final class CreateUserCommand extends AbstractUserCommand final class CreateUserCommand extends AbstractUserCommand
{ {
public function __construct(private UserService $userService) public function __construct(private UserService $userService)
@@ -33,7 +33,6 @@ final class CreateUserCommand extends AbstractUserCommand
$roles = implode(',', [User::DEFAULT_ROLE, User::ROLE_ADMIN]); $roles = implode(',', [User::DEFAULT_ROLE, User::ROLE_ADMIN]);
$this $this
->setDescription('Create a new user')
->setHelp('This command allows you to create a new user.') ->setHelp('This command allows you to create a new user.')
->addArgument('username', InputArgument::REQUIRED, 'A name for the new user (must be unique)') ->addArgument('username', InputArgument::REQUIRED, 'A name for the new user (must be unique)')
->addArgument('email', InputArgument::REQUIRED, 'Email address of the new user (must be unique)') ->addArgument('email', InputArgument::REQUIRED, 'Email address of the new user (must be unique)')

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:deactivate')] #[AsCommand(name: 'kimai:user:deactivate', description: 'Deactivate a user')]
final class DeactivateUserCommand extends Command final class DeactivateUserCommand extends Command
{ {
public function __construct(private UserService $userService) public function __construct(private UserService $userService)
@@ -28,10 +28,7 @@ final class DeactivateUserCommand extends Command
protected function configure(): void protected function configure(): void
{ {
$this $this
->setDescription('Deactivate a user') ->addArgument('username', InputArgument::REQUIRED, 'The username')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
])
->setHelp( ->setHelp(
<<<'EOT' <<<'EOT'
The <info>kimai:user:deactivate</info> command deactivates a user (will not be able to log in) The <info>kimai:user:deactivate</info> command deactivates a user (will not be able to log in)

View File

@@ -14,7 +14,7 @@ use App\User\UserService;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:demote')] #[AsCommand(name: 'kimai:user:demote', description: 'Demote a user by removing a role')]
final class DemoteUserCommand extends AbstractRoleCommand final class DemoteUserCommand extends AbstractRoleCommand
{ {
protected function configure(): void protected function configure(): void
@@ -22,7 +22,6 @@ final class DemoteUserCommand extends AbstractRoleCommand
parent::configure(); parent::configure();
$this $this
->setDescription('Demote a user by removing a role')
->setHelp( ->setHelp(
<<<'EOT' <<<'EOT'
The <info>kimai:user:demote</info> command demotes a user by removing a role The <info>kimai:user:demote</info> command demotes a user by removing a role

View File

@@ -31,7 +31,7 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Contracts\Translation\LocaleAwareInterface; use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
#[AsCommand(name: 'kimai:export:create')] #[AsCommand(name: 'kimai:export:create', description: 'Create exports')]
final class ExportCreateCommand extends Command final class ExportCreateCommand extends Command
{ {
public function __construct( public function __construct(
@@ -49,7 +49,6 @@ final class ExportCreateCommand extends Command
protected function configure(): void protected function configure(): void
{ {
$this $this
->setDescription('Create exports')
->setHelp('Create exports by several different filters and sent them via email.') ->setHelp('Create exports by several different filters and sent them via email.')
->addOption('username', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the export (e.g. used for permissions and decimal setting)') ->addOption('username', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the export (e.g. used for permissions and decimal setting)')
->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null) ->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null)

View File

@@ -34,7 +34,7 @@ use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
#[AsCommand(name: 'kimai:invoice:create')] #[AsCommand(name: 'kimai:invoice:create', description: 'Create invoices')]
final class InvoiceCreateCommand extends Command final class InvoiceCreateCommand extends Command
{ {
private ?string $previewDirectory = null; private ?string $previewDirectory = null;
@@ -54,7 +54,6 @@ final class InvoiceCreateCommand extends Command
protected function configure(): void protected function configure(): void
{ {
$this $this
->setDescription('Create invoices')
->setHelp('This command allows to create invoices by several different filters.') ->setHelp('This command allows to create invoices by several different filters.')
->addOption('user', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the invoices') ->addOption('user', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the invoices')
->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null) ->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null)

View File

@@ -16,7 +16,6 @@ use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Email;
@@ -31,7 +30,6 @@ final class MailTestCommand extends Command
protected function configure(): void protected function configure(): void
{ {
$this->addArgument('to', InputArgument::REQUIRED, 'The email address to send the email to'); $this->addArgument('to', InputArgument::REQUIRED, 'The email address to send the email to');
$this->addOption('from', null, InputOption::VALUE_OPTIONAL, 'Deprecated: uses the MAILER_FROM env variable.');
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
@@ -41,10 +39,6 @@ final class MailTestCommand extends Command
throw new \InvalidArgumentException('Need a non-empty "to" address'); throw new \InvalidArgumentException('Need a non-empty "to" address');
} }
if ($input->getOption('from') !== null) {
throw new \InvalidArgumentException('The "from" option is deprecated and will be ignored');
}
$message = new Email(); $message = new Email();
$message->to($to); $message->to($to);
$message->subject('Test email - ' . Constants::SOFTWARE); $message->subject('Test email - ' . Constants::SOFTWARE);

View File

@@ -14,7 +14,7 @@ use App\User\UserService;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:promote')] #[AsCommand(name: 'kimai:user:promote', description: 'Promotes a user by adding a role')]
final class PromoteUserCommand extends AbstractRoleCommand final class PromoteUserCommand extends AbstractRoleCommand
{ {
protected function configure(): void protected function configure(): void
@@ -22,7 +22,6 @@ final class PromoteUserCommand extends AbstractRoleCommand
parent::configure(); parent::configure();
$this $this
->setDescription('Promotes a user by adding a role')
->setHelp( ->setHelp(
<<<'EOT' <<<'EOT'
The <info>kimai:user:promote</info> command promotes a user by adding a role The <info>kimai:user:promote</info> command promotes a user by adding a role

View File

@@ -19,19 +19,14 @@ use Symfony\Component\Console\Style\SymfonyStyle;
/** /**
* @codeCoverageIgnore * @codeCoverageIgnore
*/ */
#[AsCommand(name: 'kimai:timesheet:stop-all')] #[AsCommand(name: 'kimai:timesheet:stop-all', description: 'Stop all running timesheets immediately')]
final class TimesheetStopAllCommand extends Command final class TimesheetStopAllCommand extends Command
{ {
public function __construct(private TimesheetService $timesheetService) public function __construct(private readonly TimesheetService $timesheetService)
{ {
parent::__construct(); parent::__construct();
} }
protected function configure(): void
{
$this->setDescription('Stop all running timesheets immediately');
}
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$amount = $this->timesheetService->stopAll(); $amount = $this->timesheetService->stopAll();

View File

@@ -25,7 +25,7 @@ use Symfony\Component\HttpClient\HttpClient;
* *
* @codeCoverageIgnore * @codeCoverageIgnore
*/ */
#[AsCommand(name: 'kimai:translations')] #[AsCommand(name: 'kimai:translations', description: 'Automated translation adjustments')]
final class TranslationCommand extends Command final class TranslationCommand extends Command
{ {
public function __construct( public function __construct(
@@ -40,7 +40,6 @@ final class TranslationCommand extends Command
protected function configure(): void protected function configure(): void
{ {
$this $this
->setDescription('Translation adjustments')
->addOption('resname', null, InputOption::VALUE_NONE, 'Fix the resname vs. id attribute') ->addOption('resname', null, InputOption::VALUE_NONE, 'Fix the resname vs. id attribute')
->addOption('duplicates', null, InputOption::VALUE_NONE, 'Find duplicate translation keys') ->addOption('duplicates', null, InputOption::VALUE_NONE, 'Find duplicate translation keys')
->addOption('delete-resname', null, InputOption::VALUE_REQUIRED, 'Deletes the translation by resname') ->addOption('delete-resname', null, InputOption::VALUE_REQUIRED, 'Deletes the translation by resname')

View File

@@ -17,17 +17,14 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:version')] #[AsCommand(name: 'kimai:version', description: 'Show version information')]
final class VersionCommand extends Command final class VersionCommand extends Command
{ {
protected function configure(): void protected function configure(): void
{ {
$this $this->setHelp('This command allows you to fetch various version information about Kimai.');
->setDescription('Receive version information') $this->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only');
->setHelp('This command allows you to fetch various version information about Kimai.') $this->addOption('number', null, InputOption::VALUE_NONE, 'Display the version identifier only only');
->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only')
->addOption('number', null, InputOption::VALUE_NONE, 'Display the version identifier only only')
;
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int

View File

@@ -458,6 +458,11 @@ final class SystemConfiguration
return (bool) $this->find('timesheet.rules.break_time_active'); return (bool) $this->find('timesheet.rules.break_time_active');
} }
public function getExportTimeout(): int
{
return (int) $this->find('export.timeout');
}
// ========== Company configurations ========== // ========== Company configurations ==========
public function getFinancialYearStart(): ?string public function getFinancialYearStart(): ?string

View File

@@ -17,11 +17,11 @@ final class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.36.1'; public const VERSION = '2.37.0';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 23601; public const VERSION_ID = 23700;
/** /**
* The software name * The software name
*/ */

View File

@@ -13,6 +13,7 @@ use App\Configuration\SamlConfigurationInterface;
use App\Saml\SamlAuthFactory; use App\Saml\SamlAuthFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
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\ServiceUnavailableHttpException; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
@@ -76,7 +77,7 @@ final class SamlController extends AbstractController
throw new \RuntimeException('SAML login failed'); throw new \RuntimeException('SAML login failed');
} }
return $this->redirect($url); return new RedirectResponse($url);
} }
#[Route(path: '/metadata', name: 'saml_metadata')] #[Route(path: '/metadata', name: 'saml_metadata')]

View File

@@ -9,6 +9,7 @@
namespace App\Controller; namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\ExportableItem; use App\Entity\ExportableItem;
use App\Entity\ExportTemplate; use App\Entity\ExportTemplate;
use App\Export\Base\DispositionInlineInterface; use App\Export\Base\DispositionInlineInterface;
@@ -114,7 +115,7 @@ final class ExportController extends AbstractController
} }
#[Route(path: '/data', name: 'export_data', methods: ['POST'])] #[Route(path: '/data', name: 'export_data', methods: ['POST'])]
public function export(Request $request): Response public function export(Request $request, SystemConfiguration $systemConfiguration): Response
{ {
$query = $this->getDefaultQuery(); $query = $this->getDefaultQuery();
@@ -132,6 +133,9 @@ final class ExportController extends AbstractController
throw $this->createNotFoundException('Unknown export renderer'); throw $this->createNotFoundException('Unknown export renderer');
} }
$oldMaxExecTime = \ini_get('max_execution_time');
ini_set('max_execution_time', $systemConfiguration->getExportTimeout());
// display file inline if supported and `markAsExported` is not set // display file inline if supported and `markAsExported` is not set
if ($renderer instanceof DispositionInlineInterface && !$query->isMarkAsExported()) { if ($renderer instanceof DispositionInlineInterface && !$query->isMarkAsExported()) {
$renderer->setDispositionInline(true); $renderer->setDispositionInline(true);
@@ -144,6 +148,8 @@ final class ExportController extends AbstractController
$this->export->setExported($entries); $this->export->setExported($entries);
} }
ini_set('max_execution_time', $oldMaxExecTime);
return $response; return $response;
} }

View File

@@ -41,11 +41,11 @@ use Symfony\Component\HttpFoundation\Response;
abstract class TimesheetAbstractController extends AbstractController abstract class TimesheetAbstractController extends AbstractController
{ {
public function __construct( public function __construct(
protected TimesheetRepository $repository, protected readonly TimesheetRepository $repository,
protected EventDispatcherInterface $dispatcher, protected readonly EventDispatcherInterface $dispatcher,
protected TimesheetService $service, protected readonly TimesheetService $service,
protected SystemConfiguration $configuration, protected readonly SystemConfiguration $configuration,
protected TagRepository $tagRepository protected readonly TagRepository $tagRepository
) { ) {
} }
@@ -263,7 +263,14 @@ abstract class TimesheetAbstractController extends AbstractController
$entries = $this->repository->getTimesheetResult($query); $entries = $this->repository->getTimesheetResult($query);
return $exporter->render($entries->getResults(), $query); $oldMaxExecTime = \ini_get('max_execution_time');
ini_set('max_execution_time', $this->configuration->getExportTimeout());
$response = $exporter->render($entries->getResults(), $query);
ini_set('max_execution_time', $oldMaxExecTime);
return $response;
} }
protected function multiUpdate(Request $request): Response protected function multiUpdate(Request $request): Response

View File

@@ -396,6 +396,10 @@ final class Configuration implements ConfigurationInterface
->scalarPrototype()->end() ->scalarPrototype()->end()
->defaultValue([]) ->defaultValue([])
->end() ->end()
->integerNode('timeout')
->defaultValue(60)
->end()
->end() ->end()
; ;

View File

@@ -49,7 +49,7 @@ class ExportTemplate
#[Assert\NotNull] #[Assert\NotNull]
private array $columns = []; private array $columns = [];
/** /**
* @var array<string, mixed> * @var array<string, int|string|null|bool>
*/ */
#[ORM\Column(name: 'options', type: Types::JSON, nullable: false)] #[ORM\Column(name: 'options', type: Types::JSON, nullable: false)]
#[Assert\NotNull] #[Assert\NotNull]
@@ -106,8 +106,22 @@ class ExportTemplate
$this->columns = $columns ?? []; $this->columns = $columns ?? [];
} }
public function getOption(string $key, int|string|bool $default): int|string|bool
{
if (\array_key_exists($key, $this->options)) {
return $this->options[$key] ?? $default;
}
return $default;
}
public function setOption(string $key, int|string|null|bool $value): void
{
$this->options[$key] = $value;
}
/** /**
* @return array<string, mixed> * @return array<string, int|string|null|bool>
*/ */
public function getOptions(): array public function getOptions(): array
{ {
@@ -115,13 +129,30 @@ class ExportTemplate
} }
/** /**
* @param array<string, mixed> $options * @param array<string, int|string|null|bool> $options
*/ */
public function setOptions(?array $options): void public function setOptions(?array $options): void
{ {
$this->options = $options ?? []; $this->options = $options ?? [];
} }
/**
* Only used for CSV export
*/
public function setSeparator(string $separator): void
{
if (!\in_array($separator, [',', ';'], true)) {
throw new \InvalidArgumentException('Invalid separator, comma and semicolon are allowed.');
}
$this->setOption('separator', $separator);
}
public function getSeparator(): string
{
return (string) $this->getOption('separator', ',');
}
public function __toString(): string public function __toString(): string
{ {
return $this->title ?? 'New'; return $this->title ?? 'New';

View File

@@ -914,6 +914,11 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return $this; return $this;
} }
/**
* Alias for setUserIdentifier()
*
* The visible username is setAlias())
*/
public function setUsername(string $username): void public function setUsername(string $username): void
{ {
$this->username = $username; $this->username = $username;

View File

@@ -13,7 +13,7 @@ use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event; use Symfony\Contracts\EventDispatcher\Event;
/** /**
* This event can be used, to dynamically add meta fields to invoices * This event can be used, to dynamically add meta-fields to invoices
*/ */
final class InvoiceMetaDefinitionEvent extends Event final class InvoiceMetaDefinitionEvent extends Event
{ {

View File

@@ -87,7 +87,12 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
$options = new Options(); $options = new Options();
$options->SHOULD_ADD_BOM = false; $options->SHOULD_ADD_BOM = false;
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->locale); $opts = $this->spreadsheetRenderer->getTemplate()->getOptions();
if (\array_key_exists('separator', $opts) && $opts['separator'] === ';') {
$options->FIELD_DELIMITER = ';';
}
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate()->getLocale());
$spreadsheet->open($filename); $spreadsheet->open($filename);
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter()); $this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());

View File

@@ -65,13 +65,14 @@ final class SpreadsheetRenderer
$this->template = $template; $this->template = $template;
} }
private function getTemplate(): TemplateInterface public function getTemplate(): TemplateInterface
{ {
if ($this->template === null) { if ($this->template === null) {
$template = new Template('default', 'default'); $template = new Template('default', 'default');
$template->setColumns($this->getDefaultColumns()); $template->setColumns($this->getDefaultColumns());
$template->setLocale('en');
return $template; $this->template = $template;
} }
return $this->template; return $this->template;
@@ -263,6 +264,8 @@ final class SpreadsheetRenderer
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM); $columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
} elseif ($column === 'user.name') { } elseif ($column === 'user.name') {
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM); $columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
} elseif ($column === 'user.email') {
$columns[] = (new Column('email', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getEmail())->withColumnWidth(ColumnWidth::MEDIUM);
} elseif ($column === 'user.account_number') { } elseif ($column === 'user.account_number') {
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber()); $columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
} elseif ($column === 'customer.name') { } elseif ($column === 'customer.name') {
@@ -336,6 +339,7 @@ final class SpreadsheetRenderer
'fixed_rate', 'fixed_rate',
'user.alias', 'user.alias',
'user.name', 'user.name',
'user.email',
'user.account_number', 'user.account_number',
'customer.name', 'customer.name',
'project.name', 'project.name',

View File

@@ -81,7 +81,7 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
throw new \Exception('Could not open temporary file'); throw new \Exception('Could not open temporary file');
} }
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale); $spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate()->getLocale());
$spreadsheet->open($filename); $spreadsheet->open($filename);
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query); $this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);

View File

@@ -85,6 +85,7 @@ final class ServiceExport
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type $tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
$tpl->setColumns($template->getColumns()); $tpl->setColumns($template->getColumns());
$tpl->setLocale($template->getLanguage()); $tpl->setLocale($template->getLanguage());
$tpl->setOptions($template->getOptions());
switch ($template->getRenderer()) { switch ($template->getRenderer()) {
case 'csv': case 'csv':

View File

@@ -14,6 +14,7 @@ use App\Form\Type\ExportColumnsType;
use App\Form\Type\ExportRendererType; use App\Form\Type\ExportRendererType;
use App\Form\Type\LanguageType; use App\Form\Type\LanguageType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -26,6 +27,7 @@ class ExportTemplateSpreadsheetForm extends AbstractType
$builder->add('renderer', ExportRendererType::class, ['label' => 'type']); $builder->add('renderer', ExportRendererType::class, ['label' => 'type']);
$builder->add('language', LanguageType::class, ['required' => false]); $builder->add('language', LanguageType::class, ['required' => false]);
$builder->add('columns', ExportColumnsType::class, ['required' => true]); $builder->add('columns', ExportColumnsType::class, ['required' => true]);
$builder->add('separator', ChoiceType::class, ['choices' => ['Comma (,)' => ',', 'Semicolon (;)' => ';'], 'required' => true]);
} }
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void

View File

@@ -61,7 +61,7 @@ final class QuickEntryTimesheetType extends AbstractType
} }
if ($data instanceof Timesheet && !$this->security->isGranted('edit', $data)) { if ($data instanceof Timesheet && !$this->security->isGranted('edit', $data)) {
$event->getForm()->remove('duration'); // do not call $event->getForm()->remove() this would change the field order
$event->getForm()->add('duration', DurationType::class, array_merge(['disabled' => true], $durationOptions)); $event->getForm()->add('duration', DurationType::class, array_merge(['disabled' => true], $durationOptions));
$mainForm = $event->getForm()->getParent()?->getParent(); $mainForm = $event->getForm()->getParent()?->getParent();
@@ -79,7 +79,7 @@ final class QuickEntryTimesheetType extends AbstractType
continue; continue;
} }
$type = \get_class($child->getConfig()->getType()->getInnerType()); $type = \get_class($child->getConfig()->getType()->getInnerType());
$mainForm->remove($key); // do not call $mainForm->remove() this would change the field order
$mainForm->add($key, $type, array_merge($child->getConfig()->getOptions(), ['disabled' => true])); $mainForm->add($key, $type, array_merge($child->getConfig()->getOptions(), ['disabled' => true]));
} }
} }

View File

@@ -147,7 +147,7 @@
{% if btnTitle == id %} {% if btnTitle == id %}
{% set btnTitle = title|trans %} {% set btnTitle = title|trans %}
{% endif %} {% endif %}
{% if btnTitle == id%} {% if btnTitle == id %}
{% set btnTitle = id|split('.')|first|replace({'-': ' ', '_': ' '})|split(' ')|map(t => t|capitalize)|join(' ') %} {% set btnTitle = id|split('.')|first|replace({'-': ' ', '_': ' '})|split(' ')|map(t => t|capitalize)|join(' ') %}
{% endif %} {% endif %}
<a href="#" class="dropdown-item startExportBtn" data-type="{{ id }}"> <a href="#" class="dropdown-item startExportBtn" data-type="{{ id }}">

View File

@@ -14,11 +14,30 @@
{{ form_row(form.title) }} {{ form_row(form.title) }}
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{{ form_row(form.renderer) }} {{ form_row(form.renderer, {attr: {onchange: "changeExportTemplateType(this)"}}) }}
</div> </div>
</div> </div>
{{ form_row(form.language) }} {{ form_row(form.language) }}
{{ form_row(form.separator, {row_attr: {class: 'mb-3 export-type csv'}}) }}
{{ form_row(form.columns) }}
{{ form_rest(form) }} {{ form_rest(form) }}
{% endblock %} {% endblock %}
{% block form_after %}
<script>
function changeExportTemplateType(typeElement)
{
typeElement.form.querySelectorAll('.export-type').forEach(
element => {
if (element.classList.contains(typeElement.value)) {
element.classList.remove('d-none');
} else {
element.classList.add('d-none');
}
}
);
}
changeExportTemplateType(document.getElementById('{{ form.renderer.vars.id }}'));
</script>
{% endblock %}
{% endembed %} {% endembed %}
{% endblock %} {% endblock %}

View File

@@ -338,7 +338,7 @@
{% endif %} {% endif %}
{% elseif 'DurationType' in type %} {% elseif 'DurationType' in type %}
{{ value|duration }} {{ value|duration }}
{% elseif 'YesNoType' in type or 'CheckBoxType' in type %} {% elseif 'YesNoType' in type or 'CheckboxType' in type %}
{{ _self.label_boolean(value) }} {{ _self.label_boolean(value) }}
{% elseif 'DatePickerType' in type %} {% elseif 'DatePickerType' in type %}
{{ value|date_short }} {{ value|date_short }}

View File

@@ -54,9 +54,9 @@
<td></td> <td></td>
{% endfor %} {% endfor %}
{% for id, week in days %} {% for id, week in days %}
<td class="text-center" id="qe-totals-day-{{ loop.index0 }}"></td> <td class="text-center total" id="qe-totals-day-{{ loop.index0 }}"></td>
{% endfor %} {% endfor %}
<td class="text-center" id="qe-totals-week"></td> <td class="text-center total" id="qe-totals-week"></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -66,25 +66,15 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script type="text/javascript"> <script>
document.addEventListener('kimai.initialized', function(event) { document.addEventListener('kimai.initialized', function(event) {
const kimai = event.detail.kimai; const kimai = event.detail.kimai;
const DATES = kimai.getPlugin('date'); const DATES = kimai.getPlugin('date');
/** @type {KimaiFormSelect} FORM_SELECT */ /** @type {KimaiFormSelect} FORM_SELECT */
const FORM_SELECT = kimai.getPlugin('form-select'); const FORM_SELECT = kimai.getPlugin('form-select');
if (window.matchMedia("(max-width: 1000px)").matches) {
for (let dropdowns of document.querySelectorAll('tr.qe-entry-week-row .btn-duration-preset')) {
dropdowns.style.display = 'none';
}
for (let tmp of document.querySelectorAll('tr.qe-entry-week-row .duration-widget .input-group')) { if (window.matchMedia("(max-width: 1360px)").matches) {
tmp.style.maxWidth = '50px';
tmp.style.minWidth = '50px';
}
for (let input of document.querySelectorAll('tr.qe-entry-week-row input.duration-input')) {
input.style.maxWidth = '50px';
}
document.querySelector('form[name=quick_entry_form] div.maybe-table-responsive').classList.replace('maybe-table-responsive', 'table-responsive'); document.querySelector('form[name=quick_entry_form] div.maybe-table-responsive').classList.replace('maybe-table-responsive', 'table-responsive');
} }
@@ -139,7 +129,6 @@
collectionHolder.appendChild(node); collectionHolder.appendChild(node);
[].slice.call(node.querySelectorAll('.selectpicker')).map((element) => { [].slice.call(node.querySelectorAll('.selectpicker')).map((element) => {
FORM_SELECT.activateSelectPickerByElement(element); FORM_SELECT.activateSelectPickerByElement(element);
}); });

View File

@@ -24,7 +24,7 @@
<div class="card-footer"> <div class="card-footer">
{% if user.totpAuthenticationEnabled %} {% if user.totpAuthenticationEnabled %}
{{ form_start(deactivate, {'attr': {'id': 'user_two_factor_form'}}) }} {{ form_start(deactivate, {'attr': {'id': 'user_two_factor_form'}}) }}
<input type="submit" value="{{ 'deactivate'|trans }}" class="btn btn-warning" id="user_two_factor_button" /> <input type="submit" value="{{ 'deactivate'|trans }}" class="btn btn-warning confirmation-link" id="user_two_factor_button" data-question="{{ 'deactivate'|trans }}?" />
{{ form_end(deactivate) }} {{ form_end(deactivate) }}
{% else %} {% else %}
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" id="user_two_factor_button" /> <input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" id="user_two_factor_button" />

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Command;
use App\Command\MailTestCommand;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @covers \App\Command\MailTestCommand
* @group integration
*/
class MailTestCommandTest extends KernelTestCase
{
protected Application $application;
protected function setUp(): void
{
parent::setUp();
$kernel = self::bootKernel();
$this->application = new Application($kernel);
$this->application->add(new MailTestCommand(
$this->createMock(EventDispatcherInterface::class)
));
}
public function testCommandName(): void
{
$command = $this->application->find('kimai:mail:test');
self::assertInstanceOf(MailTestCommand::class, $command);
}
}

View File

@@ -319,6 +319,7 @@ class ConfigurationTest extends TestCase
0 => 'var/export/', 0 => 'var/export/',
1 => 'templates/export/renderer/', 1 => 'templates/export/renderer/',
], ],
'timeout' => 60,
], ],
'calendar' => [ 'calendar' => [
'week_numbers' => true, 'week_numbers' => true,

View File

@@ -54,10 +54,28 @@ class ExportTemplateTest extends AbstractEntityTestCase
$sut->setOptions(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO']); $sut->setOptions(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO']);
self::assertEquals(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO'], $sut->getOptions()); self::assertEquals(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO'], $sut->getOptions());
$sut->setOption('foo', 4711);
$sut->setOption('empty', null);
$sut->setOption('bar', false);
$sut->setOption('hello', 'kimai');
self::assertEquals(['foo' => 4711, 'bar' => false, 'WORLD' => 'HELLO', 'empty' => null, 'hello' => 'kimai'], $sut->getOptions());
$sut->setOptions(null); $sut->setOptions(null);
self::assertEquals([], $sut->getOptions()); self::assertEquals([], $sut->getOptions());
} }
public function testSeparator(): void
{
$sut = new ExportTemplate();
self::assertEquals(',', $sut->getSeparator());
$sut->setSeparator(';');
self::assertEquals(';', $sut->getSeparator());
$sut->setSeparator(',');
self::assertEquals(',', $sut->getSeparator());
$this->expectException(\InvalidArgumentException::class);
$sut->setSeparator('.');
}
public function testClone(): void public function testClone(): void
{ {
$sut = new ExportTemplate(); $sut = new ExportTemplate();

View File

@@ -65,13 +65,13 @@ class CsvRendererTest extends AbstractRendererTestCase
{ {
$en = [ $en = [
'Date', 'From', 'To', 'Duration', 'Currency', 'Price', 'Internal price', 'Hourly price', 'Fixed price', 'Name', 'Date', 'From', 'To', 'Duration', 'Currency', 'Price', 'Internal price', 'Hourly price', 'Fixed price', 'Name',
'User', 'Staff number', 'Customer', 'Project', 'Activity', 'Description', 'Billable', 'Tags', 'User', 'E-mail', 'Staff number', 'Customer', 'Project', 'Activity', 'Description', 'Billable', 'Tags',
'Type', 'category', 'Account', 'Project number', 'VAT-ID', 'Order number', 'Type', 'category', 'Account', 'Project number', 'VAT-ID', 'Order number',
'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref',
]; ];
$de = [ $de = [
'Datum', 'Von', 'Bis', 'Dauer', 'Währung', 'Preis', 'Interner Preis', 'Preis pro Stunde', 'Festpreis', 'Name', 'Datum', 'Von', 'Bis', 'Dauer', 'Währung', 'Preis', 'Interner Preis', 'Preis pro Stunde', 'Festpreis', 'Name',
'Benutzer', 'Personalnummer', 'Kunde', 'Projekt', 'Tätigkeit', 'Beschreibung', 'Abrechenbar', 'Schlagworte', 'Benutzer', 'E-Mail', 'Personalnummer', 'Kunde', 'Projekt', 'Tätigkeit', 'Beschreibung', 'Abrechenbar', 'Schlagworte',
'Typ', 'category', 'Kundennummer', 'Projektnummer', 'Umsatzsteuer-ID', 'Bestellnummer', 'Typ', 'category', 'Kundennummer', 'Projektnummer', 'Umsatzsteuer-ID', 'Bestellnummer',
'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref',
]; ];
@@ -140,6 +140,7 @@ class CsvRendererTest extends AbstractRendererTestCase
'Kevin', 'Kevin',
'kevin', 'kevin',
'', '',
'',
'Customer Name', 'Customer Name',
'project name', 'project name',
'activity description', 'activity description',
@@ -175,6 +176,7 @@ class CsvRendererTest extends AbstractRendererTestCase
'niveK', 'niveK',
'nivek', 'nivek',
'', '',
'',
'Customer Name', 'Customer Name',
'project name', 'project name',
'activity description', 'activity description',
@@ -200,6 +202,6 @@ class CsvRendererTest extends AbstractRendererTestCase
self::assertEquals($expected, $all[5]); self::assertEquals($expected, $all[5]);
self::assertEquals($expected2, $all[6]); self::assertEquals($expected2, $all[6]);
self::assertEquals(\count($expected), \count($all[0])); self::assertEquals(\count($expected), \count($all[0]));
self::assertEquals('foo', $all[4][17]); self::assertEquals('foo', $all[4][18]);
} }
} }

View File

@@ -133,6 +133,7 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
'project.meta.project-foo2' => 'Working place', 'project.meta.project-foo2' => 'Working place',
'activity.meta.activity-foo' => 'Working place', 'activity.meta.activity-foo' => 'Working place',
'user.meta.mypref' => 'mypref', 'user.meta.mypref' => 'mypref',
'email' => 'email'
]]; ]];
$template = new Template('test', 'Testing'); $template = new Template('test', 'Testing');