Release 2.38.0 (#5563)
This commit is contained in:
@@ -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/)
|
||||
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
|
||||
|
||||
- [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
|
||||
|
||||
- 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
|
||||
- A webserver and subdomain (subdirectory is not supported)
|
||||
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`
|
||||
|
||||
5
assets/highlight.js
Normal file
5
assets/highlight.js
Normal file
@@ -0,0 +1,5 @@
|
||||
require('highlight.js/styles/github-dark.css');
|
||||
|
||||
const hljs = require('highlight.js/lib/common');
|
||||
|
||||
global.hljs = hljs;
|
||||
@@ -202,8 +202,8 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
|
||||
{
|
||||
let formatted = time.trim();
|
||||
|
||||
// replace dot with colon
|
||||
formatted = formatted.replace(/\./g, ':');
|
||||
// replace invalid separators with colon
|
||||
formatted = formatted.replace(/\.|;|,/g, ':');
|
||||
// uppercase 12-hour format
|
||||
formatted = formatted.replace(/am/i, 'AM');
|
||||
formatted = formatted.replace(/pm/i, 'PM');
|
||||
|
||||
@@ -49,6 +49,7 @@ import enGbLocale from '@fullcalendar/core/locales/en-gb';
|
||||
import enUsLocale from '@fullcalendar/core/locales/en-gb';
|
||||
import KimaiColor from './KimaiColor';
|
||||
import KimaiContextMenu from "./KimaiContextMenu";
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
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 =============
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,3 +93,19 @@ table.dataTable thead > tr > th.hw-min {
|
||||
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
1139
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@ tabler:
|
||||
barcode: fas fa-barcode
|
||||
bookmark: far fa-star
|
||||
bookmarked: fas fa-star
|
||||
break: fas fa-utensils
|
||||
calendar: far fa-calendar-alt
|
||||
cancel: fas fa-times
|
||||
clock: far fa-clock
|
||||
@@ -68,9 +69,9 @@ tabler:
|
||||
documentation: fas fa-book
|
||||
dot: fas fa-circle
|
||||
download: fas fa-download
|
||||
duration: far fa-hourglass
|
||||
duration: fas fa-stopwatch
|
||||
edit: far fa-edit
|
||||
end: fas fa-stopwatch
|
||||
end: fas fa-hourglass-end
|
||||
export: fas fa-download
|
||||
failure: fas fa-times
|
||||
fax: fas fa-fax
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"eslint": "^9",
|
||||
"globals": "^15",
|
||||
"gridstack": "^7",
|
||||
"highlight.js": "^11.11.1",
|
||||
"litepicker": "^2",
|
||||
"luxon": "^3",
|
||||
"sass": "^1",
|
||||
|
||||
25
phpstan.neon
25
phpstan.neon
@@ -4164,29 +4164,14 @@ parameters:
|
||||
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
|
||||
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
|
||||
path: src/Utils/ParsedownExtension.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
|
||||
path: src/Utils/Parsedown.php
|
||||
|
||||
-
|
||||
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\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/ParsedownExtension.php
|
||||
path: src/Utils/Parsedown.php
|
||||
|
||||
-
|
||||
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
2
public/build/app.dd0a8e58.js
Normal file
2
public/build/app.dd0a8e58.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/calendar.f4379767.js
Normal file
2
public/build/calendar.f4379767.js
Normal file
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/app.3d366594.js"
|
||||
"/build/app.dd0a8e58.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.7ffe3b24.css"
|
||||
"/build/app.8b6629a6.css"
|
||||
]
|
||||
},
|
||||
"app-rtl": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"/build/app-rtl.7a875ca7.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.4f157a57.css"
|
||||
"/build/app-rtl.bf451276.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
@@ -54,7 +54,7 @@
|
||||
"calendar": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/calendar.7415ab21.js"
|
||||
"/build/calendar.f4379767.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/calendar.d757753e.css"
|
||||
@@ -68,14 +68,23 @@
|
||||
"css": [
|
||||
"/build/dashboard.b7129fa1.css"
|
||||
]
|
||||
},
|
||||
"highlight": {
|
||||
"js": [
|
||||
"/build/runtime.6c399d29.js",
|
||||
"/build/highlight.766d3929.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/highlight.98bf3927.css"
|
||||
]
|
||||
}
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
|
||||
"/build/app.3d366594.js": "sha384-Wa5ZJDt7hVmlqWA8ixxZ/w/9vh/NJQ74KK3FqAsT/PPWNayIcg0N5XOv/9GfspBk",
|
||||
"/build/app.7ffe3b24.css": "sha384-G4p41N3MjNkWRsaJkUKgGj1Ukd18qMGXHPOaDH6DqE8AzHAJPa1sGskswGP5zg+N",
|
||||
"/build/app.dd0a8e58.js": "sha384-k0DlCny0XdsJHHuyL8B5MNlZTl+s8ZvBFvjwD9xrPQhbZ7ryiTx72pHOi+vaEzB8",
|
||||
"/build/app.8b6629a6.css": "sha384-mgEEi5bDaaMfrI+/iEBBD2S2sQvjdRJhsYN89QlhqPeUhM39pcxiGx9WVVP31jls",
|
||||
"/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.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
|
||||
"/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY",
|
||||
@@ -83,9 +92,11 @@
|
||||
"/build/invoice-pdf.26d98626.js": "sha384-gwNzQiU1y6qU/M9DPGiNW0MVZkLctEHk37sCES2X9ov+zugEaDABdkMjKBYOC9lz",
|
||||
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
|
||||
"/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/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"
|
||||
}
|
||||
}
|
||||
1
public/build/highlight.766d3929.js
Normal file
1
public/build/highlight.766d3929.js
Normal file
File diff suppressed because one or more lines are too long
1
public/build/highlight.98bf3927.css
Normal file
1
public/build/highlight.98bf3927.css
Normal 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}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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.css": "/build/app.8b6629a6.css",
|
||||
"build/app.js": "/build/app.dd0a8e58.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.bf451276.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",
|
||||
@@ -11,9 +11,11 @@
|
||||
"build/invoice-pdf.js": "/build/invoice-pdf.26d98626.js",
|
||||
"build/chart.js": "/build/chart.62631acc.js",
|
||||
"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.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/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",
|
||||
|
||||
@@ -20,7 +20,7 @@ use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* @final
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.37.0';
|
||||
public const VERSION = '2.38.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 23700;
|
||||
public const VERSION_ID = 23800;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -106,15 +106,15 @@ final class PermissionController extends AbstractController
|
||||
$other = [];
|
||||
|
||||
foreach ($event->getSections() as $section) {
|
||||
$permissionSorted[$section->getTitle()] = [];
|
||||
$permissionSorted[$section->getTitle()] = []; // @phpstan-ignore method.deprecatedInterface
|
||||
}
|
||||
|
||||
foreach ($this->manager->getPermissions() as $permission) {
|
||||
$found = false;
|
||||
|
||||
foreach (array_reverse($event->getSections()) as $section) {
|
||||
if ($section->filter($permission)) {
|
||||
$permissionSorted[$section->getTitle()][] = $permission;
|
||||
if ($section->filter($permission)) { // @phpstan-ignore method.deprecatedInterface
|
||||
$permissionSorted[$section->getTitle()][] = $permission; // @phpstan-ignore method.deprecatedInterface
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace App\Controller\Security;
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Controller\AbstractController;
|
||||
use App\Entity\User;
|
||||
use App\Event\EmailEvent;
|
||||
use App\Event\EmailPasswordResetEvent;
|
||||
use App\Event\UserEmailEvent;
|
||||
use App\User\UserService;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
@@ -112,7 +112,7 @@ final class PasswordResetController extends AbstractController
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
// this will send the email
|
||||
$this->eventDispatcher->dispatch(new EmailEvent($event->getEmail()));
|
||||
$this->eventDispatcher->dispatch(new UserEmailEvent($user, $event->getEmail()));
|
||||
|
||||
$user->markPasswordRequested();
|
||||
$user->setRequiresPasswordReset(true);
|
||||
|
||||
@@ -348,6 +348,10 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setConstraints([
|
||||
new GreaterThanOrEqual(['value' => 0])
|
||||
]),
|
||||
(new Configuration('timesheet.rules.break_time_active'))
|
||||
->setLabel('break')
|
||||
->setType(YesNoType::class)
|
||||
->setOptions(['help' => 'Beta']),
|
||||
]),
|
||||
(new SystemConfigurationModel('quick_entry'))
|
||||
->setTranslation('quick_entry.title')
|
||||
|
||||
@@ -21,7 +21,7 @@ use DateTimeInterface;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* @final
|
||||
|
||||
@@ -11,6 +11,10 @@ namespace App\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
/**
|
||||
* @method getTags() array
|
||||
* @method getBreak() int
|
||||
*/
|
||||
interface ExportableItem
|
||||
{
|
||||
public function getId(): ?int;
|
||||
@@ -85,4 +89,18 @@ interface ExportableItem
|
||||
* Example: "work"
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
#[ORM\Column(name: 'order_number', type: Types::TEXT, length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Project_Entity'])]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'orderNumber')]
|
||||
private ?string $orderNumber = null;
|
||||
/**
|
||||
|
||||
25
src/Event/AbstractInvoiceEvent.php
Normal file
25
src/Event/AbstractInvoiceEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered for activity instances, which were just saved.
|
||||
*/
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'activity.created', description: 'Triggered after a new activity was created', payload: 'object.getActivity()')]
|
||||
final class ActivityCreatePostEvent extends AbstractActivityEvent
|
||||
{
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered right before a activity will be deleted.
|
||||
*/
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'activity.deleted', description: 'Triggered right before an activity will be deleted', payload: 'object.getActivity()')]
|
||||
final class ActivityDeleteEvent extends AbstractActivityEvent
|
||||
{
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered for activity instances, which were updated.
|
||||
*/
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'activity.updated', description: 'Triggered after an activity was updated', payload: 'object.getActivity()')]
|
||||
final class ActivityUpdatePostEvent extends AbstractActivityEvent
|
||||
{
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered for customer instances, which were just saved.
|
||||
*/
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'customer.created', description: 'Triggered after a customer was created', payload: 'object.getCustomer()')]
|
||||
final class CustomerCreatePostEvent extends AbstractCustomerEvent
|
||||
{
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered right before a customer will be deleted.
|
||||
*/
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'customer.deleted', description: 'Triggered right before a customer will be deleted', payload: 'object.getCustomer()')]
|
||||
final class CustomerDeleteEvent extends AbstractCustomerEvent
|
||||
{
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered for customer instances, which were just updated.
|
||||
*/
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'customer.updated', description: 'Triggered after a customer was updated', payload: 'object.getCustomer()')]
|
||||
final class CustomerUpdatePostEvent extends AbstractCustomerEvent
|
||||
{
|
||||
}
|
||||
|
||||
@@ -11,17 +11,14 @@ namespace App\Event;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
public function getInvoice(): Invoice
|
||||
{
|
||||
return $this->invoice;
|
||||
parent::__construct($invoice);
|
||||
}
|
||||
|
||||
public function getInvoiceModel(): InvoiceModel
|
||||
|
||||
@@ -9,17 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
final class InvoiceDeleteEvent extends Event
|
||||
{
|
||||
public function __construct(private Invoice $invoice)
|
||||
#[AsWebhook(name: 'invoice.deleted', description: 'Triggered after an invoice was deleted', payload: 'object.getInvoice()')]
|
||||
final class InvoiceDeleteEvent extends AbstractInvoiceEvent
|
||||
{
|
||||
}
|
||||
|
||||
public function getInvoice(): Invoice
|
||||
{
|
||||
return $this->invoice;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ final class PermissionSectionsEvent extends Event
|
||||
/**
|
||||
* @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;
|
||||
|
||||
@@ -32,7 +32,7 @@ final class PermissionSectionsEvent extends Event
|
||||
/**
|
||||
* @return PermissionSectionInterface[]
|
||||
*/
|
||||
public function getSections(): array
|
||||
public function getSections(): array // @phpstan-ignore return.deprecatedInterface
|
||||
{
|
||||
return $this->sections;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered for project instances, which were just saved.
|
||||
*/
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'project.created', description: 'Triggered after a project was created', payload: 'object.getProject()')]
|
||||
final class ProjectCreatePostEvent extends AbstractProjectEvent
|
||||
{
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered right before a project will be deleted.
|
||||
*/
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'project.deleted', description: 'Triggered right before a project will be deleted', payload: 'object.getProject()')]
|
||||
final class ProjectDeleteEvent extends AbstractProjectEvent
|
||||
{
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered for project instances, which were just updated.
|
||||
*/
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'project.updated', description: 'Triggered after a project was updated', payload: 'object.getProject()')]
|
||||
final class ProjectUpdatePostEvent extends AbstractProjectEvent
|
||||
{
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Form\Model\SystemConfiguration;
|
||||
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
|
||||
{
|
||||
@@ -32,10 +32,6 @@ final class SystemConfigurationEvent extends Event
|
||||
return $this->configurations;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SystemConfiguration $configuration
|
||||
* @return SystemConfigurationEvent
|
||||
*/
|
||||
public function addConfiguration(SystemConfiguration $configuration): SystemConfigurationEvent
|
||||
{
|
||||
$this->configurations[] = $configuration;
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered for new user instances, which were just saved.
|
||||
*/
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'user.created', description: 'Triggered after a user was created', payload: 'object.getUser()')]
|
||||
final class UserCreatePostEvent extends AbstractUserEvent
|
||||
{
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered for user instances which were just deleted.
|
||||
*/
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'user.deleted', description: 'Triggered after a user was deleted', payload: 'object.getUser()')]
|
||||
final class UserDeletePostEvent extends UserDeleteEvent
|
||||
{
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered for user instances, which were just updated.
|
||||
*/
|
||||
use App\Webhook\Attribute\AsWebhook;
|
||||
|
||||
#[AsWebhook(name: 'user.updated', description: 'Triggered after a user was updated', payload: 'object.getUser()')]
|
||||
final class UserUpdatePostEvent extends AbstractUserEvent
|
||||
{
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
*/
|
||||
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
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -87,12 +87,12 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
$options = new Options();
|
||||
$options->SHOULD_ADD_BOM = false;
|
||||
|
||||
$opts = $this->spreadsheetRenderer->getTemplate()->getOptions();
|
||||
$opts = $this->spreadsheetRenderer->getTemplate($query)->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 = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate($query)->getLocale());
|
||||
$spreadsheet->open($filename);
|
||||
|
||||
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());
|
||||
|
||||
@@ -65,11 +65,11 @@ final class SpreadsheetRenderer
|
||||
$this->template = $template;
|
||||
}
|
||||
|
||||
public function getTemplate(): TemplateInterface
|
||||
public function getTemplate(?TimesheetQuery $query = null): TemplateInterface
|
||||
{
|
||||
if ($this->template === null) {
|
||||
$template = new Template('default', 'default');
|
||||
$template->setColumns($this->getDefaultColumns());
|
||||
$template->setColumns($this->getDefaultColumns($query));
|
||||
$template->setLocale('en');
|
||||
|
||||
$this->template = $template;
|
||||
@@ -231,7 +231,7 @@ final class SpreadsheetRenderer
|
||||
}
|
||||
}
|
||||
|
||||
$template = $this->getTemplate();
|
||||
$template = $this->getTemplate($query);
|
||||
|
||||
$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);
|
||||
} elseif ($column === 'duration_seconds') {
|
||||
$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) {
|
||||
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
|
||||
} elseif ($column === 'rate' && $showRates) {
|
||||
@@ -319,9 +328,9 @@ final class SpreadsheetRenderer
|
||||
/**
|
||||
* @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';
|
||||
if (($user = $this->voter->getUser()) instanceof User) {
|
||||
$durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration';
|
||||
@@ -355,7 +364,7 @@ final class SpreadsheetRenderer
|
||||
'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) {
|
||||
$columns[] = 'timesheet.meta.' . $metaField->getName();
|
||||
}
|
||||
|
||||
@@ -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 ?? $this->spreadsheetRenderer->getTemplate()->getLocale());
|
||||
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate($query)->getLocale());
|
||||
$spreadsheet->open($filename);
|
||||
|
||||
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
|
||||
|
||||
@@ -19,7 +19,7 @@ final class ProjectHelper
|
||||
public const PATTERN_NAME = '{name}';
|
||||
public const PATTERN_NUMBER = '{number}';
|
||||
public const PATTERN_COMMENT = '{comment}';
|
||||
public const PATTERN_ORDERNUMBER = '{ordernumber}';
|
||||
public const PATTERN_ORDERNUMBER = '{orderNumber}';
|
||||
public const PATTERN_DATERANGE = '{daterange}';
|
||||
public const PATTERN_START = '{start}';
|
||||
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_COMMENT, $project->getComment() ?? '', $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);
|
||||
|
||||
if ($this->dateFormatter === null) {
|
||||
|
||||
@@ -329,7 +329,7 @@ class TimesheetEditForm extends AbstractType
|
||||
$builder->add('duration', DurationType::class, $durationOptions);
|
||||
|
||||
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(
|
||||
@@ -392,7 +392,14 @@ class TimesheetEditForm extends AbstractType
|
||||
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
|
||||
|
||||
@@ -32,7 +32,7 @@ final class DurationType extends AbstractType
|
||||
'preset_minutes' => null,
|
||||
'toggle' => false,
|
||||
'max_hours' => 24,
|
||||
'icon' => 'clock',
|
||||
'icon' => 'duration',
|
||||
'documentation' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Duration - supports various formats: https://www.kimai.org/documentation/duration-format.html',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Event\ActivityMetaDisplayEvent;
|
||||
use App\Event\CustomerMetaDisplayEvent;
|
||||
@@ -39,6 +40,7 @@ final class ExportColumnsType extends AbstractType
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
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) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns['timesheet'][$metaField->getLabel()] = 'timesheet.meta.' . $metaField->getName();
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
class PermissionSection implements PermissionSectionInterface
|
||||
/**
|
||||
* @final with 3.0
|
||||
*/
|
||||
class PermissionSection implements PermissionSectionInterface // @phpstan-ignore class.implementsDeprecatedInterface
|
||||
{
|
||||
/** @var array<string> */
|
||||
private array $filter;
|
||||
@@ -17,7 +20,7 @@ class PermissionSection implements PermissionSectionInterface
|
||||
/**
|
||||
* @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)) {
|
||||
$filter = [$filter];
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* @deprecated since 2.38 - use PermissionSection directly
|
||||
*/
|
||||
interface PermissionSectionInterface
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@ class UserQuery extends BaseQuery implements VisibilityInterface
|
||||
{
|
||||
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;
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,7 @@ use App\Event\ThemeEvent;
|
||||
use App\Event\ThemeJavascriptTranslationsEvent;
|
||||
use App\Utils\Color;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Twig\Environment;
|
||||
use Twig\Extension\RuntimeExtensionInterface;
|
||||
@@ -40,12 +40,8 @@ final class ThemeExtension implements RuntimeExtensionInterface
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->security->getUser();
|
||||
|
||||
$themeEvent = new ThemeEvent($user, $payload);
|
||||
|
||||
if ($this->eventDispatcher->hasListeners($eventName)) {
|
||||
$this->eventDispatcher->dispatch($themeEvent, $eventName);
|
||||
}
|
||||
|
||||
return $themeEvent;
|
||||
}
|
||||
@@ -53,10 +49,7 @@ final class ThemeExtension implements RuntimeExtensionInterface
|
||||
public function actions(User $user, string $action, string $view, array $payload = []): ThemeEvent
|
||||
{
|
||||
$themeEvent = new PageActionsEvent($user, $payload, $action, $view);
|
||||
|
||||
if ($this->eventDispatcher->hasListeners($themeEvent->getEventName())) {
|
||||
$this->eventDispatcher->dispatch($themeEvent, $themeEvent->getEventName());
|
||||
}
|
||||
|
||||
return $themeEvent;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ final class RuntimeExtensions extends AbstractExtension
|
||||
public function getFilters(): array
|
||||
{
|
||||
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('comment2html', [MarkdownExtension::class, 'commentContent'], ['is_safe' => ['html']]),
|
||||
new TwigFilter('comment1line', [MarkdownExtension::class, 'commentOneLiner'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
|
||||
|
||||
@@ -33,7 +33,7 @@ final class Markdown
|
||||
public function withFullMarkdownSupport(string $text): string
|
||||
{
|
||||
if ($this->parserFull === null) {
|
||||
$this->parserFull = new \Parsedown();
|
||||
$this->parserFull = new Parsedown();
|
||||
$this->parserFull->setUrlsLinked(true);
|
||||
$this->parserFull->setBreaksEnabled(true);
|
||||
$this->parserFull->setSafeMode(true);
|
||||
|
||||
82
src/Utils/Parsedown.php
Normal file
82
src/Utils/Parsedown.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,10 @@ namespace App\Utils;
|
||||
/**
|
||||
* 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
|
||||
* - Headings should not occur in time record listings
|
||||
*/
|
||||
@@ -98,57 +95,7 @@ final class ParsedownExtension extends \Parsedown
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
* - 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)
|
||||
protected function blockTable($Line, array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
|
||||
{
|
||||
$Block = parent::blockTable($Line, $Block);
|
||||
|
||||
|
||||
18
src/Webhook/Attribute/AsWebhook.php
Normal file
18
src/Webhook/Attribute/AsWebhook.php
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -59,8 +59,8 @@ final class YearPerUserSummary implements \Countable, \IteratorAggregate
|
||||
public function getExpectedTime(): int
|
||||
{
|
||||
$all = 0;
|
||||
foreach ($this->getSummaries() as $month) {
|
||||
$all += $month->getExpectedTime();
|
||||
foreach ($this->getSummaries() as $year) {
|
||||
$all += $year->getExpectedTime();
|
||||
}
|
||||
|
||||
return $all;
|
||||
@@ -69,8 +69,8 @@ final class YearPerUserSummary implements \Countable, \IteratorAggregate
|
||||
public function getActualTime(): int
|
||||
{
|
||||
$all = 0;
|
||||
foreach ($this->getSummaries() as $month) {
|
||||
$all += $month->getActualTime();
|
||||
foreach ($this->getSummaries() as $year) {
|
||||
$all += $year->getActualTime();
|
||||
}
|
||||
|
||||
return $all;
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
{% set columns = {
|
||||
'avatar': {'class': 'text-nowrap w-avatar d-none d-md-table-cell', 'title': false, '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},
|
||||
'project': {'class': 'd-none d-sm-table-cell', 'orderBy': false},
|
||||
'activity': {'class': 'd-none', 'orderBy': false},
|
||||
@@ -234,6 +236,16 @@
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">
|
||||
{{ entry.begin|date_short }}
|
||||
</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 %}
|
||||
‐
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">
|
||||
{{ widgets.label_user(entry.user) }}
|
||||
</td>
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
{%- 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-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>
|
||||
{{ block('form_widget_simple') }}
|
||||
{% set time_presets = form_time_presets(app.user.timezone) %}
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
{% set tableName = 'user_admin_permissions' %}
|
||||
|
||||
{{ tables.datatable_header(tableName, columns, null, {'reload': 'kimai.userRoleUpdate'}) }}
|
||||
|
||||
@@ -487,6 +487,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
|
||||
'color' => '@string',
|
||||
'customer' => 'int',
|
||||
'number' => '@int',
|
||||
'orderNumber' => '@string',
|
||||
'globalActivities' => 'bool',
|
||||
'comment' => '@string',
|
||||
];
|
||||
@@ -501,6 +502,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
|
||||
'color' => '@string',
|
||||
'customer' => ['result' => 'object', 'type' => 'Customer'],
|
||||
'number' => '@int',
|
||||
'orderNumber' => '@string',
|
||||
'globalActivities' => 'bool',
|
||||
'comment' => '@string',
|
||||
];
|
||||
@@ -514,6 +516,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
|
||||
'billable' => 'bool',
|
||||
'customer' => 'int',
|
||||
'number' => '@int',
|
||||
'orderNumber' => '@string',
|
||||
'color' => '@string',
|
||||
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
|
||||
'parentTitle' => 'string',
|
||||
|
||||
@@ -100,7 +100,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
|
||||
$titles[] = trim($th->textContent);
|
||||
}
|
||||
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);
|
||||
|
||||
// assert export type buttons are available
|
||||
|
||||
@@ -49,7 +49,10 @@ class UserTest extends TestCase
|
||||
self::assertFalse($user->canSeeAllData());
|
||||
self::assertFalse($user->isExportDecimal());
|
||||
self::assertFalse($user->isSystemAccount());
|
||||
self::assertFalse($user->isPasswordRequestNonExpired(-1));
|
||||
self::assertFalse($user->isPasswordRequestNonExpired(0));
|
||||
self::assertFalse($user->isPasswordRequestNonExpired(3599));
|
||||
self::assertFalse($user->isPasswordRequestNonExpired(PHP_INT_MAX));
|
||||
|
||||
$user->setUserIdentifier('foo');
|
||||
self::assertEquals('foo', $user->getUserIdentifier());
|
||||
@@ -675,4 +678,21 @@ class UserTest extends TestCase
|
||||
self::assertInstanceOf(\DateTime::class, $lastLogin);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Event\InvoiceCreatedEvent
|
||||
* @covers \App\Event\AbstractInvoiceEvent
|
||||
*/
|
||||
class InvoiceCreatedEventTest extends TestCase
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Event\InvoiceDeleteEvent
|
||||
* @covers \App\Event\AbstractInvoiceEvent
|
||||
*/
|
||||
class InvoiceDeleteEventTest extends TestCase
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Entity\ExportTemplate;
|
||||
use App\Form\ExportTemplateSpreadsheetForm;
|
||||
use App\Form\Type\ExportColumnsType;
|
||||
use App\Form\Type\LanguageType;
|
||||
use App\Tests\Mocks\SystemConfigurationFactory;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\FormTypeInterface;
|
||||
use Symfony\Component\Form\Test\TypeTestCase;
|
||||
@@ -31,9 +32,10 @@ class ExportTemplateSpreadsheetFormTest extends TypeTestCase
|
||||
{
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
$config = SystemConfigurationFactory::createStub();
|
||||
|
||||
return [
|
||||
new ExportColumnsType($dispatcher, $translator),
|
||||
new ExportColumnsType($dispatcher, $translator, $config),
|
||||
new LanguageType(new LocaleService([]))
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Tests\Form\Type;
|
||||
|
||||
use App\Form\Type\ExportColumnsType;
|
||||
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
|
||||
use App\Tests\Mocks\SystemConfigurationFactory;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\Test\TypeTestCase;
|
||||
@@ -40,9 +41,10 @@ class ExportColumnsTypeTest extends TypeTestCase
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
|
||||
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
$config = SystemConfigurationFactory::createStub();
|
||||
|
||||
return [
|
||||
new ExportColumnsType($dispatcher, $translator)
|
||||
new ExportColumnsType($dispatcher, $translator, $config)
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class MarkdownExtensionTest extends TestCase
|
||||
$config = SystemConfigurationFactory::create($loader, ['timesheet' => ['markdown_content' => true]]);
|
||||
$sut = new MarkdownExtension(new Markdown(), $config);
|
||||
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(
|
||||
'<p><a href="javascript%3Aalert(`XSS`)">XSS</a></p>',
|
||||
$sut->markdownToHtml('[XSS](javascript:alert(`XSS`))')
|
||||
|
||||
@@ -53,7 +53,6 @@ class ThemeEventExtensionTest extends TestCase
|
||||
protected function getSut(bool $hasListener = true): ThemeExtension
|
||||
{
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$dispatcher->method('hasListeners')->willReturn($hasListener);
|
||||
$dispatcher->expects($hasListener ? $this->once() : $this->never())->method('dispatch');
|
||||
|
||||
$translator = $this->getMockBuilder(TranslatorInterface::class)->getMock();
|
||||
@@ -95,13 +94,6 @@ class ThemeEventExtensionTest extends TestCase
|
||||
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
|
||||
{
|
||||
$sut = $this->getSut();
|
||||
|
||||
@@ -79,7 +79,6 @@ class RuntimeExtensionsTest extends TestCase
|
||||
foreach ($filters as $filter) {
|
||||
switch ($filter->getName()) {
|
||||
case 'md2html':
|
||||
self::assertEquals('html', $filters[0]->getPreEscape());
|
||||
self::assertEquals(['html'], $filters[0]->getSafe(new TextNode('', 10)));
|
||||
$found_md2html = true;
|
||||
break;
|
||||
|
||||
42
tests/Utils/ParsedownExtensionTest.php
Normal file
42
tests/Utils/ParsedownExtensionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
54
tests/Utils/ParsedownTest.php
Normal file
54
tests/Utils/ParsedownTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
47
tests/Webhook/Attribute/AsWebhookTestCase.php
Normal file
47
tests/Webhook/Attribute/AsWebhookTestCase.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -1954,6 +1954,18 @@
|
||||
<source>sending_company</source>
|
||||
<target>Rechnungsabsender</target>
|
||||
</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>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -1954,6 +1954,18 @@
|
||||
<source>sending_company</source>
|
||||
<target>Invoice Sender</target>
|
||||
</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>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -16,6 +16,7 @@ Encore
|
||||
.addEntry('chart', './assets/chart.js')
|
||||
.addEntry('calendar', './assets/calendar.js')
|
||||
.addEntry('dashboard', './assets/dashboard.js')
|
||||
.addEntry('highlight', './assets/highlight.js')
|
||||
|
||||
.splitEntryChunks()
|
||||
.configureSplitChunks((splitChunks) => {
|
||||
|
||||
@@ -3842,6 +3842,13 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 6.1.0
|
||||
resolution: "htmlparser2@npm:6.1.0"
|
||||
@@ -4243,6 +4250,7 @@ __metadata:
|
||||
eslint: "npm:^9"
|
||||
globals: "npm:^15"
|
||||
gridstack: "npm:^7"
|
||||
highlight.js: "npm:^11.11.1"
|
||||
litepicker: "npm:^2"
|
||||
luxon: "npm:^3"
|
||||
sass: "npm:^1"
|
||||
|
||||
Reference in New Issue
Block a user