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);
if (!formElement.disabled) {
formElement.value = this.getDateUtils().format(linkTarget.dataset.format, null);
// this should usually work
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();

View File

@@ -82,6 +82,8 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
// see App\Form\Type\TagsType::MAX_AMOUNT_SELECT
maxOptions: 500,
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 = {

View File

@@ -16,23 +16,151 @@ section.quick-entry-page {
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 {
display: table;
table-layout: fixed;
min-height: 36px;
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 */
#quick_entry_box {
.duration-widget {
.input-group {
min-width: 85px;
max-width: 50px;
min-width: 50px;
}
.btn-duration-preset {
padding: 7px 8px 7px 5px;
display: none;
padding: 7px 5px 7px 2px;
}
input {
padding: 7px 3px 7px 7px;
input.duration-input {
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 {
z-index: 1021;
z-index: 1056;
}
/* 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
edit: far fa-edit
end: fas fa-stopwatch
export: fas fa-file-export
export: fas fa-download
failure: fas fa-times
fax: fas fa-fax
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": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app.d4ea12cd.js"
"/build/app.3d366594.js"
],
"css": [
"/build/app.6e7b2701.css"
"/build/app.7ffe3b24.css"
]
},
"app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.7a875ca7.js"
],
"css": [
"/build/app-rtl.bfe8d111.css"
"/build/app-rtl.4f157a57.css"
]
},
"export-pdf": {
@@ -72,10 +72,10 @@
},
"integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.d4ea12cd.js": "sha384-vv5bzRvK0YN29MXeYe0uKv/LdRlwzMBu+RMvBXpNGu6btx1WxFmDOF6QM+CDoDPB",
"/build/app.6e7b2701.css": "sha384-rrrlE5ISpDF53P81i2ce7AKONdm+b+I3mFacFPiJUohuMbMuHnUINac7YDkKD0/p",
"/build/app.3d366594.js": "sha384-Wa5ZJDt7hVmlqWA8ixxZ/w/9vh/NJQ74KK3FqAsT/PPWNayIcg0N5XOv/9GfspBk",
"/build/app.7ffe3b24.css": "sha384-G4p41N3MjNkWRsaJkUKgGj1Ukd18qMGXHPOaDH6DqE8AzHAJPa1sGskswGP5zg+N",
"/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.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY",

View File

@@ -1,7 +1,7 @@
{
"build/app.css": "/build/app.6e7b2701.css",
"build/app.js": "/build/app.d4ea12cd.js",
"build/app-rtl.css": "/build/app-rtl.bfe8d111.css",
"build/app.css": "/build/app.7ffe3b24.css",
"build/app.js": "/build/app.3d366594.js",
"build/app-rtl.css": "/build/app-rtl.4f157a57.css",
"build/app-rtl.js": "/build/app-rtl.7a875ca7.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"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
{
public function __construct(private UserService $userService)
public function __construct(private readonly UserService $userService)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
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'),
]);
$this->addArgument('username', InputArgument::REQUIRED, 'The username');
$this->addArgument('role', InputArgument::OPTIONAL, 'The role');
$this->addOption('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

View File

@@ -26,7 +26,7 @@ abstract class AbstractUserCommand extends Command
$passwordQuestion = new Question('Please enter the password: ');
$passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(false);
$passwordQuestion->setHiddenFallback(true);
$passwordQuestion->setValidator(function (?string $value) {
$password = trim($value);
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\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:activate')]
#[AsCommand(name: 'kimai:user:activate', description: 'Activate a user')]
final class ActivateUserCommand extends Command
{
public function __construct(private UserService $userService)
@@ -28,10 +28,7 @@ final class ActivateUserCommand extends Command
protected function configure(): void
{
$this
->setDescription('Activate a user')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
])
->addArgument('username', InputArgument::REQUIRED, 'The username')
->setHelp(
<<<'EOT'
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\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:password')]
#[AsCommand(name: 'kimai:user:password', description: 'Change the password of a user')]
final class ChangePasswordCommand extends AbstractUserCommand
{
public function __construct(private UserService $userService)
@@ -29,11 +29,8 @@ final class ChangePasswordCommand extends AbstractUserCommand
protected function configure(): void
{
$this
->setDescription('Change the password of a user.')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
new InputArgument('password', InputArgument::OPTIONAL, 'The password'),
])
->addArgument('username', InputArgument::REQUIRED, 'The username')
->addArgument('password', InputArgument::OPTIONAL, 'The password')
->setHelp(
<<<'EOT'
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\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:create')]
#[AsCommand(name: 'kimai:user:create', description: 'Create a new user')]
final class CreateUserCommand extends AbstractUserCommand
{
public function __construct(private UserService $userService)
@@ -33,7 +33,6 @@ final class CreateUserCommand extends AbstractUserCommand
$roles = implode(',', [User::DEFAULT_ROLE, User::ROLE_ADMIN]);
$this
->setDescription('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('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\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:deactivate')]
#[AsCommand(name: 'kimai:user:deactivate', description: 'Deactivate a user')]
final class DeactivateUserCommand extends Command
{
public function __construct(private UserService $userService)
@@ -28,10 +28,7 @@ final class DeactivateUserCommand extends Command
protected function configure(): void
{
$this
->setDescription('Deactivate a user')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
])
->addArgument('username', InputArgument::REQUIRED, 'The username')
->setHelp(
<<<'EOT'
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\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
{
protected function configure(): void
@@ -22,7 +22,6 @@ final class DemoteUserCommand extends AbstractRoleCommand
parent::configure();
$this
->setDescription('Demote a user by removing a role')
->setHelp(
<<<'EOT'
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\TranslatorInterface;
#[AsCommand(name: 'kimai:export:create')]
#[AsCommand(name: 'kimai:export:create', description: 'Create exports')]
final class ExportCreateCommand extends Command
{
public function __construct(
@@ -49,7 +49,6 @@ final class ExportCreateCommand extends Command
protected function configure(): void
{
$this
->setDescription('Create exports')
->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('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\Response;
#[AsCommand(name: 'kimai:invoice:create')]
#[AsCommand(name: 'kimai:invoice:create', description: 'Create invoices')]
final class InvoiceCreateCommand extends Command
{
private ?string $previewDirectory = null;
@@ -54,7 +54,6 @@ final class InvoiceCreateCommand extends Command
protected function configure(): void
{
$this
->setDescription('Create invoices')
->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('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\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Mime\Email;
@@ -31,7 +30,6 @@ final class MailTestCommand extends Command
protected function configure(): void
{
$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
@@ -41,10 +39,6 @@ final class MailTestCommand extends Command
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->to($to);
$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\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
{
protected function configure(): void
@@ -22,7 +22,6 @@ final class PromoteUserCommand extends AbstractRoleCommand
parent::configure();
$this
->setDescription('Promotes a user by adding a role')
->setHelp(
<<<'EOT'
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
*/
#[AsCommand(name: 'kimai:timesheet:stop-all')]
#[AsCommand(name: 'kimai:timesheet:stop-all', description: 'Stop all running timesheets immediately')]
final class TimesheetStopAllCommand extends Command
{
public function __construct(private TimesheetService $timesheetService)
public function __construct(private readonly TimesheetService $timesheetService)
{
parent::__construct();
}
protected function configure(): void
{
$this->setDescription('Stop all running timesheets immediately');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$amount = $this->timesheetService->stopAll();

View File

@@ -25,7 +25,7 @@ use Symfony\Component\HttpClient\HttpClient;
*
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:translations')]
#[AsCommand(name: 'kimai:translations', description: 'Automated translation adjustments')]
final class TranslationCommand extends Command
{
public function __construct(
@@ -40,7 +40,6 @@ final class TranslationCommand extends Command
protected function configure(): void
{
$this
->setDescription('Translation adjustments')
->addOption('resname', null, InputOption::VALUE_NONE, 'Fix the resname vs. id attribute')
->addOption('duplicates', null, InputOption::VALUE_NONE, 'Find duplicate translation keys')
->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\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:version')]
#[AsCommand(name: 'kimai:version', description: 'Show version information')]
final class VersionCommand extends Command
{
protected function configure(): void
{
$this
->setDescription('Receive version information')
->setHelp('This command allows you to fetch various version information about Kimai.')
->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only')
->addOption('number', null, InputOption::VALUE_NONE, 'Display the version identifier only only')
;
$this->setHelp('This command allows you to fetch various version information about Kimai.');
$this->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only');
$this->addOption('number', null, InputOption::VALUE_NONE, 'Display the version identifier only only');
}
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');
}
public function getExportTimeout(): int
{
return (int) $this->find('export.timeout');
}
// ========== Company configurations ==========
public function getFinancialYearStart(): ?string

View File

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

View File

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

View File

@@ -9,6 +9,7 @@
namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\ExportableItem;
use App\Entity\ExportTemplate;
use App\Export\Base\DispositionInlineInterface;
@@ -114,7 +115,7 @@ final class ExportController extends AbstractController
}
#[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();
@@ -132,6 +133,9 @@ final class ExportController extends AbstractController
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
if ($renderer instanceof DispositionInlineInterface && !$query->isMarkAsExported()) {
$renderer->setDispositionInline(true);
@@ -144,6 +148,8 @@ final class ExportController extends AbstractController
$this->export->setExported($entries);
}
ini_set('max_execution_time', $oldMaxExecTime);
return $response;
}

View File

@@ -41,11 +41,11 @@ use Symfony\Component\HttpFoundation\Response;
abstract class TimesheetAbstractController extends AbstractController
{
public function __construct(
protected TimesheetRepository $repository,
protected EventDispatcherInterface $dispatcher,
protected TimesheetService $service,
protected SystemConfiguration $configuration,
protected TagRepository $tagRepository
protected readonly TimesheetRepository $repository,
protected readonly EventDispatcherInterface $dispatcher,
protected readonly TimesheetService $service,
protected readonly SystemConfiguration $configuration,
protected readonly TagRepository $tagRepository
) {
}
@@ -263,7 +263,14 @@ abstract class TimesheetAbstractController extends AbstractController
$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

View File

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

View File

@@ -49,7 +49,7 @@ class ExportTemplate
#[Assert\NotNull]
private array $columns = [];
/**
* @var array<string, mixed>
* @var array<string, int|string|null|bool>
*/
#[ORM\Column(name: 'options', type: Types::JSON, nullable: false)]
#[Assert\NotNull]
@@ -106,8 +106,22 @@ class ExportTemplate
$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
{
@@ -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
{
$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
{
return $this->title ?? 'New';

View File

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

View File

@@ -13,7 +13,7 @@ use App\Entity\Invoice;
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
{

View File

@@ -87,7 +87,12 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
$options = new Options();
$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);
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());

View File

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

View File

@@ -81,7 +81,7 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
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);
$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->setColumns($template->getColumns());
$tpl->setLocale($template->getLanguage());
$tpl->setOptions($template->getOptions());
switch ($template->getRenderer()) {
case 'csv':

View File

@@ -14,6 +14,7 @@ use App\Form\Type\ExportColumnsType;
use App\Form\Type\ExportRendererType;
use App\Form\Type\LanguageType;
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\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -26,6 +27,7 @@ class ExportTemplateSpreadsheetForm extends AbstractType
$builder->add('renderer', ExportRendererType::class, ['label' => 'type']);
$builder->add('language', LanguageType::class, ['required' => false]);
$builder->add('columns', ExportColumnsType::class, ['required' => true]);
$builder->add('separator', ChoiceType::class, ['choices' => ['Comma (,)' => ',', 'Semicolon (;)' => ';'], 'required' => true]);
}
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)) {
$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));
$mainForm = $event->getForm()->getParent()?->getParent();
@@ -79,7 +79,7 @@ final class QuickEntryTimesheetType extends AbstractType
continue;
}
$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]));
}
}

View File

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

View File

@@ -14,11 +14,30 @@
{{ form_row(form.title) }}
</div>
<div class="col-md-3">
{{ form_row(form.renderer) }}
{{ form_row(form.renderer, {attr: {onchange: "changeExportTemplateType(this)"}}) }}
</div>
</div>
{{ form_row(form.language) }}
{{ form_row(form.separator, {row_attr: {class: 'mb-3 export-type csv'}}) }}
{{ form_row(form.columns) }}
{{ form_rest(form) }}
{% 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 %}
{% endblock %}

View File

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

View File

@@ -54,9 +54,9 @@
<td></td>
{% endfor %}
{% 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 %}
<td class="text-center" id="qe-totals-week"></td>
<td class="text-center total" id="qe-totals-week"></td>
</tr>
</tfoot>
</table>
@@ -66,25 +66,15 @@
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
<script>
document.addEventListener('kimai.initialized', function(event) {
const kimai = event.detail.kimai;
const DATES = kimai.getPlugin('date');
/** @type {KimaiFormSelect} 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')) {
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';
}
if (window.matchMedia("(max-width: 1360px)").matches) {
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);
[].slice.call(node.querySelectorAll('.selectpicker')).map((element) => {
FORM_SELECT.activateSelectPickerByElement(element);
});

View File

@@ -24,7 +24,7 @@
<div class="card-footer">
{% if user.totpAuthenticationEnabled %}
{{ 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) }}
{% else %}
<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/',
1 => 'templates/export/renderer/',
],
'timeout' => 60,
],
'calendar' => [
'week_numbers' => true,

View File

@@ -54,10 +54,28 @@ class ExportTemplateTest extends AbstractEntityTestCase
$sut->setOptions(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO']);
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);
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
{
$sut = new ExportTemplate();

View File

@@ -65,13 +65,13 @@ class CsvRendererTest extends AbstractRendererTestCase
{
$en = [
'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',
'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref',
];
$de = [
'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',
'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref',
];
@@ -140,6 +140,7 @@ class CsvRendererTest extends AbstractRendererTestCase
'Kevin',
'kevin',
'',
'',
'Customer Name',
'project name',
'activity description',
@@ -175,6 +176,7 @@ class CsvRendererTest extends AbstractRendererTestCase
'niveK',
'nivek',
'',
'',
'Customer Name',
'project name',
'activity description',
@@ -200,6 +202,6 @@ class CsvRendererTest extends AbstractRendererTestCase
self::assertEquals($expected, $all[5]);
self::assertEquals($expected2, $all[6]);
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',
'activity.meta.activity-foo' => 'Working place',
'user.meta.mypref' => 'mypref',
'email' => 'email'
]];
$template = new Template('test', 'Testing');