diff --git a/assets/controllers.json b/assets/controllers.json index 1927d14..ae63605 100644 --- a/assets/controllers.json +++ b/assets/controllers.json @@ -5,6 +5,10 @@ "enabled": true, "fetch": "lazy" }, + "modal-prefill": { + "enabled": true, + "fetch": "lazy" + }, "csrf-protection": { "enabled": true, "fetch": "eager" diff --git a/assets/controllers/manual_entry_controller.ts b/assets/controllers/manual_entry_controller.ts new file mode 100644 index 0000000..53a2c5f --- /dev/null +++ b/assets/controllers/manual_entry_controller.ts @@ -0,0 +1,108 @@ +import { Controller } from '@hotwired/stimulus'; + +interface TomSelectOption { + [key: string]: unknown; +} + +interface TomSelectLike { + options: Record; + addOption: (option: TomSelectOption) => void; + removeOption: (value: string, silent?: boolean) => void; + refreshOptions: (triggerDropdown?: boolean) => void; + clear: (silent?: boolean) => void; + getValue: () => string | string[]; +} + +interface SelectWithTomSelect extends HTMLSelectElement { + tomselect?: TomSelectLike; +} + +// Narrows the manual-entry modal's project list to a single client, for the +// trigger on a client row (which has no one project to pre-select). +// +// The generic "seed a field from the trigger" behaviour lives in the platform's +// `modal-prefill` controller; only the project => client relationship is +// SolidTrack's, so it stays here. The two are mutually exclusive in practice — +// a trigger names either a project or a client, never both. +// +// The narrowing has to go through TomSelect rather than the server because the +// project field sits behind `data-live-ignore`, so a re-render cannot replace +// its options. The project => client map is published by the component as +// `data-project-clients`. + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + private removed: TomSelectOption[] = []; + + private onShow = (event: Event) => this.narrow(event as Event & { relatedTarget?: HTMLElement }); + + private onHidden = () => this.restore(); + + connect() { + this.element.addEventListener('show.bs.modal', this.onShow); + this.element.addEventListener('hidden.bs.modal', this.onHidden); + } + + disconnect() { + this.element.removeEventListener('show.bs.modal', this.onShow); + this.element.removeEventListener('hidden.bs.modal', this.onHidden); + } + + private narrow(event: Event & { relatedTarget?: HTMLElement }) { + this.restore(); + + const clientId = event.relatedTarget?.dataset?.prefillClient; + if (!clientId) { + return; + } + + const select = this.projectField(); + const tomSelect = select?.tomselect; + if (!tomSelect) { + return; + } + + const projectClients = this.projectClients(); + + Object.keys(tomSelect.options).forEach((value) => { + if (projectClients[value] !== clientId) { + this.removed.push(tomSelect.options[value]); + tomSelect.removeOption(value, true); + } + }); + + // A project carried over from a previous open may no longer be on offer. + if (this.removed.length > 0) { + tomSelect.clear(true); + tomSelect.refreshOptions(false); + } + } + + private restore() { + if (this.removed.length === 0) { + return; + } + + const tomSelect = this.projectField()?.tomselect; + if (tomSelect) { + this.removed.forEach((option) => tomSelect.addOption(option)); + tomSelect.refreshOptions(false); + } + + this.removed = []; + } + + private projectField(): SelectWithTomSelect | null { + return this.element.querySelector('select[name$="[project]"]'); + } + + private projectClients(): Record { + const holder = this.element.querySelector('[data-project-clients]'); + + try { + return JSON.parse(holder?.dataset.projectClients ?? '{}'); + } catch { + return {}; + } + } +} diff --git a/composer.lock b/composer.lock index 2bd262f..0cdeeb8 100644 --- a/composer.lock +++ b/composer.lock @@ -281,16 +281,16 @@ }, { "name": "carbonphp/carbon-doctrine-types", - "version": "3.2.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", - "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", - "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", "shasum": "" }, "require": { @@ -305,6 +305,11 @@ "phpunit/phpunit": "^10.3" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, "autoload": { "psr-4": { "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" @@ -330,7 +335,7 @@ ], "support": { "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", - "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" }, "funding": [ { @@ -346,7 +351,7 @@ "type": "tidelift" } ], - "time": "2024-02-09T16:56:22+00:00" + "time": "2026-09-06T14:11:38+00:00" }, { "name": "composer/semver", @@ -477,29 +482,29 @@ }, { "name": "doctrine/collections", - "version": "2.6.0", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/collections.git", - "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2" + "reference": "7c2f25ed928553a3de389ccb99aa46c0eedb2d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/7713da39d8e237f28411d6a616a3dce5e20d5de2", - "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2", + "url": "https://api.github.com/repos/doctrine/collections/zipball/7c2f25ed928553a3de389ccb99aa46c0eedb2d4d", + "reference": "7c2f25ed928553a3de389ccb99aa46c0eedb2d4d", "shasum": "" }, "require": { "doctrine/deprecations": "^1", - "php": "^8.1", - "symfony/polyfill-php84": "^1.30" + "php": "^8.4", + "symfony/polyfill-php86": "^1.36" }, "require-dev": { "doctrine/coding-standard": "^14", "ext-json": "*", "phpstan/phpstan": "^2.1.30", "phpstan/phpstan-phpunit": "^2.0.7", - "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" + "phpunit/phpunit": "^12.4" }, "type": "library", "autoload": { @@ -543,7 +548,7 @@ ], "support": { "issues": "https://github.com/doctrine/collections/issues", - "source": "https://github.com/doctrine/collections/tree/2.6.0" + "source": "https://github.com/doctrine/collections/tree/3.1.0" }, "funding": [ { @@ -559,7 +564,7 @@ "type": "tidelift" } ], - "time": "2026-01-15T10:01:58+00:00" + "time": "2026-04-29T20:41:02+00:00" }, { "name": "doctrine/dbal", @@ -1350,24 +1355,24 @@ }, { "name": "doctrine/orm", - "version": "3.6.8", + "version": "3.7.0", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc" + "reference": "f25c5e5385d3a0bca2dc364d556d3b83a06559ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc", - "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc", + "url": "https://api.github.com/repos/doctrine/orm/zipball/f25c5e5385d3a0bca2dc364d556d3b83a06559ed", + "reference": "f25c5e5385d3a0bca2dc364d556d3b83a06559ed", "shasum": "" }, "require": { "composer-runtime-api": "^2", - "doctrine/collections": "^2.2", + "doctrine/collections": "^2.2 || ^3.1", "doctrine/dbal": "^3.8.2 || ^4", "doctrine/deprecations": "^0.5.3 || ^1", - "doctrine/event-manager": "^1.2 || ^2", + "doctrine/event-manager": "^2.1.1", "doctrine/inflector": "^1.4 || ^2.0", "doctrine/instantiator": "^1.3 || ^2", "doctrine/lexer": "^3", @@ -1376,6 +1381,7 @@ "php": "^8.1", "psr/cache": "^1 || ^2 || ^3", "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/polyfill-php86": "^1.37", "symfony/var-exporter": "^6.3.9 || ^7.0 || ^8.0" }, "require-dev": { @@ -1433,9 +1439,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.6.8" + "source": "https://github.com/doctrine/orm/tree/3.7.0" }, - "time": "2026-08-05T19:05:32+00:00" + "time": "2026-09-07T19:35:17+00:00" }, { "name": "doctrine/persistence", @@ -3511,7 +3517,7 @@ "dist": { "type": "path", "url": "../platform", - "reference": "ed9513ca6de0563c4c40d3294ced3e794a0a3439" + "reference": "31a59df1645a6abfc4ec79c97bdd74a75d7b4338" }, "require": { "doctrine/doctrine-bundle": "^2.13 || ^3.0", @@ -3556,7 +3562,7 @@ "require-dev": { "ergebnis/composer-normalize": "^2.52", "ergebnis/phpunit-slow-test-detector": "^2.24", - "monolog/monolog": "^3.0", + "monolog/monolog": "^3.10", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.2", "phpstan/phpstan-deprecation-rules": "^2.0", @@ -3821,16 +3827,16 @@ }, { "name": "symfony/cache", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "c12be586c0e4798e649e7501aa9181ddc9d93310" + "reference": "91a2b338b18de1400ba38faae3f1e160d3eefc28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/c12be586c0e4798e649e7501aa9181ddc9d93310", - "reference": "c12be586c0e4798e649e7501aa9181ddc9d93310", + "url": "https://api.github.com/repos/symfony/cache/zipball/91a2b338b18de1400ba38faae3f1e160d3eefc28", + "reference": "91a2b338b18de1400ba38faae3f1e160d3eefc28", "shasum": "" }, "require": { @@ -3896,7 +3902,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v8.1.5" + "source": "https://github.com/symfony/cache/tree/v8.1.6" }, "funding": [ { @@ -3916,7 +3922,7 @@ "type": "tidelift" } ], - "time": "2026-08-19T08:32:28+00:00" + "time": "2026-08-30T20:10:55+00:00" }, { "name": "symfony/cache-contracts", @@ -4155,16 +4161,16 @@ }, { "name": "symfony/console", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "d07c06839e33047e2c894a6793248f3fb66c8129" + "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/d07c06839e33047e2c894a6793248f3fb66c8129", - "reference": "d07c06839e33047e2c894a6793248f3fb66c8129", + "url": "https://api.github.com/repos/symfony/console/zipball/eb7d9957d66739649e931ce7a9d05dab69f8abac", + "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac", "shasum": "" }, "require": { @@ -4231,7 +4237,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.5" + "source": "https://github.com/symfony/console/tree/v8.1.6" }, "funding": [ { @@ -4251,7 +4257,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T14:29:57+00:00" + "time": "2026-08-25T14:18:42+00:00" }, { "name": "symfony/dependency-injection", @@ -4407,16 +4413,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "1f2acb6df169495f4993851c01bf98a44fa2c59e" + "reference": "d2e36cf2c46fed6f58c7d2e07e8b083d78cc3e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/1f2acb6df169495f4993851c01bf98a44fa2c59e", - "reference": "1f2acb6df169495f4993851c01bf98a44fa2c59e", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/d2e36cf2c46fed6f58c7d2e07e8b083d78cc3e8d", + "reference": "d2e36cf2c46fed6f58c7d2e07e8b083d78cc3e8d", "shasum": "" }, "require": { @@ -4488,7 +4494,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v8.1.5" + "source": "https://github.com/symfony/doctrine-bridge/tree/v8.1.6" }, "funding": [ { @@ -4508,7 +4514,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T19:39:55+00:00" + "time": "2026-08-30T18:34:12+00:00" }, { "name": "symfony/doctrine-messenger", @@ -4978,16 +4984,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "6b2f4a0eeb28b5d74f90862592923a654bc629b3" + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/6b2f4a0eeb28b5d74f90862592923a654bc629b3", - "reference": "6b2f4a0eeb28b5d74f90862592923a654bc629b3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7599ebb855fede59413ddb7f15198d67bfcae7ba", + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba", "shasum": "" }, "require": { @@ -5025,7 +5031,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.1.5" + "source": "https://github.com/symfony/filesystem/tree/v8.1.6" }, "funding": [ { @@ -5045,7 +5051,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/finder", @@ -5190,16 +5196,16 @@ }, { "name": "symfony/form", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "0e51e9e9fb49832ea84b1f6ebdfeedc44dcde582" + "reference": "fa484bd95566ce6e4a63c099a2b1d66316eb243e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/0e51e9e9fb49832ea84b1f6ebdfeedc44dcde582", - "reference": "0e51e9e9fb49832ea84b1f6ebdfeedc44dcde582", + "url": "https://api.github.com/repos/symfony/form/zipball/fa484bd95566ce6e4a63c099a2b1d66316eb243e", + "reference": "fa484bd95566ce6e4a63c099a2b1d66316eb243e", "shasum": "" }, "require": { @@ -5263,7 +5269,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v8.1.5" + "source": "https://github.com/symfony/form/tree/v8.1.6" }, "funding": [ { @@ -5283,20 +5289,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-30T01:03:44+00:00" }, { "name": "symfony/framework-bundle", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "1c7b41364312c67376a560e35c7f7f0be0c1b4ea" + "reference": "18ea259ef00ecbd13ce87fcbed3978758154e5a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/1c7b41364312c67376a560e35c7f7f0be0c1b4ea", - "reference": "1c7b41364312c67376a560e35c7f7f0be0c1b4ea", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/18ea259ef00ecbd13ce87fcbed3978758154e5a0", + "reference": "18ea259ef00ecbd13ce87fcbed3978758154e5a0", "shasum": "" }, "require": { @@ -5407,7 +5413,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v8.1.5" + "source": "https://github.com/symfony/framework-bundle/tree/v8.1.6" }, "funding": [ { @@ -5427,20 +5433,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T19:39:55+00:00" + "time": "2026-08-30T20:10:55+00:00" }, { "name": "symfony/html-sanitizer", - "version": "v8.1.1", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "09e1f2f9a3c8dcdca072587dc71999c1921c07cb" + "reference": "f8bbdb0704e6b6e9a3412481c1a87886a0633199" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/09e1f2f9a3c8dcdca072587dc71999c1921c07cb", - "reference": "09e1f2f9a3c8dcdca072587dc71999c1921c07cb", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/f8bbdb0704e6b6e9a3412481c1a87886a0633199", + "reference": "f8bbdb0704e6b6e9a3412481c1a87886a0633199", "shasum": "" }, "require": { @@ -5479,7 +5485,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v8.1.1" + "source": "https://github.com/symfony/html-sanitizer/tree/v8.1.6" }, "funding": [ { @@ -5499,20 +5505,20 @@ "type": "tidelift" } ], - "time": "2026-06-06T11:11:44+00:00" + "time": "2026-08-30T01:03:44+00:00" }, { "name": "symfony/http-client", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "9f941ed000bb11f16dc7eafed98a7b646d3b3e5d" + "reference": "fe91dd1ddf09b61aa01e73d29907acbf1775c0a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/9f941ed000bb11f16dc7eafed98a7b646d3b3e5d", - "reference": "9f941ed000bb11f16dc7eafed98a7b646d3b3e5d", + "url": "https://api.github.com/repos/symfony/http-client/zipball/fe91dd1ddf09b61aa01e73d29907acbf1775c0a9", + "reference": "fe91dd1ddf09b61aa01e73d29907acbf1775c0a9", "shasum": "" }, "require": { @@ -5576,7 +5582,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v8.1.5" + "source": "https://github.com/symfony/http-client/tree/v8.1.6" }, "funding": [ { @@ -5596,20 +5602,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-30T14:03:40+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", - "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", "shasum": "" }, "require": { @@ -5658,7 +5664,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" }, "funding": [ { @@ -5678,20 +5684,20 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-08-04T08:41:16+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4" + "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ee16f97e95cfa011a742714d7c8c8f70fe7423f4", - "reference": "ee16f97e95cfa011a742714d7c8c8f70fe7423f4", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/093b78326f649c3a9db922b9f17123b6aeb3b8fb", + "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb", "shasum": "" }, "require": { @@ -5739,7 +5745,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.5" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.6" }, "funding": [ { @@ -5759,20 +5765,20 @@ "type": "tidelift" } ], - "time": "2026-08-20T09:59:12+00:00" + "time": "2026-08-30T20:10:55+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6" + "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/0306e1e65b90023fe40de6c6be95d06396bcb2e6", - "reference": "0306e1e65b90023fe40de6c6be95d06396bcb2e6", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", + "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", "shasum": "" }, "require": { @@ -5849,7 +5855,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.5" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.6" }, "funding": [ { @@ -5869,7 +5875,7 @@ "type": "tidelift" } ], - "time": "2026-08-22T13:45:00+00:00" + "time": "2026-08-30T21:40:49+00:00" }, { "name": "symfony/intl", @@ -6042,16 +6048,16 @@ }, { "name": "symfony/messenger", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "fa890d0632db0b5e8bc547560b412de824b3b9cd" + "reference": "fbd6c30c6b71ca95713aa3592f5112d39f9c073d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/fa890d0632db0b5e8bc547560b412de824b3b9cd", - "reference": "fa890d0632db0b5e8bc547560b412de824b3b9cd", + "url": "https://api.github.com/repos/symfony/messenger/zipball/fbd6c30c6b71ca95713aa3592f5112d39f9c073d", + "reference": "fbd6c30c6b71ca95713aa3592f5112d39f9c073d", "shasum": "" }, "require": { @@ -6110,7 +6116,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v8.1.5" + "source": "https://github.com/symfony/messenger/tree/v8.1.6" }, "funding": [ { @@ -6130,11 +6136,11 @@ "type": "tidelift" } ], - "time": "2026-08-21T20:43:01+00:00" + "time": "2026-08-30T01:03:44+00:00" }, { "name": "symfony/mime", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", @@ -6196,7 +6202,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.5" + "source": "https://github.com/symfony/mime/tree/v8.1.6" }, "funding": [ { @@ -7275,6 +7281,86 @@ ], "time": "2026-07-01T12:47:55+00:00" }, + { + "name": "symfony/polyfill-php86", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-02T13:42:24+00:00" + }, { "name": "symfony/polyfill-uuid", "version": "v1.37.0", @@ -7360,7 +7446,7 @@ }, { "name": "symfony/process", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/process.git", @@ -7401,7 +7487,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.1.5" + "source": "https://github.com/symfony/process/tree/v8.1.6" }, "funding": [ { @@ -7506,16 +7592,16 @@ }, { "name": "symfony/property-info", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "b335f8e7fb1440ed3448fb33340efc6127678e53" + "reference": "d25e1dabbaff3f91bb15ba68328cf4fbd1c43c42" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/b335f8e7fb1440ed3448fb33340efc6127678e53", - "reference": "b335f8e7fb1440ed3448fb33340efc6127678e53", + "url": "https://api.github.com/repos/symfony/property-info/zipball/d25e1dabbaff3f91bb15ba68328cf4fbd1c43c42", + "reference": "d25e1dabbaff3f91bb15ba68328cf4fbd1c43c42", "shasum": "" }, "require": { @@ -7568,7 +7654,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v8.1.5" + "source": "https://github.com/symfony/property-info/tree/v8.1.6" }, "funding": [ { @@ -7588,7 +7674,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-30T08:48:43+00:00" }, { "name": "symfony/remote-event", @@ -7661,7 +7747,7 @@ }, { "name": "symfony/routing", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", @@ -7717,7 +7803,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.1.5" + "source": "https://github.com/symfony/routing/tree/v8.1.6" }, "funding": [ { @@ -7825,16 +7911,16 @@ }, { "name": "symfony/security-bundle", - "version": "v8.1.2", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "c331a8e70da9568b26f341da8d66392e1536c797" + "reference": "b5830aca56fa40265c90bea012e28c0130b3a3f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/c331a8e70da9568b26f341da8d66392e1536c797", - "reference": "c331a8e70da9568b26f341da8d66392e1536c797", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/b5830aca56fa40265c90bea012e28c0130b3a3f0", + "reference": "b5830aca56fa40265c90bea012e28c0130b3a3f0", "shasum": "" }, "require": { @@ -7902,7 +7988,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v8.1.2" + "source": "https://github.com/symfony/security-bundle/tree/v8.1.6" }, "funding": [ { @@ -7922,20 +8008,20 @@ "type": "tidelift" } ], - "time": "2026-07-29T07:22:54+00:00" + "time": "2026-08-30T17:31:45+00:00" }, { "name": "symfony/security-core", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "5fdd9ad1449af76d537bcea471aaabaf8dbe92e7" + "reference": "efa0b10f82504d5710db3b53e234607a8ccbd2a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/5fdd9ad1449af76d537bcea471aaabaf8dbe92e7", - "reference": "5fdd9ad1449af76d537bcea471aaabaf8dbe92e7", + "url": "https://api.github.com/repos/symfony/security-core/zipball/efa0b10f82504d5710db3b53e234607a8ccbd2a1", + "reference": "efa0b10f82504d5710db3b53e234607a8ccbd2a1", "shasum": "" }, "require": { @@ -7985,7 +8071,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v8.1.5" + "source": "https://github.com/symfony/security-core/tree/v8.1.6" }, "funding": [ { @@ -8005,7 +8091,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-08-30T01:03:44+00:00" }, { "name": "symfony/security-csrf", @@ -8081,16 +8167,16 @@ }, { "name": "symfony/security-http", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "3c45aece2c527722e276cf68f5751af578e59153" + "reference": "31cbb601b78ec25a0b7c68e9a9e105c09a63f549" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/3c45aece2c527722e276cf68f5751af578e59153", - "reference": "3c45aece2c527722e276cf68f5751af578e59153", + "url": "https://api.github.com/repos/symfony/security-http/zipball/31cbb601b78ec25a0b7c68e9a9e105c09a63f549", + "reference": "31cbb601b78ec25a0b7c68e9a9e105c09a63f549", "shasum": "" }, "require": { @@ -8145,7 +8231,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v8.1.5" + "source": "https://github.com/symfony/security-http/tree/v8.1.6" }, "funding": [ { @@ -8165,20 +8251,20 @@ "type": "tidelift" } ], - "time": "2026-08-22T09:06:25+00:00" + "time": "2026-08-30T17:31:45+00:00" }, { "name": "symfony/serializer", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "9c064d505c661e3e17aa8999870f4fb0ed05e024" + "reference": "931151b12d6e2d4996ce2ce8e7b180d35455f37a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/9c064d505c661e3e17aa8999870f4fb0ed05e024", - "reference": "9c064d505c661e3e17aa8999870f4fb0ed05e024", + "url": "https://api.github.com/repos/symfony/serializer/zipball/931151b12d6e2d4996ce2ce8e7b180d35455f37a", + "reference": "931151b12d6e2d4996ce2ce8e7b180d35455f37a", "shasum": "" }, "require": { @@ -8244,7 +8330,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v8.1.5" + "source": "https://github.com/symfony/serializer/tree/v8.1.6" }, "funding": [ { @@ -8264,20 +8350,20 @@ "type": "tidelift" } ], - "time": "2026-08-22T09:06:25+00:00" + "time": "2026-08-30T08:48:47+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -8331,7 +8417,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -8351,7 +8437,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/stimulus-bundle", @@ -9676,16 +9762,16 @@ }, { "name": "symfony/validator", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "88b48d885df545a0fb1ffc25c8ebe215982168d8" + "reference": "597f234bf65d9fce67d9c74ca847b50f1b1da314" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/88b48d885df545a0fb1ffc25c8ebe215982168d8", - "reference": "88b48d885df545a0fb1ffc25c8ebe215982168d8", + "url": "https://api.github.com/repos/symfony/validator/zipball/597f234bf65d9fce67d9c74ca847b50f1b1da314", + "reference": "597f234bf65d9fce67d9c74ca847b50f1b1da314", "shasum": "" }, "require": { @@ -9749,7 +9835,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v8.1.5" + "source": "https://github.com/symfony/validator/tree/v8.1.6" }, "funding": [ { @@ -9769,20 +9855,20 @@ "type": "tidelift" } ], - "time": "2026-08-22T09:06:25+00:00" + "time": "2026-08-30T01:03:44+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "61743d9bc7ab23b194527ca1be2fafd7dc93b74a" + "reference": "3783365b58972f4779254d98372af80fbf15e170" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/61743d9bc7ab23b194527ca1be2fafd7dc93b74a", - "reference": "61743d9bc7ab23b194527ca1be2fafd7dc93b74a", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/3783365b58972f4779254d98372af80fbf15e170", + "reference": "3783365b58972f4779254d98372af80fbf15e170", "shasum": "" }, "require": { @@ -9836,7 +9922,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.5" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.6" }, "funding": [ { @@ -9856,20 +9942,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-08-30T20:10:55+00:00" }, { "name": "symfony/var-exporter", - "version": "v8.1.5", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "b8f7dd85493e8372c7c81a1547caaaf86b9c16d1" + "reference": "802362f41128c430fd6685491e1fb72127fae78a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/b8f7dd85493e8372c7c81a1547caaaf86b9c16d1", - "reference": "b8f7dd85493e8372c7c81a1547caaaf86b9c16d1", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/802362f41128c430fd6685491e1fb72127fae78a", + "reference": "802362f41128c430fd6685491e1fb72127fae78a", "shasum": "" }, "require": { @@ -9919,7 +10005,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.1.5" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.6" }, "funding": [ { @@ -9939,7 +10025,7 @@ "type": "tidelift" } ], - "time": "2026-08-13T16:11:28+00:00" + "time": "2026-08-29T06:30:03+00:00" }, { "name": "symfony/web-link", diff --git a/config/reference.php b/config/reference.php index 98e5ed5..4b916f7 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1074,7 +1074,7 @@ * limiter?: scalar|Param|null, // A service id implementing "Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface". * max_attempts?: int|Param, // Default: 5 * interval?: scalar|Param|null, // Default: "1 minute" - * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by the login rate limiter (or null to disable locking). // Default: null + * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by the login rate limiter ("auto" to use the default one when the Lock component is configured, or null to disable locking). // Default: "auto" * cache_pool?: string|Param, // The cache pool to use for storing the limiter state // Default: "cache.rate_limiter" * storage_service?: string|Param, // The service ID of a custom storage implementation, this precedes any configured "cache_pool" // Default: null * }, @@ -1264,7 +1264,7 @@ * lifetime?: int|Param, // Default: 31536000 * path?: scalar|Param|null, // Default: "/" * domain?: scalar|Param|null, // Default: null - * secure?: true|false|"auto"|Param, // Default: null + * secure?: true|false|"auto"|Param, // Default: "auto" * httponly?: bool|Param, // Default: true * samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" * always_remember_me?: bool|Param, // Default: false diff --git a/platform.yaml b/platform.yaml index 63cafee..6a49c50 100644 --- a/platform.yaml +++ b/platform.yaml @@ -1,7 +1,7 @@ # yaml-language-server: $schema=./vendor/solidworx/platform/platform-schema.json platform: name: 'SolidTrack' - version: '0.1.0-dev' + version: !php/const App\Kernel::APP_VERSION security: two_factor: diff --git a/src/Entity/TimeEntry.php b/src/Entity/TimeEntry.php index 7504c4c..accc04b 100644 --- a/src/Entity/TimeEntry.php +++ b/src/Entity/TimeEntry.php @@ -17,6 +17,7 @@ use App\Repository\TimeEntryRepository; use Carbon\CarbonImmutable; use Carbon\CarbonInterval; +use DateTimeInterface; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; @@ -25,6 +26,7 @@ use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator; use Symfony\Bridge\Doctrine\Types\UlidType; use Symfony\Component\Uid\Ulid; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: TimeEntryRepository::class)] #[ORM\Table(name: self::TABLE_NAME)] @@ -47,6 +49,7 @@ class TimeEntry implements Stringable private ?CarbonImmutable $dateStart = null; #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + #[Assert\GreaterThan(propertyPath: 'dateStart', message: 'The end time must be after the start time.')] private ?CarbonImmutable $dateEnd = null; #[ORM\Column] @@ -103,9 +106,9 @@ public function getDateStart(): ?CarbonImmutable return $this->dateStart; } - public function setDateStart(CarbonImmutable $dateStart): static + public function setDateStart(?DateTimeInterface $dateStart): static { - $this->dateStart = $dateStart; + $this->dateStart = $dateStart === null ? null : CarbonImmutable::instance($dateStart); return $this; } @@ -115,9 +118,9 @@ public function getDateEnd(): ?CarbonImmutable return $this->dateEnd; } - public function setDateEnd(CarbonImmutable $dateEnd): static + public function setDateEnd(?DateTimeInterface $dateEnd): static { - $this->dateEnd = $dateEnd; + $this->dateEnd = $dateEnd === null ? null : CarbonImmutable::instance($dateEnd); return $this; } diff --git a/src/Form/AbstractTimeEntryType.php b/src/Form/AbstractTimeEntryType.php new file mode 100644 index 0000000..1ee0c68 --- /dev/null +++ b/src/Form/AbstractTimeEntryType.php @@ -0,0 +1,117 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace App\Form; + +use App\Entity\Project; +use App\Entity\Tag; +use App\Entity\TimeEntry; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * The fields every time entry shares, however it is captured: the running + * tracker bar and the manual-entry modal both build on this. + */ +abstract class AbstractTimeEntryType extends AbstractType +{ + /** + * Deliberately outside `framework.csrf_protection.stateless_token_ids` + * (see config/packages/csrf.yaml). + * + * Stateless CSRF renders a sentinel value that the platform's + * `csrf_protection` controller only swaps for a real token on a `submit` + * event — so a LiveComponent action fired from a non-submit control (the + * tracker's Stop button) would post the sentinel, and SameOriginCsrfTokenManager + * rejects that once the session has used double-submit. Falling through to the + * session-backed manager gives these forms a real token that round-trips + * through the component's `formValues` on every kind of interaction. + */ + public const CSRF_TOKEN_ID = 'time_entry'; + + public function __construct( + private readonly TranslatorInterface $translator + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add( + 'description', + null, + [ + 'attr' => [ + 'placeholder' => $this->translator->trans('What are you working on?'), + ], + 'required' => false, + ] + ) + ->add( + 'project', + EntityType::class, + [ + 'class' => Project::class, + 'choice_label' => static fn (Project $project) => sprintf( + '%s', + htmlspecialchars($project->getColor(), \ENT_QUOTES), + htmlspecialchars($project->getName(), \ENT_QUOTES), + ), + 'group_by' => static fn (Project $project) => $project->getClient()?->getName(), + 'options_as_html' => true, + 'autocomplete' => true, + 'placeholder' => $this->translator->trans('Select a project'), + 'required' => false, + ] + ) + ->add( + 'tags', + EntityType::class, + [ + 'class' => Tag::class, + 'choice_label' => static fn (Tag $tag) => sprintf( + '%s', + htmlspecialchars($tag->getColor(), \ENT_QUOTES), + htmlspecialchars($tag->getName(), \ENT_QUOTES), + ), + 'options_as_html' => true, + 'multiple' => true, + 'expanded' => false, + 'required' => false, + 'autocomplete' => true, + 'placeholder' => $this->translator->trans('Add tags...'), + 'label' => false, + ] + ) + ->add( + 'billable', + CheckboxType::class, + [ + 'required' => false, + 'label' => false, + ] + ); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => TimeEntry::class, + 'csrf_token_id' => self::CSRF_TOKEN_ID, + ]); + } +} diff --git a/src/Form/ManualTimeEntryType.php b/src/Form/ManualTimeEntryType.php new file mode 100644 index 0000000..583f82c --- /dev/null +++ b/src/Form/ManualTimeEntryType.php @@ -0,0 +1,114 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace App\Form; + +use App\Entity\Tag; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Capturing a period that has already been worked, rather than timing it live. + * + * The two datetime fields hand over plain DateTimeImmutable instances; TimeEntry's + * setters normalise those to Carbon. "End after start" is enforced on the entity + * so the running tracker (which has no end yet) is covered by the same rule. + */ +final class ManualTimeEntryType extends AbstractTimeEntryType +{ + public function __construct( + private readonly TranslatorInterface $translator + ) { + parent::__construct($translator); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + parent::buildForm($builder, $options); + + $builder + // Same fields as the tracker bar, reworded for work already done and + // given the labels a stacked form needs (the tracker bar has none). + ->add( + 'description', + null, + [ + 'label' => $this->translator->trans('What did you work on?'), + 'attr' => [ + 'placeholder' => $this->translator->trans('Description'), + ], + 'required' => false, + ] + ) + // Symfony drops `placeholder` for a multiple choice field, so TomSelect + // has nothing to show in an empty tags box. The attribute survives, and + // TomSelect reads it off the select. + ->add( + 'tags', + EntityType::class, + [ + 'class' => Tag::class, + 'choice_label' => static fn (Tag $tag) => sprintf( + '%s', + htmlspecialchars($tag->getColor(), \ENT_QUOTES), + htmlspecialchars($tag->getName(), \ENT_QUOTES), + ), + 'options_as_html' => true, + 'multiple' => true, + 'expanded' => false, + 'required' => false, + 'autocomplete' => true, + 'label' => false, + 'attr' => [ + 'placeholder' => $this->translator->trans('Add tags...'), + ], + ] + ) + ->add( + 'billable', + CheckboxType::class, + [ + 'required' => false, + 'label' => $this->translator->trans('Billable'), + ] + ) + ->add( + 'dateStart', + DateTimeType::class, + [ + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + 'label' => $this->translator->trans('Starts at'), + 'constraints' => [ + new NotNull(message: 'Enter when the work started.'), + ], + ] + ) + ->add( + 'dateEnd', + DateTimeType::class, + [ + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + 'label' => $this->translator->trans('Ends at'), + 'constraints' => [ + new NotNull(message: 'Enter when the work ended.'), + ], + ] + ); + } +} diff --git a/src/Form/TimeTrackerType.php b/src/Form/TimeTrackerType.php index 26dd6e2..d0383cf 100644 --- a/src/Form/TimeTrackerType.php +++ b/src/Form/TimeTrackerType.php @@ -1,5 +1,7 @@ add( - 'description', - null, - [ - 'attr' => [ - 'placeholder' => $this->translator->trans('What are you working on?'), - ], - 'required' => false, - ] - ) - ->add( - 'project', - EntityType::class, - [ - 'class' => Project::class, - 'choice_label' => static fn (Project $project) => sprintf( - '%s', - htmlspecialchars($project->getColor(), \ENT_QUOTES), - htmlspecialchars($project->getName(), \ENT_QUOTES), - ), - 'group_by' => static fn (Project $project) => $project->getClient()?->getName(), - 'options_as_html' => true, - 'autocomplete' => true, - 'placeholder' => $this->translator->trans('Select a project'), - 'required' => false, - ] - ) - ->add( - 'tags', - EntityType::class, - [ - 'class' => Tag::class, - 'choice_label' => static fn (Tag $tag) => sprintf( - '%s', - htmlspecialchars($tag->getColor(), \ENT_QUOTES), - htmlspecialchars($tag->getName(), \ENT_QUOTES), - ), - 'options_as_html' => true, - 'multiple' => true, - 'expanded' => false, - 'required' => false, - 'autocomplete' => true, - 'placeholder' => $this->translator->trans('Add tags...'), - 'label' => false, - ] - ) - ->add( - 'billable', - CheckboxType::class, - [ - 'required' => false, - 'label' => false, - ] - ); - } - - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'data_class' => TimeEntry::class, - // This form is driven by a LiveComponent, which has its own CSRF - // protection at the action level. Form-level CSRF would also reject - // submitForm() calls triggered by non-submit interactions (e.g. the - // stop button is an , not a form submit). - 'csrf_protection' => false, - ]); - } } diff --git a/src/Kernel.php b/src/Kernel.php index 1305f51..26f1e65 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -16,7 +16,7 @@ final class Kernel extends BaseKernel { - public const APP_VERSION = '0.1.0-dev'; + public const string APP_VERSION = '0.1.0-dev'; public function boot(): void { diff --git a/src/Twig/Components/ManualTimeEntry.php b/src/Twig/Components/ManualTimeEntry.php new file mode 100644 index 0000000..cc50ecc --- /dev/null +++ b/src/Twig/Components/ManualTimeEntry.php @@ -0,0 +1,186 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace App\Twig\Components; + +use App\Entity\TimeEntry; +use App\Entity\User; +use App\Enum\TimeEntryStatus; +use App\Enum\TimeEntryType; +use App\Form\ManualTimeEntryType; +use App\Repository\ProjectRepository; +use App\Repository\TimeEntryRepository; +use App\Time\Duration; +use Carbon\CarbonInterval; +use LogicException; +use Override; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Contracts\Translation\TranslatorInterface; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\ComponentWithFormTrait; +use Symfony\UX\LiveComponent\DefaultActionTrait; +use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; + +/** + * The form behind the "Add time entry" modal, for logging work that was not + * timed live. + * + * Rendered *inside* the modal body rather than around it: Live rewrites the class + * attribute of its own root element on every morph, which would strip the `show` + * class Bootstrap puts on an open modal. Keeping the component root within the body + * leaves open/close entirely to Bootstrap. + */ +#[AsLiveComponent] +final class ManualTimeEntry extends AbstractController +{ + use DefaultActionTrait; + use ComponentWithFormTrait; + + /** + * Where to send the browser after a successful save. Not writable, so Live's + * prop checksum keeps it to the value the page rendered. + */ + #[LiveProp] + public ?string $returnUrl = null; + + public function __construct( + private readonly TimeEntryRepository $timeEntryRepository, + private readonly ProjectRepository $projectRepository, + private readonly Security $security, + private readonly TranslatorInterface $translator, + ) { + } + + #[Override] + protected function instantiateForm(): FormInterface + { + return $this->createForm(ManualTimeEntryType::class, new TimeEntry()); + } + + /** + * project id => client id, published to the modal so opening it from a client + * row can narrow the project list to that client's projects. + * + * Sent as data rather than filtered server-side because the project field sits + * behind `data-live-ignore`: a re-render cannot replace its options, so the + * narrowing has to happen through TomSelect's own API. + * + * @return array + */ + #[ExposeInTemplate] + public function projectClients(): array + { + $map = []; + + foreach ($this->projectRepository->findAll() as $project) { + $clientId = $project->getClient()?->getId(); + + if ($clientId !== null) { + $map[(string) $project->getId()] = (string) $clientId; + } + } + + return $map; + } + + /** + * The period the form currently describes, for the hint under the fields. + * + * Null while either end is missing or the period is inverted — the form reports + * those as validation errors, so the hint has nothing useful to add. + */ + #[ExposeInTemplate] + public function duration(): ?CarbonInterval + { + $entry = $this->getForm()->getData(); + + if (! $entry instanceof TimeEntry) { + return null; + } + + $start = $entry->getDateStart(); + $end = $entry->getDateEnd(); + + if ($start === null || $end === null || $end <= $start) { + return null; + } + + return $entry->getDuration(); + } + + /** + * Redirects rather than re-rendering in place, matching UserActivity::resumeEntry() + * and for the same reason: the project and tag cells sit behind `data-live-ignore` + * because TomSelect owns that DOM, so a morph cannot clear them once saved. The + * full render also refreshes whichever lists the page happens to show. + */ + #[LiveAction] + public function save(): RedirectResponse + { + $user = $this->currentUser(); + + $this->submitForm(); + + /** @var TimeEntry $entry */ + $entry = $this->getForm()->getData(); + $entry + ->setUser($user) + ->setStatus(TimeEntryStatus::COMPLETED) + ->setEntryType(TimeEntryType::MANUAL); + + $this->timeEntryRepository->save($entry); + + $this->addFlash( + 'success', + $this->translator->trans('Added %duration% to %entry%', [ + // Same short rendering as the format_interval Twig helper, so the + // flash reads like the durations in the list behind it. + '%duration%' => Duration::fromHours($entry->getDuration()?->totalHours ?? 0.0) + ->forHumans(short: true, parts: 3), + '%entry%' => (string) $entry, + ]), + ); + + return $this->redirect($this->safeReturnUrl()); + } + + /** + * The prop is checksummed, but keep the redirect to a path on this host + * regardless — a redirect target is not somewhere to rely on a single guard. + */ + private function safeReturnUrl(): string + { + $url = $this->returnUrl ?? ''; + + if (! str_starts_with($url, '/') || str_starts_with($url, '//')) { + return $this->generateUrl('dashboard'); + } + + return $url; + } + + private function currentUser(): User + { + $user = $this->security->getUser(); + if (! $user instanceof User) { + throw new LogicException('ManualTimeEntry requires an authenticated User.'); + } + + return $user; + } +} diff --git a/templates/client/index.html.twig b/templates/client/index.html.twig index 5747cf3..f50156e 100644 --- a/templates/client/index.html.twig +++ b/templates/client/index.html.twig @@ -14,4 +14,6 @@ {% block content %} + + {{ include('components/_manual_entry_modal.html.twig') }} {% endblock %} diff --git a/templates/components/ClientList.html.twig b/templates/components/ClientList.html.twig index e36eecd..6ec4314 100644 --- a/templates/components/ClientList.html.twig +++ b/templates/components/ClientList.html.twig @@ -56,6 +56,11 @@ + + {{ form_end(form) }} + diff --git a/templates/components/ProjectList.html.twig b/templates/components/ProjectList.html.twig index 905d7d4..d11f08c 100644 --- a/templates/components/ProjectList.html.twig +++ b/templates/components/ProjectList.html.twig @@ -61,6 +61,11 @@
+ {{ include('components/_manual_entry_button.html.twig', { + 'project': row.project, + 'label': '', + 'class': 'btn-sm', + }) }} {{ ux_icon('tabler:eye', {class: 'icon'}) }} {{ 'View'|trans }} diff --git a/templates/components/_manual_entry_button.html.twig b/templates/components/_manual_entry_button.html.twig new file mode 100644 index 0000000..1949121 --- /dev/null +++ b/templates/components/_manual_entry_button.html.twig @@ -0,0 +1,25 @@ +{# Trigger for the "Add time entry" modal (_manual_entry_modal.html.twig must be + included somewhere on the same page). + + Params: + project - optional Project to pre-select + client - optional Client to narrow the project list to + label - optional text; omit for an icon-only button + class - optional extra classes, e.g. 'btn-primary' or 'btn-sm' #} +{% set label = label is defined ? label : 'Add time entry'|trans %} + + diff --git a/templates/components/_manual_entry_modal.html.twig b/templates/components/_manual_entry_modal.html.twig new file mode 100644 index 0000000..a343d75 --- /dev/null +++ b/templates/components/_manual_entry_modal.html.twig @@ -0,0 +1,18 @@ +{# The "Add time entry" modal. Include once per page; triggers are plain buttons + pointing at it (see _manual_entry_button.html.twig), so a page can carry any + number of them — including one per table row — without repeating the modal. + + The controllers go on a wrapper rather than on itself: that + component renders its own stimulus_controller() after {{ attributes }}, so a + data-controller passed in as an attribute wins the duplicate and silently drops + the platform's own modal controller. Bootstrap's modal events bubble, so a + wrapper listens just as well. #} +
+ + + +
diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig index 342b788..0328ec8 100644 --- a/templates/dashboard/index.html.twig +++ b/templates/dashboard/index.html.twig @@ -13,6 +13,7 @@ {% block page_title %}{{ greeting }}, {{ display_name }}{% endblock %} {% block page_title_actions %} + {{ include('components/_manual_entry_button.html.twig') }}
{{ ux_icon('tabler:calendar', {class: 'icon'}) }} @@ -40,4 +41,6 @@
+ + {{ include('components/_manual_entry_modal.html.twig') }} {% endblock content %} diff --git a/templates/project/index.html.twig b/templates/project/index.html.twig index 3605b3d..874e211 100644 --- a/templates/project/index.html.twig +++ b/templates/project/index.html.twig @@ -14,4 +14,6 @@ {% block content %} + + {{ include('components/_manual_entry_modal.html.twig') }} {% endblock %} diff --git a/templates/project/show.html.twig b/templates/project/show.html.twig index d315d9d..25b82f3 100644 --- a/templates/project/show.html.twig +++ b/templates/project/show.html.twig @@ -25,6 +25,7 @@ {% endblock %} {% block page_title_actions %} + {{ include('components/_manual_entry_button.html.twig', {'project': project}) }} {{ ux_icon('tabler:pencil', {class: 'icon'}) }} {{ 'Edit project'|trans }} @@ -85,4 +86,6 @@ + + {{ include('components/_manual_entry_modal.html.twig') }} {% endblock %} diff --git a/templates/report/index.html.twig b/templates/report/index.html.twig index 750176b..e63b606 100644 --- a/templates/report/index.html.twig +++ b/templates/report/index.html.twig @@ -15,6 +15,7 @@ {% endblock %} {% block page_title_actions %} + {{ include('components/_manual_entry_button.html.twig') }}
@@ -35,4 +36,6 @@ {% else %} {% endif %} + + {{ include('components/_manual_entry_modal.html.twig') }} {% endblock %} diff --git a/tests/Controller/ManualEntryModalPageTest.php b/tests/Controller/ManualEntryModalPageTest.php new file mode 100644 index 0000000..64e3115 --- /dev/null +++ b/tests/Controller/ManualEntryModalPageTest.php @@ -0,0 +1,156 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace App\Test\Controller; + +use App\Entity\Client; +use App\Entity\Project; +use App\Entity\User; +use App\Twig\Components\ManualTimeEntry; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +/** + * The manual-entry modal is shared by every page that offers to capture time, so + * what matters per page is that a trigger is present and that exactly one modal + * is rendered for all the triggers on it to point at. + */ +#[CoversClass(ManualTimeEntry::class)] +final class ManualEntryModalPageTest extends WebTestCase +{ + /** + * @return iterable + */ + public static function pageProvider(): iterable + { + yield 'dashboard' => ['/dashboard']; + yield 'projects list' => ['/projects/']; + yield 'clients list' => ['/clients/']; + yield 'detailed report' => ['/reports?type=detailed']; + yield 'summary report' => ['/reports?type=summary']; + } + + #[DataProvider('pageProvider')] + public function testThePageOffersTheManualEntryModal(string $path): void + { + $browser = $this->browserWithFixtures(); + $browser->request('GET', $path); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('button[data-bs-target="#manual-time-entry"]'); + self::assertSelectorExists('#manual-time-entry'); + self::assertSelectorTextContains('#manual-time-entry', 'Starts at'); + self::assertSelectorTextContains('#manual-time-entry', 'Ends at'); + } + + public function testTheProjectPageTriggerPreSelectsThatProject(): void + { + $browser = $this->browserWithFixtures(); + $project = $this->findProject(); + + $browser->request('GET', '/projects/' . $project->getId()); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists( + sprintf('button[data-bs-target="#manual-time-entry"][data-prefill-project="%s"]', $project->getId()) + ); + } + + public function testTheProjectsListOffersOneTriggerPerRowAndASingleModal(): void + { + $browser = $this->browserWithFixtures(); + $crawler = $browser->request('GET', '/projects/'); + + self::assertResponseIsSuccessful(); + self::assertCount(1, $crawler->filter('#manual-time-entry')); + self::assertGreaterThanOrEqual( + 1, + $crawler->filter('button[data-bs-target="#manual-time-entry"][data-prefill-project]')->count() + ); + } + + public function testTheClientsListTriggerNarrowsToThatClient(): void + { + $browser = $this->browserWithFixtures(); + $crawler = $browser->request('GET', '/clients/'); + + self::assertResponseIsSuccessful(); + + $client = $this->findClient(); + self::assertSelectorExists( + sprintf('button[data-bs-target="#manual-time-entry"][data-prefill-client="%s"]', $client->getId()) + ); + + // The narrowing is done in the browser, so the modal has to carry the + // project => client map for the controller to work from. + $map = $crawler->filter('[data-project-clients]')->attr('data-project-clients'); + self::assertIsString($map); + + $decoded = json_decode($map, true, 512, \JSON_THROW_ON_ERROR); + self::assertIsArray($decoded); + self::assertArrayHasKey((string) $this->findProject()->getId(), $decoded); + self::assertSame((string) $client->getId(), $decoded[(string) $this->findProject()->getId()]); + } + + private function browserWithFixtures(): KernelBrowser + { + $browser = static::createClient(); + $em = static::getContainer()->get('doctrine')->getManager(); + \assert($em instanceof EntityManagerInterface); + + $user = new User(); + $user->setEmail('owner@example.test')->setEnabled(true)->setVerified(true)->setRoles(['ROLE_USER']); + $user->setPassword('hashed'); + $em->persist($user); + + $client = new Client(); + $client->setName('Acme')->setCurrency('USD'); + $em->persist($client); + + $project = new Project(); + $project->setName('Marketing Website'); + $project->setClient($client); + $em->persist($project); + + $em->flush(); + + $browser->loginUser($user); + + return $browser; + } + + private function findProject(): Project + { + $em = static::getContainer()->get('doctrine')->getManager(); + \assert($em instanceof EntityManagerInterface); + + $project = $em->getRepository(Project::class)->findOneBy(['name' => 'Marketing Website']); + \assert($project instanceof Project); + + return $project; + } + + private function findClient(): Client + { + $em = static::getContainer()->get('doctrine')->getManager(); + \assert($em instanceof EntityManagerInterface); + + $client = $em->getRepository(Client::class)->findOneBy(['name' => 'Acme']); + \assert($client instanceof Client); + + return $client; + } +} diff --git a/tests/Entity/TimeEntryTest.php b/tests/Entity/TimeEntryTest.php index 7aaa4f9..86579dd 100644 --- a/tests/Entity/TimeEntryTest.php +++ b/tests/Entity/TimeEntryTest.php @@ -16,6 +16,7 @@ use App\Entity\Project; use App\Entity\TimeEntry; use Carbon\CarbonImmutable; +use DateTimeImmutable; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -64,4 +65,37 @@ public function testStringCastTreatsAnEmptyDescriptionAsMissing(): void self::assertSame('Acme redesign', (string) $entry); } + + public function testDateSettersNormalisePlainDateTimesToCarbon(): void + { + $entry = (new TimeEntry()) + ->setDateStart(new DateTimeImmutable('2026-08-30 09:00:00')) + ->setDateEnd(new DateTimeImmutable('2026-08-30 11:30:00')); + + // The form layer hands over plain DateTimeImmutable instances, but the + // templates and UserActivity call Carbon-only methods on these values. + self::assertInstanceOf(CarbonImmutable::class, $entry->getDateStart()); + self::assertInstanceOf(CarbonImmutable::class, $entry->getDateEnd()); + self::assertTrue($entry->getDateStart()->isSameAs('Y-m-d H:i', CarbonImmutable::parse('2026-08-30 09:00:00'))); + } + + public function testDateSettersAcceptNull(): void + { + // The manual-entry form maps an empty datetime field to null before the + // NotNull constraint gets a chance to report it, so the setters must not + // blow up on the way through. + $entry = (new TimeEntry())->setDateStart(null)->setDateEnd(null); + + self::assertNull($entry->getDateStart()); + self::assertNull($entry->getDateEnd()); + } + + public function testDateSettersPreserveCarbonInstancesUnchanged(): void + { + $start = CarbonImmutable::parse('2026-08-30 09:00:00'); + + $entry = (new TimeEntry())->setDateStart($start); + + self::assertTrue($start->equalTo($entry->getDateStart())); + } } diff --git a/tests/Twig/Components/ManualTimeEntryTest.php b/tests/Twig/Components/ManualTimeEntryTest.php new file mode 100644 index 0000000..f9060f4 --- /dev/null +++ b/tests/Twig/Components/ManualTimeEntryTest.php @@ -0,0 +1,282 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace App\Test\Twig\Components; + +use App\Entity\Client; +use App\Entity\Project; +use App\Entity\Tag; +use App\Entity\TimeEntry; +use App\Entity\User; +use App\Enum\TimeEntryStatus; +use App\Enum\TimeEntryType; +use App\Form\AbstractTimeEntryType; +use App\Repository\TimeEntryRepository; +use App\Twig\Components\ManualTimeEntry; +use Doctrine\ORM\EntityManagerInterface; +use LogicException; +use PHPUnit\Framework\Attributes\CoversClass; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; + +/** + * Covers capturing a time entry by hand, for work that was not timed live. + * + * Driven through the component object so the save action and its validation can be + * exercised directly; ManualEntryModalPageTest covers the rendered side over HTTP. + */ +#[CoversClass(ManualTimeEntry::class)] +final class ManualTimeEntryTest extends KernelTestCase +{ + private EntityManagerInterface $em; + + private TimeEntryRepository $repository; + + private ManualTimeEntry $component; + + protected function setUp(): void + { + self::bootKernel(); + $container = self::getContainer(); + + $manager = $container->get('doctrine')->getManager(); + \assert($manager instanceof EntityManagerInterface); + $this->em = $manager; + $this->repository = $this->em->getRepository(TimeEntry::class); + + // addFlash() and the session-backed CSRF token manager both need a session. + $request = new Request(); + $request->setSession(new Session(new MockArraySessionStorage())); + $requestStack = $container->get(RequestStack::class); + \assert($requestStack instanceof RequestStack); + $requestStack->push($request); + + $component = $container->get(ManualTimeEntry::class); + \assert($component instanceof ManualTimeEntry); + $this->component = $component; + } + + public function testSaveCapturesACompletedManualEntryForTheCurrentUser(): void + { + $user = $this->createUser('owner@example.test'); + $project = $this->createProject('Website'); + $tag = $this->createTag('manual-test-' . bin2hex(random_bytes(4))); + $this->em->flush(); + + $this->authenticate($user); + $this->component->returnUrl = '/dashboard'; + $this->component->formValues = $this->formValues([ + 'description' => 'Refactoring the invoice parser', + 'project' => (string) $project->getId(), + 'tags' => [(string) $tag->getId()], + 'billable' => '1', + 'dateStart' => '2026-09-01T09:00', + 'dateEnd' => '2026-09-01T11:30', + ]); + + $response = $this->component->save(); + $this->em->flush(); + + self::assertSame('/dashboard', $response->getTargetUrl()); + + $entries = $this->repository->findBy(['user' => $user]); + self::assertCount(1, $entries); + + $entry = $entries[0]; + self::assertSame('Refactoring the invoice parser', $entry->getDescription()); + self::assertSame($project->getId()?->toRfc4122(), $entry->getProject()?->getId()?->toRfc4122()); + self::assertTrue($entry->isBillable()); + self::assertSame(TimeEntryStatus::COMPLETED, $entry->getStatus()); + self::assertSame(TimeEntryType::MANUAL, $entry->getEntryType()); + self::assertSame('2026-09-01 09:00', $entry->getDateStart()?->format('Y-m-d H:i')); + self::assertSame('2026-09-01 11:30', $entry->getDateEnd()?->format('Y-m-d H:i')); + self::assertSame(150.0, $entry->getDuration()?->totalMinutes); + self::assertCount(1, $entry->getTags()); + self::assertSame($tag->getName(), $entry->getTags()->first()->getName()); + } + + public function testSaveCapturesAnEntryWithoutAProjectOrDescription(): void + { + $user = $this->createUser('owner@example.test'); + $this->em->flush(); + + $this->authenticate($user); + $this->component->formValues = $this->formValues([ + 'dateStart' => '2026-09-01T09:00', + 'dateEnd' => '2026-09-01T09:45', + ]); + + $this->component->save(); + $this->em->flush(); + + $entries = $this->repository->findBy(['user' => $user]); + self::assertCount(1, $entries); + self::assertNull($entries[0]->getProject()); + self::assertFalse($entries[0]->isBillable()); + } + + public function testSaveRejectsAnEndBeforeTheStart(): void + { + $user = $this->createUser('owner@example.test'); + $this->em->flush(); + + $this->authenticate($user); + $this->component->formValues = $this->formValues([ + 'dateStart' => '2026-09-01T11:30', + 'dateEnd' => '2026-09-01T09:00', + ]); + + $this->expectException(UnprocessableEntityHttpException::class); + $this->expectExceptionMessageMatches('/end time must be after the start time/'); + + $this->component->save(); + } + + public function testSaveRejectsAnEndEqualToTheStart(): void + { + $user = $this->createUser('owner@example.test'); + $this->em->flush(); + + $this->authenticate($user); + $this->component->formValues = $this->formValues([ + 'dateStart' => '2026-09-01T09:00', + 'dateEnd' => '2026-09-01T09:00', + ]); + + $this->expectException(UnprocessableEntityHttpException::class); + + $this->component->save(); + } + + public function testSaveRequiresBothEndsOfThePeriod(): void + { + $user = $this->createUser('owner@example.test'); + $this->em->flush(); + + $this->authenticate($user); + $this->component->formValues = $this->formValues([ + 'dateStart' => '2026-09-01T09:00', + 'dateEnd' => '', + ]); + + $this->expectException(UnprocessableEntityHttpException::class); + $this->expectExceptionMessageMatches('/when the work ended/'); + + $this->component->save(); + } + + public function testSaveRejectsAForgedCsrfToken(): void + { + $user = $this->createUser('owner@example.test'); + $this->em->flush(); + + $this->authenticate($user); + $values = $this->formValues([ + 'dateStart' => '2026-09-01T09:00', + 'dateEnd' => '2026-09-01T11:30', + ]); + $values['_token'] = 'forged'; + $this->component->formValues = $values; + + $this->expectException(UnprocessableEntityHttpException::class); + $this->expectExceptionMessageMatches('/CSRF token is invalid/'); + + $this->component->save(); + } + + public function testSaveWithoutAnAuthenticatedUserIsRefused(): void + { + $this->component->formValues = $this->formValues([ + 'dateStart' => '2026-09-01T09:00', + 'dateEnd' => '2026-09-01T11:30', + ]); + + $this->expectException(LogicException::class); + + $this->component->save(); + } + + /** + * Mirrors what the browser posts back: every field the form renders, plus a + * real CSRF token for the entry forms' own (session-backed) token id. + * + * @param array $overrides + * + * @return array + */ + private function formValues(array $overrides = []): array + { + $csrf = self::getContainer()->get(CsrfTokenManagerInterface::class); + \assert($csrf instanceof CsrfTokenManagerInterface); + + return array_merge([ + 'description' => '', + 'project' => '', + 'tags' => [], + 'billable' => null, + 'dateStart' => '', + 'dateEnd' => '', + '_token' => $csrf->getToken(AbstractTimeEntryType::CSRF_TOKEN_ID)->getValue(), + ], $overrides); + } + + private function authenticate(User $user): void + { + $tokenStorage = self::getContainer()->get(TokenStorageInterface::class); + \assert($tokenStorage instanceof TokenStorageInterface); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + } + + private function createUser(string $email): User + { + $user = new User(); + $user->setEmail($email) + ->setEnabled(true) + ->setVerified(true) + ->setRoles(['ROLE_USER']); + $user->setPassword('hashed'); + $this->em->persist($user); + + return $user; + } + + private function createProject(string $name): Project + { + $client = new Client(); + $client->setName('Acme')->setCurrency('USD'); + $this->em->persist($client); + + $project = new Project(); + $project->setName($name); + $project->setClient($client); + $this->em->persist($project); + + return $project; + } + + private function createTag(string $name): Tag + { + $tag = new Tag(); + $tag->setName($name); + $this->em->persist($tag); + + return $tag; + } +}