Release 2.38.0 (#5563)

This commit is contained in:
Kevin Papst
2025-08-08 23:25:42 +02:00
committed by GitHub
parent f3890691ce
commit 04331420ae
88 changed files with 1319 additions and 697 deletions

View File

@@ -21,13 +21,6 @@ authentication via SAML/LDAP/Database, two-factor authentication (2FA) with TOTP
user/customer/project specific rates, advanced search & filtering, money and time budgets, advanced reporting, support for [plugins](https://www.kimai.org/store/) user/customer/project specific rates, advanced search & filtering, money and time budgets, advanced reporting, support for [plugins](https://www.kimai.org/store/)
and so much more. and so much more.
### Versions
There are two [versions](https://www.kimai.org/documentation/versions.html) of Kimai existing:
- [Version 2](https://github.com/kimai/kimai) — the current stable release (PHP 8.1+)
- [Version 1](https://github.com/kimai/kimai/tree/1.x) — do **NOT** use, EOL since mid of 2023 (PHP 7.4)
### Links ### Links
- [Home](https://www.kimai.org) — Kimai project homepage - [Home](https://www.kimai.org) — Kimai project homepage
@@ -36,7 +29,7 @@ There are two [versions](https://www.kimai.org/documentation/versions.html) of K
### Requirements ### Requirements
- PHP 8.1.3 minimum (support for PHP 8.2 and 8.3) - PHP 8.1.3 minimum (support for PHP 8.2, 8.3, 8.4)
- MariaDB or MySQL - MariaDB or MySQL
- A webserver and subdomain (subdirectory is not supported) - A webserver and subdomain (subdirectory is not supported)
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip` - PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`

5
assets/highlight.js Normal file
View File

@@ -0,0 +1,5 @@
require('highlight.js/styles/github-dark.css');
const hljs = require('highlight.js/lib/common');
global.hljs = hljs;

View File

@@ -202,8 +202,8 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
{ {
let formatted = time.trim(); let formatted = time.trim();
// replace dot with colon // replace invalid separators with colon
formatted = formatted.replace(/\./g, ':'); formatted = formatted.replace(/\.|;|,/g, ':');
// uppercase 12-hour format // uppercase 12-hour format
formatted = formatted.replace(/am/i, 'AM'); formatted = formatted.replace(/am/i, 'AM');
formatted = formatted.replace(/pm/i, 'PM'); formatted = formatted.replace(/pm/i, 'PM');

View File

@@ -49,6 +49,7 @@ import enGbLocale from '@fullcalendar/core/locales/en-gb';
import enUsLocale from '@fullcalendar/core/locales/en-gb'; import enUsLocale from '@fullcalendar/core/locales/en-gb';
import KimaiColor from './KimaiColor'; import KimaiColor from './KimaiColor';
import KimaiContextMenu from "./KimaiContextMenu"; import KimaiContextMenu from "./KimaiContextMenu";
import { DateTime } from 'luxon';
export default class KimaiCalendar { export default class KimaiCalendar {
@@ -266,6 +267,12 @@ export default class KimaiCalendar {
} }
}); });
}, },
// called after all events of one source were set, so this can
// and will be called multiple times before the calendar is initialized
eventsSet: (events) => {
this._renderDayAndWeekSum(this.getCalendar().getCurrentData().viewSpec.type, events);
}
}; };
// ============= DRAG & DROP ============= // ============= DRAG & DROP =============
@@ -687,4 +694,73 @@ export default class KimaiCalendar {
}); });
} }
/**
* @param {string} view
* @param {EventApi[]} events
* @private
*/
_renderDayAndWeekSum(view, events) {
if (view === 'dayGridMonth') {
// currently we do not display totals in month view
return;
}
/** @type {KimaiDateUtils} DATES */
const DATES = this.kimai.getPlugin('date');
const durations = {};
if (view === 'timeGridWeek') {
// make sure we have an entry for every day of the week, even days without timesheets
document.querySelectorAll(`th.fc-col-header-cell[data-date]`).forEach(cell => {
durations[cell.dataset.date] = 0;
});
}
events.forEach(item => {
const start = DateTime.fromJSDate(item.start);
const dateStr = start.toISODate();
if (!durations[dateStr]) {
durations[dateStr] = 0;
}
// absences or public holidays are all day
if (item.end !== null) {
const end = DateTime.fromJSDate(item.end);
const duration = end.diff(start, 'hours').as('seconds');
durations[dateStr] += duration;
}
});
const dailyTotals = document.querySelectorAll('.fc-dailytotal');
dailyTotals.forEach(element => element.remove());
for (const dateValue in durations) {
const durationValue = durations[dateValue];
if (view === 'timeGridWeek') { // this is the week view
const headerCells = document.querySelectorAll(`th.fc-col-header-cell[data-date="${dateValue}"]`);
headerCells.forEach(cell => {
const newElement = document.createElement('div');
newElement.classList.add('fc-dailytotal');
newElement.textContent = DATES.formatSeconds(durationValue);
cell.appendChild(newElement);
});
}
}
// this is the day view
if (view === 'timeGridDay') {
const dayEl = document.querySelector('th.fc-day');
const dayDate = dayEl.dataset.date;
const dayTotal = document.querySelectorAll('.fc-dailytotal');
dayTotal.forEach(element => element.remove());
const newElement = document.createElement('div');
newElement.classList.add('fc-dailytotal');
newElement.textContent = DATES.formatSeconds(durations[dayDate]);
dayEl.appendChild(newElement);
}
}
} }

View File

@@ -93,3 +93,19 @@ table.dataTable thead > tr > th.hw-min {
width: 30%; width: 30%;
} }
} }
.markdown {
blockquote {
p {
/* Parsedown wraps content in a <p> like <blockquote><p> */
margin: 0;
}
/* Tabler doesn't style blockquotes */
border-left: 15px var(--tblr-border-style) var(--tblr-border-color);
background-color: var(--tblr-bg-surface-secondary);
}
pre code.hljs {
padding: 0;
background-color: var(--tblr-bg-surface-dark);
}
}

1139
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,7 @@ tabler:
barcode: fas fa-barcode barcode: fas fa-barcode
bookmark: far fa-star bookmark: far fa-star
bookmarked: fas fa-star bookmarked: fas fa-star
break: fas fa-utensils
calendar: far fa-calendar-alt calendar: far fa-calendar-alt
cancel: fas fa-times cancel: fas fa-times
clock: far fa-clock clock: far fa-clock
@@ -68,9 +69,9 @@ tabler:
documentation: fas fa-book documentation: fas fa-book
dot: fas fa-circle dot: fas fa-circle
download: fas fa-download download: fas fa-download
duration: far fa-hourglass duration: fas fa-stopwatch
edit: far fa-edit edit: far fa-edit
end: fas fa-stopwatch end: fas fa-hourglass-end
export: fas fa-download export: fas fa-download
failure: fas fa-times failure: fas fa-times
fax: fas fa-fax fax: fas fa-fax

View File

@@ -45,6 +45,7 @@
"eslint": "^9", "eslint": "^9",
"globals": "^15", "globals": "^15",
"gridstack": "^7", "gridstack": "^7",
"highlight.js": "^11.11.1",
"litepicker": "^2", "litepicker": "^2",
"luxon": "^3", "luxon": "^3",
"sass": "^1", "sass": "^1",

View File

@@ -4164,29 +4164,14 @@ parameters:
path: src/Utils/PaginationTemplate.php path: src/Utils/PaginationTemplate.php
- -
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockHeader\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Utils\\\\Parsedown\\:\\:blockHeader\\(\\) has no return type specified\\.$#"
count: 1 count: 1
path: src/Utils/ParsedownExtension.php path: src/Utils/Parsedown.php
- -
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockHeader\\(\\) has parameter \\$Line with no type specified\\.$#" message: "#^Method App\\\\Utils\\\\Parsedown\\:\\:blockHeader\\(\\) has parameter \\$Line with no type specified\\.$#"
count: 1 count: 1
path: src/Utils/ParsedownExtension.php path: src/Utils/Parsedown.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockTable\\(\\) has no return type specified\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockTable\\(\\) has parameter \\$Block with no value type specified in iterable type array\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
-
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:blockTable\\(\\) has parameter \\$Line with no type specified\\.$#"
count: 1
path: src/Utils/ParsedownExtension.php
- -
message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:inlineUrl\\(\\) has parameter \\$Excerpt with no type specified\\.$#" message: "#^Method App\\\\Utils\\\\ParsedownExtension\\:\\:inlineUrl\\(\\) has parameter \\$Excerpt with no type specified\\.$#"
@@ -4201,7 +4186,7 @@ parameters:
- -
message: "#^Parameter \\#1 \\$str of function strtr expects string, string\\|null given\\.$#" message: "#^Parameter \\#1 \\$str of function strtr expects string, string\\|null given\\.$#"
count: 1 count: 1
path: src/Utils/ParsedownExtension.php path: src/Utils/Parsedown.php
- -
message: "#^Property App\\\\Utils\\\\ParsedownExtension\\:\\:\\$BlockTypes has no type specified\\.$#" message: "#^Property App\\\\Utils\\\\ParsedownExtension\\:\\:\\$BlockTypes has no type specified\\.$#"

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

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.3d366594.js" "/build/app.dd0a8e58.js"
], ],
"css": [ "css": [
"/build/app.7ffe3b24.css" "/build/app.8b6629a6.css"
] ]
}, },
"app-rtl": { "app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.7a875ca7.js" "/build/app-rtl.7a875ca7.js"
], ],
"css": [ "css": [
"/build/app-rtl.4f157a57.css" "/build/app-rtl.bf451276.css"
] ]
}, },
"export-pdf": { "export-pdf": {
@@ -54,7 +54,7 @@
"calendar": { "calendar": {
"js": [ "js": [
"/build/runtime.6c399d29.js", "/build/runtime.6c399d29.js",
"/build/calendar.7415ab21.js" "/build/calendar.f4379767.js"
], ],
"css": [ "css": [
"/build/calendar.d757753e.css" "/build/calendar.d757753e.css"
@@ -68,14 +68,23 @@
"css": [ "css": [
"/build/dashboard.b7129fa1.css" "/build/dashboard.b7129fa1.css"
] ]
},
"highlight": {
"js": [
"/build/runtime.6c399d29.js",
"/build/highlight.766d3929.js"
],
"css": [
"/build/highlight.98bf3927.css"
]
} }
}, },
"integrity": { "integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC", "/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.3d366594.js": "sha384-Wa5ZJDt7hVmlqWA8ixxZ/w/9vh/NJQ74KK3FqAsT/PPWNayIcg0N5XOv/9GfspBk", "/build/app.dd0a8e58.js": "sha384-k0DlCny0XdsJHHuyL8B5MNlZTl+s8ZvBFvjwD9xrPQhbZ7ryiTx72pHOi+vaEzB8",
"/build/app.7ffe3b24.css": "sha384-G4p41N3MjNkWRsaJkUKgGj1Ukd18qMGXHPOaDH6DqE8AzHAJPa1sGskswGP5zg+N", "/build/app.8b6629a6.css": "sha384-mgEEi5bDaaMfrI+/iEBBD2S2sQvjdRJhsYN89QlhqPeUhM39pcxiGx9WVVP31jls",
"/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/", "/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/",
"/build/app-rtl.4f157a57.css": "sha384-kV+s8JUXnXpA6O/j8m7QCvqA5Qb7BK4kk3l1Ojs84OIrg4ztcvf3h6cBb+g/dMfw", "/build/app-rtl.bf451276.css": "sha384-hWaFBRhgNGAmCoe30xfzWFG1qkmKSAXBjZj/GRXY61Gymsq3p2d9sbyC7M6EwumB",
"/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",
@@ -83,9 +92,11 @@
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz", "/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7", "/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.62631acc.js": "sha384-L4evSO0OZiQt+jTqfMR70M2Vid7Hl5YdPocC6syhSoBRavlf7Az9/Ldh4YJUuwAU", "/build/chart.62631acc.js": "sha384-L4evSO0OZiQt+jTqfMR70M2Vid7Hl5YdPocC6syhSoBRavlf7Az9/Ldh4YJUuwAU",
"/build/calendar.7415ab21.js": "sha384-+iYr7VnMIxSQMERlaruX2Ktp6gWmGSyCxfUAaeSDkoninXdUdiKBjlcZZwhHhZWU", "/build/calendar.f4379767.js": "sha384-fNnf3iqMaALA89ncvi3nHAlJ2vlI1gjuL1fqnktU+RKVKTrVFCGuDrlIurNHrggx",
"/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ", "/build/calendar.d757753e.css": "sha384-cTmQMgHYjd2gfObFWmEUph7qQLCyXaIkneSf+bQ2mqVmZwqOB+pJOm/UYTyTjALJ",
"/build/dashboard.632f98fb.js": "sha384-PlHarP53f8b+47VZvbQw3LURA2vODFf7UMwpnktvukrhswaHtx93cx2M1BtO56JH", "/build/dashboard.632f98fb.js": "sha384-PlHarP53f8b+47VZvbQw3LURA2vODFf7UMwpnktvukrhswaHtx93cx2M1BtO56JH",
"/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS" "/build/dashboard.b7129fa1.css": "sha384-2nn5hLA+3YedgHYBpge62S8Losj8aoPwK9Zk9EvN1xEYatvOUQ7H3rIR2UUJAGOS",
"/build/highlight.766d3929.js": "sha384-/4zEkS1Zk8Y4U14MbMEJPH8rOkSfJbSg3+ZQ+xdVW18TCHzqv0DQC5FXIPOW4aB3",
"/build/highlight.98bf3927.css": "sha384-YgweSwDwN0dI4DEmh478xYVw/TewJYvCO1QTbWHpaHFDPuLrZvYMR0Tc+QfFxVPE"
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#0d1117;color:#c9d1d9}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{background-color:#033a16;color:#aff5b4}.hljs-deletion{background-color:#67060c;color:#ffdcd7}

View File

@@ -1,7 +1,7 @@
{ {
"build/app.css": "/build/app.7ffe3b24.css", "build/app.css": "/build/app.8b6629a6.css",
"build/app.js": "/build/app.3d366594.js", "build/app.js": "/build/app.dd0a8e58.js",
"build/app-rtl.css": "/build/app-rtl.4f157a57.css", "build/app-rtl.css": "/build/app-rtl.bf451276.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",
@@ -11,9 +11,11 @@
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js", "build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
"build/chart.js": "/build/chart.62631acc.js", "build/chart.js": "/build/chart.62631acc.js",
"build/calendar.css": "/build/calendar.d757753e.css", "build/calendar.css": "/build/calendar.d757753e.css",
"build/calendar.js": "/build/calendar.7415ab21.js", "build/calendar.js": "/build/calendar.f4379767.js",
"build/dashboard.css": "/build/dashboard.b7129fa1.css", "build/dashboard.css": "/build/dashboard.b7129fa1.css",
"build/dashboard.js": "/build/dashboard.632f98fb.js", "build/dashboard.js": "/build/dashboard.632f98fb.js",
"build/highlight.css": "/build/highlight.98bf3927.css",
"build/highlight.js": "/build/highlight.766d3929.js",
"build/runtime.js": "/build/runtime.6c399d29.js", "build/runtime.js": "/build/runtime.6c399d29.js",
"build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.ad1782c7.ttf", "build/fonts/fa-solid-900.ttf": "/build/fonts/fa-solid-900.ad1782c7.ttf",
"build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.26b80c88.ttf", "build/fonts/fa-brands-400.ttf": "/build/fonts/fa-brands-400.26b80c88.ttf",

View File

@@ -20,7 +20,7 @@ use DateTimeImmutable;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
/** /**
* @final * @final

View File

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

View File

@@ -106,15 +106,15 @@ final class PermissionController extends AbstractController
$other = []; $other = [];
foreach ($event->getSections() as $section) { foreach ($event->getSections() as $section) {
$permissionSorted[$section->getTitle()] = []; $permissionSorted[$section->getTitle()] = []; // @phpstan-ignore method.deprecatedInterface
} }
foreach ($this->manager->getPermissions() as $permission) { foreach ($this->manager->getPermissions() as $permission) {
$found = false; $found = false;
foreach (array_reverse($event->getSections()) as $section) { foreach (array_reverse($event->getSections()) as $section) {
if ($section->filter($permission)) { if ($section->filter($permission)) { // @phpstan-ignore method.deprecatedInterface
$permissionSorted[$section->getTitle()][] = $permission; $permissionSorted[$section->getTitle()][] = $permission; // @phpstan-ignore method.deprecatedInterface
$found = true; $found = true;
break; break;
} }

View File

@@ -12,8 +12,8 @@ namespace App\Controller\Security;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Controller\AbstractController; use App\Controller\AbstractController;
use App\Entity\User; use App\Entity\User;
use App\Event\EmailEvent;
use App\Event\EmailPasswordResetEvent; use App\Event\EmailPasswordResetEvent;
use App\Event\UserEmailEvent;
use App\User\UserService; use App\User\UserService;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
@@ -112,7 +112,7 @@ final class PasswordResetController extends AbstractController
$this->eventDispatcher->dispatch($event); $this->eventDispatcher->dispatch($event);
// this will send the email // this will send the email
$this->eventDispatcher->dispatch(new EmailEvent($event->getEmail())); $this->eventDispatcher->dispatch(new UserEmailEvent($user, $event->getEmail()));
$user->markPasswordRequested(); $user->markPasswordRequested();
$user->setRequiresPasswordReset(true); $user->setRequiresPasswordReset(true);

View File

@@ -348,6 +348,10 @@ final class SystemConfigurationController extends AbstractController
->setConstraints([ ->setConstraints([
new GreaterThanOrEqual(['value' => 0]) new GreaterThanOrEqual(['value' => 0])
]), ]),
(new Configuration('timesheet.rules.break_time_active'))
->setLabel('break')
->setType(YesNoType::class)
->setOptions(['help' => 'Beta']),
]), ]),
(new SystemConfigurationModel('quick_entry')) (new SystemConfigurationModel('quick_entry'))
->setTranslation('quick_entry.title') ->setTranslation('quick_entry.title')

View File

@@ -21,7 +21,7 @@ use DateTimeInterface;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query; use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
/** /**
* @final * @final

View File

@@ -11,6 +11,10 @@ namespace App\Entity;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
/**
* @method getTags() array
* @method getBreak() int
*/
interface ExportableItem interface ExportableItem
{ {
public function getId(): ?int; public function getId(): ?int;
@@ -85,4 +89,18 @@ interface ExportableItem
* Example: "work" * Example: "work"
*/ */
public function getCategory(): string; public function getCategory(): string;
/*
* Returns all assigned tags.
* @TODO activate for 3.0
* @return Collection<Tag>
*/
//public function getTags(): array;
/*
* Returns the break duration.
* @TODO activate for 3.0
* @return Collection<Tag>
*/
//public function getBreak(): int;
} }

View File

@@ -75,7 +75,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'order_number', type: Types::TEXT, length: 50, nullable: true)] #[ORM\Column(name: 'order_number', type: Types::TEXT, length: 50, nullable: true)]
#[Assert\Length(max: 50)] #[Assert\Length(max: 50)]
#[Serializer\Expose] #[Serializer\Expose]
#[Serializer\Groups(['Project_Entity'])] #[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'orderNumber')] #[Exporter\Expose(label: 'orderNumber')]
private ?string $orderNumber = null; private ?string $orderNumber = null;
/** /**

View File

@@ -0,0 +1,25 @@
<?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\Event;
use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
class AbstractInvoiceEvent extends Event
{
public function __construct(private readonly Invoice $invoice)
{
}
public function getInvoice(): Invoice
{
return $this->invoice;
}
}

View File

@@ -9,9 +9,9 @@
namespace App\Event; namespace App\Event;
/** use App\Webhook\Attribute\AsWebhook;
* Triggered for activity instances, which were just saved.
*/ #[AsWebhook(name: 'activity.created', description: 'Triggered after a new activity was created', payload: 'object.getActivity()')]
final class ActivityCreatePostEvent extends AbstractActivityEvent final class ActivityCreatePostEvent extends AbstractActivityEvent
{ {
} }

View File

@@ -9,9 +9,9 @@
namespace App\Event; namespace App\Event;
/** use App\Webhook\Attribute\AsWebhook;
* Triggered right before a activity will be deleted.
*/ #[AsWebhook(name: 'activity.deleted', description: 'Triggered right before an activity will be deleted', payload: 'object.getActivity()')]
final class ActivityDeleteEvent extends AbstractActivityEvent final class ActivityDeleteEvent extends AbstractActivityEvent
{ {
} }

View File

@@ -9,9 +9,9 @@
namespace App\Event; namespace App\Event;
/** use App\Webhook\Attribute\AsWebhook;
* Triggered for activity instances, which were updated.
*/ #[AsWebhook(name: 'activity.updated', description: 'Triggered after an activity was updated', payload: 'object.getActivity()')]
final class ActivityUpdatePostEvent extends AbstractActivityEvent final class ActivityUpdatePostEvent extends AbstractActivityEvent
{ {
} }

View File

@@ -9,9 +9,9 @@
namespace App\Event; namespace App\Event;
/** use App\Webhook\Attribute\AsWebhook;
* Triggered for customer instances, which were just saved.
*/ #[AsWebhook(name: 'customer.created', description: 'Triggered after a customer was created', payload: 'object.getCustomer()')]
final class CustomerCreatePostEvent extends AbstractCustomerEvent final class CustomerCreatePostEvent extends AbstractCustomerEvent
{ {
} }

View File

@@ -9,9 +9,9 @@
namespace App\Event; namespace App\Event;
/** use App\Webhook\Attribute\AsWebhook;
* Triggered right before a customer will be deleted.
*/ #[AsWebhook(name: 'customer.deleted', description: 'Triggered right before a customer will be deleted', payload: 'object.getCustomer()')]
final class CustomerDeleteEvent extends AbstractCustomerEvent final class CustomerDeleteEvent extends AbstractCustomerEvent
{ {
} }

View File

@@ -9,9 +9,9 @@
namespace App\Event; namespace App\Event;
/** use App\Webhook\Attribute\AsWebhook;
* Triggered for customer instances, which were just updated.
*/ #[AsWebhook(name: 'customer.updated', description: 'Triggered after a customer was updated', payload: 'object.getCustomer()')]
final class CustomerUpdatePostEvent extends AbstractCustomerEvent final class CustomerUpdatePostEvent extends AbstractCustomerEvent
{ {
} }

View File

@@ -11,17 +11,14 @@ namespace App\Event;
use App\Entity\Invoice; use App\Entity\Invoice;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use Symfony\Contracts\EventDispatcher\Event; use App\Webhook\Attribute\AsWebhook;
final class InvoiceCreatedEvent extends Event #[AsWebhook(name: 'invoice.created', description: 'Triggered after an invoice was created', payload: 'object.getInvoice()')]
final class InvoiceCreatedEvent extends AbstractInvoiceEvent
{ {
public function __construct(private readonly Invoice $invoice, private readonly InvoiceModel $model) public function __construct(Invoice $invoice, private readonly InvoiceModel $model)
{ {
} parent::__construct($invoice);
public function getInvoice(): Invoice
{
return $this->invoice;
} }
public function getInvoiceModel(): InvoiceModel public function getInvoiceModel(): InvoiceModel

View File

@@ -9,17 +9,9 @@
namespace App\Event; namespace App\Event;
use App\Entity\Invoice; use App\Webhook\Attribute\AsWebhook;
use Symfony\Contracts\EventDispatcher\Event;
final class InvoiceDeleteEvent extends Event #[AsWebhook(name: 'invoice.deleted', description: 'Triggered after an invoice was deleted', payload: 'object.getInvoice()')]
final class InvoiceDeleteEvent extends AbstractInvoiceEvent
{ {
public function __construct(private Invoice $invoice)
{
}
public function getInvoice(): Invoice
{
return $this->invoice;
}
} }

View File

@@ -20,9 +20,9 @@ final class PermissionSectionsEvent extends Event
/** /**
* @var array<PermissionSectionInterface> * @var array<PermissionSectionInterface>
*/ */
private array $sections = []; private array $sections = []; // @phpstan-ignore property.deprecatedInterface
public function addSection(PermissionSectionInterface $section): PermissionSectionsEvent public function addSection(PermissionSectionInterface $section): PermissionSectionsEvent // @phpstan-ignore parameter.deprecatedInterface
{ {
$this->sections[] = $section; $this->sections[] = $section;
@@ -32,7 +32,7 @@ final class PermissionSectionsEvent extends Event
/** /**
* @return PermissionSectionInterface[] * @return PermissionSectionInterface[]
*/ */
public function getSections(): array public function getSections(): array // @phpstan-ignore return.deprecatedInterface
{ {
return $this->sections; return $this->sections;
} }

View File

@@ -9,9 +9,9 @@
namespace App\Event; namespace App\Event;
/** use App\Webhook\Attribute\AsWebhook;
* Triggered for project instances, which were just saved.
*/ #[AsWebhook(name: 'project.created', description: 'Triggered after a project was created', payload: 'object.getProject()')]
final class ProjectCreatePostEvent extends AbstractProjectEvent final class ProjectCreatePostEvent extends AbstractProjectEvent
{ {
} }

View File

@@ -9,9 +9,9 @@
namespace App\Event; namespace App\Event;
/** use App\Webhook\Attribute\AsWebhook;
* Triggered right before a project will be deleted.
*/ #[AsWebhook(name: 'project.deleted', description: 'Triggered right before a project will be deleted', payload: 'object.getProject()')]
final class ProjectDeleteEvent extends AbstractProjectEvent final class ProjectDeleteEvent extends AbstractProjectEvent
{ {
} }

View File

@@ -9,9 +9,9 @@
namespace App\Event; namespace App\Event;
/** use App\Webhook\Attribute\AsWebhook;
* Triggered for project instances, which were just updated.
*/ #[AsWebhook(name: 'project.updated', description: 'Triggered after a project was updated', payload: 'object.getProject()')]
final class ProjectUpdatePostEvent extends AbstractProjectEvent final class ProjectUpdatePostEvent extends AbstractProjectEvent
{ {
} }

View File

@@ -13,7 +13,7 @@ use App\Form\Model\SystemConfiguration;
use Symfony\Contracts\EventDispatcher\Event; use Symfony\Contracts\EventDispatcher\Event;
/** /**
* This event should be used, if system configurations should be changed/added dynamically. * Adjust system configurations dynamically.
*/ */
final class SystemConfigurationEvent extends Event final class SystemConfigurationEvent extends Event
{ {
@@ -32,10 +32,6 @@ final class SystemConfigurationEvent extends Event
return $this->configurations; return $this->configurations;
} }
/**
* @param SystemConfiguration $configuration
* @return SystemConfigurationEvent
*/
public function addConfiguration(SystemConfiguration $configuration): SystemConfigurationEvent public function addConfiguration(SystemConfiguration $configuration): SystemConfigurationEvent
{ {
$this->configurations[] = $configuration; $this->configurations[] = $configuration;

View File

@@ -9,6 +9,9 @@
namespace App\Event; namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'timesheet.created', description: 'Triggered after a timesheet was created', payload: 'object.getTimesheet()')]
final class TimesheetCreatePostEvent extends AbstractTimesheetEvent final class TimesheetCreatePostEvent extends AbstractTimesheetEvent
{ {
} }

View File

@@ -9,6 +9,9 @@
namespace App\Event; namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'timesheet.stopped', description: 'Triggered after a timesheet was stopped', payload: 'object.getTimesheet()')]
final class TimesheetStopPostEvent extends AbstractTimesheetEvent final class TimesheetStopPostEvent extends AbstractTimesheetEvent
{ {
} }

View File

@@ -9,6 +9,9 @@
namespace App\Event; namespace App\Event;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'timesheet.updated', description: 'Triggered after a timesheet was updated', payload: 'object.getTimesheet()')]
final class TimesheetUpdatePostEvent extends AbstractTimesheetEvent final class TimesheetUpdatePostEvent extends AbstractTimesheetEvent
{ {
} }

View File

@@ -9,9 +9,9 @@
namespace App\Event; namespace App\Event;
/** use App\Webhook\Attribute\AsWebhook;
* Triggered for new user instances, which were just saved.
*/ #[AsWebhook(name: 'user.created', description: 'Triggered after a user was created', payload: 'object.getUser()')]
final class UserCreatePostEvent extends AbstractUserEvent final class UserCreatePostEvent extends AbstractUserEvent
{ {
} }

View File

@@ -9,9 +9,9 @@
namespace App\Event; namespace App\Event;
/** use App\Webhook\Attribute\AsWebhook;
* Triggered for user instances which were just deleted.
*/ #[AsWebhook(name: 'user.deleted', description: 'Triggered after a user was deleted', payload: 'object.getUser()')]
final class UserDeletePostEvent extends UserDeleteEvent final class UserDeletePostEvent extends UserDeleteEvent
{ {
} }

View File

@@ -9,9 +9,9 @@
namespace App\Event; namespace App\Event;
/** use App\Webhook\Attribute\AsWebhook;
* Triggered for user instances, which were just updated.
*/ #[AsWebhook(name: 'user.updated', description: 'Triggered after a user was updated', payload: 'object.getUser()')]
final class UserUpdatePostEvent extends AbstractUserEvent final class UserUpdatePostEvent extends AbstractUserEvent
{ {
} }

View File

@@ -22,7 +22,10 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
*/ */
final class RedirectToLocaleSubscriber implements EventSubscriberInterface final class RedirectToLocaleSubscriber implements EventSubscriberInterface
{ {
public function __construct(private UrlGeneratorInterface $urlGenerator, private LocaleService $localeService) public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly LocaleService $localeService
)
{ {
} }

View File

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

View File

@@ -65,11 +65,11 @@ final class SpreadsheetRenderer
$this->template = $template; $this->template = $template;
} }
public function getTemplate(): TemplateInterface public function getTemplate(?TimesheetQuery $query = null): 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($query));
$template->setLocale('en'); $template->setLocale('en');
$this->template = $template; $this->template = $template;
@@ -231,7 +231,7 @@ final class SpreadsheetRenderer
} }
} }
$template = $this->getTemplate(); $template = $this->getTemplate($query);
$columns = []; $columns = [];
@@ -250,6 +250,15 @@ final class SpreadsheetRenderer
$columns[] = (new Column('duration', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL); $columns[] = (new Column('duration', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'duration_seconds') { } elseif ($column === 'duration_seconds') {
$columns[] = (new Column('duration', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL); $columns[] = (new Column('duration', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'break') {
// TODO remove method_exists with 3.0
$columns[] = (new Column('break', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
} elseif ($column === 'break_decimal') {
// TODO remove method_exists with 3.0
$columns[] = (new Column('break', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
} elseif ($column === 'break_seconds') {
// TODO remove method_exists with 3.0
$columns[] = (new Column('break', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType
} elseif ($column === 'currency' && $showRates) { } elseif ($column === 'currency' && $showRates) {
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL); $columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
} elseif ($column === 'rate' && $showRates) { } elseif ($column === 'rate' && $showRates) {
@@ -319,9 +328,9 @@ final class SpreadsheetRenderer
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
private function getDefaultColumns(): array private function getDefaultColumns(?TimesheetQuery $query = null): array
{ {
// @deprecated since 2.36 - will be removed with 3.0 // @deprecated from 2.36 - will be removed with 3.0
$durationFormatter = 'duration'; $durationFormatter = 'duration';
if (($user = $this->voter->getUser()) instanceof User) { if (($user = $this->voter->getUser()) instanceof User) {
$durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration'; $durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration';
@@ -355,7 +364,7 @@ final class SpreadsheetRenderer
'project.order_number', 'project.order_number',
]; ];
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) { foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query ?? new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) { if ($metaField->getName() !== null) {
$columns[] = 'timesheet.meta.' . $metaField->getName(); $columns[] = 'timesheet.meta.' . $metaField->getName();
} }

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 ?? $this->spreadsheetRenderer->getTemplate()->getLocale()); $spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate($query)->getLocale());
$spreadsheet->open($filename); $spreadsheet->open($filename);
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query); $this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);

View File

@@ -19,7 +19,7 @@ final class ProjectHelper
public const PATTERN_NAME = '{name}'; public const PATTERN_NAME = '{name}';
public const PATTERN_NUMBER = '{number}'; public const PATTERN_NUMBER = '{number}';
public const PATTERN_COMMENT = '{comment}'; public const PATTERN_COMMENT = '{comment}';
public const PATTERN_ORDERNUMBER = '{ordernumber}'; public const PATTERN_ORDERNUMBER = '{orderNumber}';
public const PATTERN_DATERANGE = '{daterange}'; public const PATTERN_DATERANGE = '{daterange}';
public const PATTERN_START = '{start}'; public const PATTERN_START = '{start}';
public const PATTERN_END = '{end}'; public const PATTERN_END = '{end}';
@@ -74,6 +74,7 @@ final class ProjectHelper
$name = str_replace(self::PATTERN_NUMBER, $project->getNumber() ?? '', $name); $name = str_replace(self::PATTERN_NUMBER, $project->getNumber() ?? '', $name);
$name = str_replace(self::PATTERN_COMMENT, $project->getComment() ?? '', $name); $name = str_replace(self::PATTERN_COMMENT, $project->getComment() ?? '', $name);
$name = str_replace(self::PATTERN_CUSTOMER, $project->getCustomer()?->getName() ?? '', $name); $name = str_replace(self::PATTERN_CUSTOMER, $project->getCustomer()?->getName() ?? '', $name);
$name = str_replace('{ordernumber}', self::PATTERN_ORDERNUMBER, $name); // this was a typo before Kimai 2.38, can be removed in the future
$name = str_replace(self::PATTERN_ORDERNUMBER, $project->getOrderNumber() ?? '', $name); $name = str_replace(self::PATTERN_ORDERNUMBER, $project->getOrderNumber() ?? '', $name);
if ($this->dateFormatter === null) { if ($this->dateFormatter === null) {

View File

@@ -329,7 +329,7 @@ class TimesheetEditForm extends AbstractType
$builder->add('duration', DurationType::class, $durationOptions); $builder->add('duration', DurationType::class, $durationOptions);
if ($this->systemConfiguration->isBreakTimeEnabled()) { if ($this->systemConfiguration->isBreakTimeEnabled()) {
$builder->add('break', DurationType::class, ['label' => 'break', 'required' => false]); $builder->add('break', DurationType::class, ['label' => 'break', 'required' => false, 'icon' => 'break']);
} }
$builder->addEventListener( $builder->addEventListener(
@@ -392,7 +392,14 @@ class TimesheetEditForm extends AbstractType
return; return;
} }
$builder->add('user', UserType::class); $users = [];
if (isset($options['data']) && $options['data'] instanceof Timesheet) {
$users = [$options['data']->getUser()];
}
$builder->add('user', UserType::class, [
'include_users' => $users,
]);
} }
protected function addExported(FormBuilderInterface $builder, array $options): void protected function addExported(FormBuilderInterface $builder, array $options): void

View File

@@ -32,7 +32,7 @@ final class DurationType extends AbstractType
'preset_minutes' => null, 'preset_minutes' => null,
'toggle' => false, 'toggle' => false,
'max_hours' => 24, 'max_hours' => 24,
'icon' => 'clock', 'icon' => 'duration',
'documentation' => [ 'documentation' => [
'type' => 'string', 'type' => 'string',
'description' => 'Duration - supports various formats: https://www.kimai.org/documentation/duration-format.html', 'description' => 'Duration - supports various formats: https://www.kimai.org/documentation/duration-format.html',

View File

@@ -9,6 +9,7 @@
namespace App\Form\Type; namespace App\Form\Type;
use App\Configuration\SystemConfiguration;
use App\Entity\MetaTableTypeInterface; use App\Entity\MetaTableTypeInterface;
use App\Event\ActivityMetaDisplayEvent; use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent; use App\Event\CustomerMetaDisplayEvent;
@@ -39,6 +40,7 @@ final class ExportColumnsType extends AbstractType
public function __construct( public function __construct(
private readonly EventDispatcherInterface $dispatcher, private readonly EventDispatcherInterface $dispatcher,
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,
private readonly SystemConfiguration $configuration,
) )
{ {
} }
@@ -86,6 +88,17 @@ final class ExportColumnsType extends AbstractType
], ],
]; ];
if ($this->configuration->isBreakTimeEnabled()) {
$tmp = [
$this->translator->trans('break') . ' (0:30)' => 'break',
$this->translator->trans('break') . ' (0:30:00)' => 'break_seconds',
$this->translator->trans('break') . ' (0.5)' => 'break_decimal',
];
foreach ($tmp as $k => $v) {
$columns['timesheet'][$k] = $v;
}
}
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) { foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) { if ($metaField->getName() !== null) {
$columns['timesheet'][$metaField->getLabel()] = 'timesheet.meta.' . $metaField->getName(); $columns['timesheet'][$metaField->getLabel()] = 'timesheet.meta.' . $metaField->getName();

View File

@@ -9,7 +9,10 @@
namespace App\Model; namespace App\Model;
class PermissionSection implements PermissionSectionInterface /**
* @final with 3.0
*/
class PermissionSection implements PermissionSectionInterface // @phpstan-ignore class.implementsDeprecatedInterface
{ {
/** @var array<string> */ /** @var array<string> */
private array $filter; private array $filter;
@@ -17,7 +20,7 @@ class PermissionSection implements PermissionSectionInterface
/** /**
* @param string|array<string> $filter * @param string|array<string> $filter
*/ */
public function __construct(private string $title, string|array $filter) public function __construct(private readonly string $title, string|array $filter)
{ {
if (!\is_array($filter)) { if (!\is_array($filter)) {
$filter = [$filter]; $filter = [$filter];

View File

@@ -9,6 +9,9 @@
namespace App\Model; namespace App\Model;
/**
* @deprecated since 2.38 - use PermissionSection directly
*/
interface PermissionSectionInterface interface PermissionSectionInterface
{ {
/** /**

View File

@@ -18,7 +18,7 @@ class UserQuery extends BaseQuery implements VisibilityInterface
{ {
use VisibilityTrait; use VisibilityTrait;
public const USER_ORDER_ALLOWED = ['username', 'alias', 'title', 'email', 'systemAccount']; public const USER_ORDER_ALLOWED = ['username', 'alias', 'title', 'email', 'system_account' => 'systemAccount'];
private ?string $role = null; private ?string $role = null;
/** /**

View File

@@ -17,7 +17,7 @@ use App\Event\ThemeEvent;
use App\Event\ThemeJavascriptTranslationsEvent; use App\Event\ThemeJavascriptTranslationsEvent;
use App\Utils\Color; use App\Utils\Color;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment; use Twig\Environment;
use Twig\Extension\RuntimeExtensionInterface; use Twig\Extension\RuntimeExtensionInterface;
@@ -40,12 +40,8 @@ final class ThemeExtension implements RuntimeExtensionInterface
{ {
/** @var User $user */ /** @var User $user */
$user = $this->security->getUser(); $user = $this->security->getUser();
$themeEvent = new ThemeEvent($user, $payload); $themeEvent = new ThemeEvent($user, $payload);
if ($this->eventDispatcher->hasListeners($eventName)) {
$this->eventDispatcher->dispatch($themeEvent, $eventName); $this->eventDispatcher->dispatch($themeEvent, $eventName);
}
return $themeEvent; return $themeEvent;
} }
@@ -53,10 +49,7 @@ final class ThemeExtension implements RuntimeExtensionInterface
public function actions(User $user, string $action, string $view, array $payload = []): ThemeEvent public function actions(User $user, string $action, string $view, array $payload = []): ThemeEvent
{ {
$themeEvent = new PageActionsEvent($user, $payload, $action, $view); $themeEvent = new PageActionsEvent($user, $payload, $action, $view);
if ($this->eventDispatcher->hasListeners($themeEvent->getEventName())) {
$this->eventDispatcher->dispatch($themeEvent, $themeEvent->getEventName()); $this->eventDispatcher->dispatch($themeEvent, $themeEvent->getEventName());
}
return $themeEvent; return $themeEvent;
} }

View File

@@ -46,7 +46,7 @@ final class RuntimeExtensions extends AbstractExtension
public function getFilters(): array public function getFilters(): array
{ {
return [ return [
new TwigFilter('md2html', [MarkdownExtension::class, 'markdownToHtml'], ['pre_escape' => 'html', 'is_safe' => ['html']]), new TwigFilter('md2html', [MarkdownExtension::class, 'markdownToHtml'], ['is_safe' => ['html']]),
new TwigFilter('desc2html', [MarkdownExtension::class, 'timesheetContent'], ['is_safe' => ['html']]), new TwigFilter('desc2html', [MarkdownExtension::class, 'timesheetContent'], ['is_safe' => ['html']]),
new TwigFilter('comment2html', [MarkdownExtension::class, 'commentContent'], ['is_safe' => ['html']]), new TwigFilter('comment2html', [MarkdownExtension::class, 'commentContent'], ['is_safe' => ['html']]),
new TwigFilter('comment1line', [MarkdownExtension::class, 'commentOneLiner'], ['pre_escape' => 'html', 'is_safe' => ['html']]), new TwigFilter('comment1line', [MarkdownExtension::class, 'commentOneLiner'], ['pre_escape' => 'html', 'is_safe' => ['html']]),

View File

@@ -33,7 +33,7 @@ final class Markdown
public function withFullMarkdownSupport(string $text): string public function withFullMarkdownSupport(string $text): string
{ {
if ($this->parserFull === null) { if ($this->parserFull === null) {
$this->parserFull = new \Parsedown(); $this->parserFull = new Parsedown();
$this->parserFull->setUrlsLinked(true); $this->parserFull->setUrlsLinked(true);
$this->parserFull->setBreaksEnabled(true); $this->parserFull->setBreaksEnabled(true);
$this->parserFull->setSafeMode(true); $this->parserFull->setSafeMode(true);

82
src/Utils/Parsedown.php Normal file
View File

@@ -0,0 +1,82 @@
<?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\Utils;
/**
* This Class extends the default Parsedown Class for custom methods.
*/
class Parsedown extends \Parsedown
{
/** @var array<string> */
private array $ids = [];
protected function blockHeader($Line)
{
$block = parent::blockHeader($Line);
$text = $block['element']['text'];
$id = $this->getIDfromText($text);
// add id-attribute
$block['element']['attributes'] = [
'id' => $id
];
return $block;
}
/**
* github-action for creating ids:
*
* - It downcases the header string
* - Removes anything that is not a letter, number, space or hyphen
* - Changes any space to a hyphen
* - If that is not unique, add "-1", "-2", "-3",... to make it unique
* #
* @param non-empty-string $text
* @return string
*/
private function getIDfromText(string $text): string
{
$text = strtolower($text);
$text = preg_replace('/[^A-Za-z0-9\-\ ]/', '', $text);
$text = strtr($text, [' ' => '-']);
if (isset($this->ids[$text])) {
$i = 0;
$numberedText = $text . '-1';
while (isset($this->ids[$numberedText])) {
$i++;
$numberedText = $text . '-' . $i;
}
$text = $numberedText;
}
$this->ids[$text] = '';
return $text;
}
protected function blockTable($Line, array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
{
$Block = parent::blockTable($Line, $Block);
if ($Block === null) {
return null;
}
$Block['element']['attributes']['class'] = 'table table-striped table-vcenter';
return $Block;
}
}

View File

@@ -12,13 +12,10 @@ namespace App\Utils;
/** /**
* This Class extends the default Parsedown Class for custom methods. * This Class extends the default Parsedown Class for custom methods.
*/ */
final class ParsedownExtension extends \Parsedown final class ParsedownExtension extends Parsedown
{ {
/** @var array<string> */
private array $ids = [];
/** /**
* Overwritten to prevent # to show up as headings for two reasons: * Overwritten to prevent # and = to show up as headings for two reasons:
* - Hashes are often used to cross-link issues in other systems * - Hashes are often used to cross-link issues in other systems
* - Headings should not occur in time record listings * - Headings should not occur in time record listings
*/ */
@@ -98,57 +95,7 @@ final class ParsedownExtension extends \Parsedown
return null; return null;
} }
protected function blockHeader($Line) protected function blockTable($Line, array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
{
$block = parent::blockHeader($Line);
$text = $block['element']['text'];
$id = $this->getIDfromText($text);
// add id-attribute
$block['element']['attributes'] = [
'id' => $id
];
return $block;
}
/**
* github-action for creating ids:
*
* - It downcases the header string
* - remove anything that is not a letter, number, space or hyphen
* - changes any space to a hyphen.
* - If that is not unique, add "-1", "-2", "-3",... to make it unique
*
* @param string $text
* @return string
*/
private function getIDfromText($text): string
{
$text = strtolower($text);
$text = preg_replace('/[^A-Za-z0-9\-\ ]/', '', $text);
$text = strtr($text, [' ' => '-']);
if (isset($this->ids[$text])) {
$i = 0;
$numberedText = $text . '-1';
while (isset($this->ids[$numberedText])) {
$i++;
$numberedText = $text . '-' . $i;
}
$text = $numberedText;
}
$this->ids[$text] = '';
return $text;
}
protected function blockTable($Line, array $Block = null)
{ {
$Block = parent::blockTable($Line, $Block); $Block = parent::blockTable($Line, $Block);

View File

@@ -0,0 +1,18 @@
<?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\Webhook\Attribute;
#[\Attribute(\Attribute::TARGET_CLASS)]
final class AsWebhook
{
public function __construct(public string $name, public string $description, public string $payload)
{
}
}

View File

@@ -59,8 +59,8 @@ final class YearPerUserSummary implements \Countable, \IteratorAggregate
public function getExpectedTime(): int public function getExpectedTime(): int
{ {
$all = 0; $all = 0;
foreach ($this->getSummaries() as $month) { foreach ($this->getSummaries() as $year) {
$all += $month->getExpectedTime(); $all += $year->getExpectedTime();
} }
return $all; return $all;
@@ -69,8 +69,8 @@ final class YearPerUserSummary implements \Countable, \IteratorAggregate
public function getActualTime(): int public function getActualTime(): int
{ {
$all = 0; $all = 0;
foreach ($this->getSummaries() as $month) { foreach ($this->getSummaries() as $year) {
$all += $month->getActualTime(); $all += $year->getActualTime();
} }
return $all; return $all;

View File

@@ -19,6 +19,8 @@
{% set columns = { {% set columns = {
'avatar': {'class': 'text-nowrap w-avatar d-none d-md-table-cell', 'title': false, 'orderBy': false}, 'avatar': {'class': 'text-nowrap w-avatar d-none d-md-table-cell', 'title': false, 'orderBy': false},
'date': {'class': 'alwaysVisible text-nowrap', 'orderBy': false}, 'date': {'class': 'alwaysVisible text-nowrap', 'orderBy': false},
'begin': {'class': 'd-none', 'orderBy': false},
'end': {'class': 'd-none', 'orderBy': false},
'user': {'class': 'd-none', 'orderBy': false}, 'user': {'class': 'd-none', 'orderBy': false},
'project': {'class': 'd-none d-sm-table-cell', 'orderBy': false}, 'project': {'class': 'd-none d-sm-table-cell', 'orderBy': false},
'activity': {'class': 'd-none', 'orderBy': false}, 'activity': {'class': 'd-none', 'orderBy': false},
@@ -234,6 +236,16 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">
{{ entry.begin|date_short }} {{ entry.begin|date_short }}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'begin') }}">
{{ entry.begin|time }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'begin') }}">
{% if entry.end %}
{{ entry.end|time }}
{% else %}
&dash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">
{{ widgets.label_user(entry.user) }} {{ widgets.label_user(entry.user) }}
</td> </td>

View File

@@ -154,7 +154,7 @@
{%- set attr = attr|merge({'pattern': time_format|pattern, 'autocomplete': 'off', 'data-timepicker': 'on', 'data-format': js_format, 'placeholder': time_format}) -%} {%- set attr = attr|merge({'pattern': time_format|pattern, 'autocomplete': 'off', 'data-timepicker': 'on', 'data-format': js_format, 'placeholder': time_format}) -%}
<div class="input-group"> <div class="input-group">
<div class="input-group-text"> <div class="input-group-text">
<a href="#" data-form-widget="date-now" data-format="{{ js_format }}" data-target="{{ id }}">{{ icon('calendar') }}</a> <a href="#" data-form-widget="date-now" data-format="{{ js_format }}" data-target="{{ id }}">{{ icon('clock') }}</a>
</div> </div>
{{ block('form_widget_simple') }} {{ block('form_widget_simple') }}
{% set time_presets = form_time_presets(app.user.timezone) %} {% set time_presets = form_time_presets(app.user.timezone) %}

View File

@@ -59,7 +59,6 @@
{% endfor %} {% endfor %}
</div> </div>
{% set tableName = 'user_admin_permissions' %} {% set tableName = 'user_admin_permissions' %}
{{ tables.datatable_header(tableName, columns, null, {'reload': 'kimai.userRoleUpdate'}) }} {{ tables.datatable_header(tableName, columns, null, {'reload': 'kimai.userRoleUpdate'}) }}

View File

@@ -487,6 +487,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'color' => '@string', 'color' => '@string',
'customer' => 'int', 'customer' => 'int',
'number' => '@int', 'number' => '@int',
'orderNumber' => '@string',
'globalActivities' => 'bool', 'globalActivities' => 'bool',
'comment' => '@string', 'comment' => '@string',
]; ];
@@ -501,6 +502,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'color' => '@string', 'color' => '@string',
'customer' => ['result' => 'object', 'type' => 'Customer'], 'customer' => ['result' => 'object', 'type' => 'Customer'],
'number' => '@int', 'number' => '@int',
'orderNumber' => '@string',
'globalActivities' => 'bool', 'globalActivities' => 'bool',
'comment' => '@string', 'comment' => '@string',
]; ];
@@ -514,6 +516,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool', 'billable' => 'bool',
'customer' => 'int', 'customer' => 'int',
'number' => '@int', 'number' => '@int',
'orderNumber' => '@string',
'color' => '@string', 'color' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'], 'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'parentTitle' => 'string', 'parentTitle' => 'string',

View File

@@ -100,7 +100,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
$titles[] = trim($th->textContent); $titles[] = trim($th->textContent);
} }
self::assertEquals([ self::assertEquals([
'', 'Date', 'User', 'Project', 'Activity', 'Description', 'Tags', 'Duration', 'Unit price', 'Internal price', 'Total price', '', '', 'Date', 'From', 'To', 'User', 'Project', 'Activity', 'Description', 'Tags', 'Duration', 'Unit price', 'Internal price', 'Total price', '',
], $titles); ], $titles);
// assert export type buttons are available // assert export type buttons are available

View File

@@ -49,7 +49,10 @@ class UserTest extends TestCase
self::assertFalse($user->canSeeAllData()); self::assertFalse($user->canSeeAllData());
self::assertFalse($user->isExportDecimal()); self::assertFalse($user->isExportDecimal());
self::assertFalse($user->isSystemAccount()); self::assertFalse($user->isSystemAccount());
self::assertFalse($user->isPasswordRequestNonExpired(-1));
self::assertFalse($user->isPasswordRequestNonExpired(0));
self::assertFalse($user->isPasswordRequestNonExpired(3599)); self::assertFalse($user->isPasswordRequestNonExpired(3599));
self::assertFalse($user->isPasswordRequestNonExpired(PHP_INT_MAX));
$user->setUserIdentifier('foo'); $user->setUserIdentifier('foo');
self::assertEquals('foo', $user->getUserIdentifier()); self::assertEquals('foo', $user->getUserIdentifier());
@@ -675,4 +678,21 @@ class UserTest extends TestCase
self::assertInstanceOf(\DateTime::class, $lastLogin); self::assertInstanceOf(\DateTime::class, $lastLogin);
self::assertEquals('Europe/Berlin', $lastLogin->getTimezone()->getName()); self::assertEquals('Europe/Berlin', $lastLogin->getTimezone()->getName());
} }
public function testIsPasswordRequestNonExpiredIsTimezoneIndependent(): void
{
$user = new User();
$user->setTimezone('Europe/Vienna');
$user->markPasswordRequested();
self::assertTrue($user->isPasswordRequestNonExpired(3600));
self::assertTrue($user->isPasswordRequestNonExpired(7200));
$before = date_default_timezone_get();
date_default_timezone_set('America/Los_Angeles');
date_default_timezone_set($before);
self::assertTrue($user->isPasswordRequestNonExpired(3600));
self::assertTrue($user->isPasswordRequestNonExpired(7200));
}
} }

View File

@@ -20,6 +20,7 @@ use PHPUnit\Framework\TestCase;
/** /**
* @covers \App\Event\InvoiceCreatedEvent * @covers \App\Event\InvoiceCreatedEvent
* @covers \App\Event\AbstractInvoiceEvent
*/ */
class InvoiceCreatedEventTest extends TestCase class InvoiceCreatedEventTest extends TestCase
{ {

View File

@@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase;
/** /**
* @covers \App\Event\InvoiceDeleteEvent * @covers \App\Event\InvoiceDeleteEvent
* @covers \App\Event\AbstractInvoiceEvent
*/ */
class InvoiceDeleteEventTest extends TestCase class InvoiceDeleteEventTest extends TestCase
{ {

View File

@@ -14,6 +14,7 @@ use App\Entity\ExportTemplate;
use App\Form\ExportTemplateSpreadsheetForm; use App\Form\ExportTemplateSpreadsheetForm;
use App\Form\Type\ExportColumnsType; use App\Form\Type\ExportColumnsType;
use App\Form\Type\LanguageType; use App\Form\Type\LanguageType;
use App\Tests\Mocks\SystemConfigurationFactory;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormTypeInterface; use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\Form\Test\TypeTestCase; use Symfony\Component\Form\Test\TypeTestCase;
@@ -31,9 +32,10 @@ class ExportTemplateSpreadsheetFormTest extends TypeTestCase
{ {
$dispatcher = $this->createMock(EventDispatcherInterface::class); $dispatcher = $this->createMock(EventDispatcherInterface::class);
$translator = $this->createMock(TranslatorInterface::class); $translator = $this->createMock(TranslatorInterface::class);
$config = SystemConfigurationFactory::createStub();
return [ return [
new ExportColumnsType($dispatcher, $translator), new ExportColumnsType($dispatcher, $translator, $config),
new LanguageType(new LocaleService([])) new LanguageType(new LocaleService([]))
]; ];
} }

View File

@@ -11,6 +11,7 @@ namespace App\Tests\Form\Type;
use App\Form\Type\ExportColumnsType; use App\Form\Type\ExportColumnsType;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock; use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use App\Tests\Mocks\SystemConfigurationFactory;
use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Test\TypeTestCase; use Symfony\Component\Form\Test\TypeTestCase;
@@ -40,9 +41,10 @@ class ExportColumnsTypeTest extends TypeTestCase
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock()); $dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
$translator = $this->createMock(TranslatorInterface::class); $translator = $this->createMock(TranslatorInterface::class);
$config = SystemConfigurationFactory::createStub();
return [ return [
new ExportColumnsType($dispatcher, $translator) new ExportColumnsType($dispatcher, $translator, $config)
]; ];
} }

View File

@@ -26,7 +26,7 @@ class MarkdownExtensionTest extends TestCase
$config = SystemConfigurationFactory::create($loader, ['timesheet' => ['markdown_content' => true]]); $config = SystemConfigurationFactory::create($loader, ['timesheet' => ['markdown_content' => true]]);
$sut = new MarkdownExtension(new Markdown(), $config); $sut = new MarkdownExtension(new Markdown(), $config);
self::assertEquals('<p><em>test</em></p>', $sut->markdownToHtml('*test*')); self::assertEquals('<p><em>test</em></p>', $sut->markdownToHtml('*test*'));
self::assertEquals('<h1>foobar</h1>', $sut->markdownToHtml('# foobar')); self::assertEquals('<h1 id="foobar">foobar</h1>', $sut->markdownToHtml('# foobar'));
self::assertEquals( self::assertEquals(
'<p><a href="javascript%3Aalert(`XSS`)">XSS</a></p>', '<p><a href="javascript%3Aalert(`XSS`)">XSS</a></p>',
$sut->markdownToHtml('[XSS](javascript:alert(`XSS`))') $sut->markdownToHtml('[XSS](javascript:alert(`XSS`))')

View File

@@ -53,7 +53,6 @@ class ThemeEventExtensionTest extends TestCase
protected function getSut(bool $hasListener = true): ThemeExtension protected function getSut(bool $hasListener = true): ThemeExtension
{ {
$dispatcher = $this->createMock(EventDispatcherInterface::class); $dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->method('hasListeners')->willReturn($hasListener);
$dispatcher->expects($hasListener ? $this->once() : $this->never())->method('dispatch'); $dispatcher->expects($hasListener ? $this->once() : $this->never())->method('dispatch');
$translator = $this->getMockBuilder(TranslatorInterface::class)->getMock(); $translator = $this->getMockBuilder(TranslatorInterface::class)->getMock();
@@ -95,13 +94,6 @@ class ThemeEventExtensionTest extends TestCase
self::assertInstanceOf(ThemeEvent::class, $event); self::assertInstanceOf(ThemeEvent::class, $event);
} }
public function testTriggerWithoutListener(): void
{
$sut = $this->getSut(false);
$event = $sut->trigger($this->getEnvironment(), 'foo', []);
self::assertInstanceOf(ThemeEvent::class, $event);
}
public function testJavascriptTranslations(): void public function testJavascriptTranslations(): void
{ {
$sut = $this->getSut(); $sut = $this->getSut();

View File

@@ -79,7 +79,6 @@ class RuntimeExtensionsTest extends TestCase
foreach ($filters as $filter) { foreach ($filters as $filter) {
switch ($filter->getName()) { switch ($filter->getName()) {
case 'md2html': case 'md2html':
self::assertEquals('html', $filters[0]->getPreEscape());
self::assertEquals(['html'], $filters[0]->getSafe(new TextNode('', 10))); self::assertEquals(['html'], $filters[0]->getSafe(new TextNode('', 10)));
$found_md2html = true; $found_md2html = true;
break; break;

View File

@@ -0,0 +1,42 @@
<?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\Utils;
use App\Utils\ParsedownExtension;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Utils\Parsedown
* @covers \App\Utils\ParsedownExtension
*/
class ParsedownExtensionTest extends TestCase
{
public function testTableContainsCssClasses(): void
{
$sut = new ParsedownExtension();
$html = $sut->parse('
| Item | Price |
|---|---|
| Something | $ 472,78 |
| Another entry | € 111 |
| | |
| Total | A lot |');
self::assertStringStartsWith('<table class="table">', $html);
}
public function testHeaderIsNotConverted(): void
{
$sut = new ParsedownExtension();
$html = $sut->parse('
# Foo
');
self::assertEquals('<p># Foo</p>', $html);
}
}

View File

@@ -0,0 +1,54 @@
<?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\Utils;
use App\Utils\Parsedown;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Utils\Parsedown
*/
class ParsedownTest extends TestCase
{
public function testTableContainsCssClasses(): void
{
$sut = new Parsedown();
$html = $sut->parse('
| Item | Price |
|---|---|
| Something | $ 472,78 |
| Another entry | € 111 |
| | |
| Total | A lot |');
self::assertStringStartsWith('<table class="table table-striped table-vcenter">', $html);
}
public function testHeaderContainsId(): void
{
$sut = new Parsedown();
$html = $sut->parse('
# Foo
');
self::assertEquals('<h1 id="foo">Foo</h1>', $html);
}
public function testHeaderContainsIdDoesNotDuplicate(): void
{
$sut = new Parsedown();
$html = $sut->parse('
# Foo
# Foo
# Foo
');
self::assertEquals('<h1 id="foo">Foo</h1>
<h1 id="foo-1">Foo</h1>
<h1 id="foo-2">Foo</h1>', $html);
}
}

View File

@@ -0,0 +1,47 @@
<?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\Webhook\Attribute;
use App\Entity\Invoice;
use App\Event\InvoiceDeleteEvent;
use App\Webhook\Attribute\AsWebhook;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Webhook\Attribute\AsWebhook
*/
class AsWebhookTestCase extends TestCase
{
public function testConstruct(): void
{
$attribute = new AsWebhook('name', 'description', 'some payload');
self::assertEquals('name', $attribute->name);
self::assertEquals('description', $attribute->description);
self::assertEquals('some payload', $attribute->payload);
}
public function testUsage(): void
{
$invoice = new Invoice();
$invoice->setComment('foo bar');
$usage = new InvoiceDeleteEvent($invoice);
$ref = new \ReflectionClass($usage);
$attr = $ref->getAttributes(AsWebhook::class);
self::assertCount(1, $attr);
$arguments = $attr[0]->getArguments();
self::assertEquals('invoice.deleted', $arguments['name']);
self::assertEquals('Triggered after an invoice was deleted', $arguments['description']);
self::assertEquals('object.getInvoice()', $arguments['payload']);
}
}

View File

@@ -1954,6 +1954,18 @@
<source>sending_company</source> <source>sending_company</source>
<target>Rechnungsabsender</target> <target>Rechnungsabsender</target>
</trans-unit> </trans-unit>
<trans-unit id="Uzll0ps" resname="Customer portal">
<source>Customer portal</source>
<target>Kundenportal</target>
</trans-unit>
<trans-unit id="cDOQMYv" resname="Beta">
<source>Beta</source>
<target>Aktiviert eine Vorschau-Funktion, die sich noch in der Entwicklung befindet.</target>
</trans-unit>
<trans-unit id="NA9GMDP" resname="Code">
<source>Code</source>
<target>Code</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -1954,6 +1954,18 @@
<source>sending_company</source> <source>sending_company</source>
<target>Invoice Sender</target> <target>Invoice Sender</target>
</trans-unit> </trans-unit>
<trans-unit id="Uzll0ps" resname="Customer portal">
<source>Customer portal</source>
<target>Customer portal</target>
</trans-unit>
<trans-unit id="cDOQMYv" resname="Beta">
<source>Beta</source>
<target>Enables a preview feature that is still in development.</target>
</trans-unit>
<trans-unit id="NA9GMDP" resname="Code">
<source>Code</source>
<target>Code</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

View File

@@ -16,6 +16,7 @@ Encore
.addEntry('chart', './assets/chart.js') .addEntry('chart', './assets/chart.js')
.addEntry('calendar', './assets/calendar.js') .addEntry('calendar', './assets/calendar.js')
.addEntry('dashboard', './assets/dashboard.js') .addEntry('dashboard', './assets/dashboard.js')
.addEntry('highlight', './assets/highlight.js')
.splitEntryChunks() .splitEntryChunks()
.configureSplitChunks((splitChunks) => { .configureSplitChunks((splitChunks) => {

View File

@@ -3842,6 +3842,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"highlight.js@npm:^11.11.1":
version: 11.11.1
resolution: "highlight.js@npm:11.11.1"
checksum: 10/205272f12f2c8ab1760452a75c58b043b11129cf3a5d2a9c0c90d43993580d0f5c385a73a4b8aba197eef20c0ec37d64000e6b35c4ed5991324f4c2dc78f4e43
languageName: node
linkType: hard
"htmlparser2@npm:^6.1.0": "htmlparser2@npm:^6.1.0":
version: 6.1.0 version: 6.1.0
resolution: "htmlparser2@npm:6.1.0" resolution: "htmlparser2@npm:6.1.0"
@@ -4243,6 +4250,7 @@ __metadata:
eslint: "npm:^9" eslint: "npm:^9"
globals: "npm:^15" globals: "npm:^15"
gridstack: "npm:^7" gridstack: "npm:^7"
highlight.js: "npm:^11.11.1"
litepicker: "npm:^2" litepicker: "npm:^2"
luxon: "npm:^3" luxon: "npm:^3"
sass: "npm:^1" sass: "npm:^1"