From 214019fb01aa17f47300ac61541b2970fdf4427a Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Wed, 9 Sep 2026 13:23:32 +0200 Subject: [PATCH 1/5] Add manual time entry form --- assets/controllers.json | 4 + assets/controllers/manual_entry_controller.ts | 108 +++++++ platform.yaml | 2 +- src/Entity/TimeEntry.php | 11 +- src/Form/AbstractTimeEntryType.php | 117 ++++++++ src/Form/ManualTimeEntryType.php | 114 +++++++ src/Form/TimeTrackerType.php | 93 +----- src/Kernel.php | 2 +- src/Twig/Components/ManualTimeEntry.php | 186 ++++++++++++ templates/client/index.html.twig | 2 + templates/components/ClientList.html.twig | 5 + .../components/ManualTimeEntry.html.twig | 62 ++++ templates/components/ProjectList.html.twig | 5 + .../components/_manual_entry_button.html.twig | 20 ++ .../components/_manual_entry_modal.html.twig | 18 ++ templates/dashboard/index.html.twig | 3 + templates/project/index.html.twig | 2 + templates/project/show.html.twig | 3 + templates/report/index.html.twig | 3 + tests/Controller/ManualEntryModalPageTest.php | 156 ++++++++++ tests/Entity/TimeEntryTest.php | 34 +++ tests/Twig/Components/ManualTimeEntryTest.php | 282 ++++++++++++++++++ 22 files changed, 1140 insertions(+), 92 deletions(-) create mode 100644 assets/controllers/manual_entry_controller.ts create mode 100644 src/Form/AbstractTimeEntryType.php create mode 100644 src/Form/ManualTimeEntryType.php create mode 100644 src/Twig/Components/ManualTimeEntry.php create mode 100644 templates/components/ManualTimeEntry.html.twig create mode 100644 templates/components/_manual_entry_button.html.twig create mode 100644 templates/components/_manual_entry_modal.html.twig create mode 100644 tests/Controller/ManualEntryModalPageTest.php create mode 100644 tests/Twig/Components/ManualTimeEntryTest.php 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/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..94deaa0 100644 --- a/templates/components/ClientList.html.twig +++ b/templates/components/ClientList.html.twig @@ -56,6 +56,11 @@
+ {{ include('components/_manual_entry_button.html.twig', { + 'client': row.client, + 'label': '', + 'class': 'btn-sm btn-icon', + }) }} {{ ux_icon('tabler:pencil', {class: 'icon'}) }} {{ 'Edit'|trans }} diff --git a/templates/components/ManualTimeEntry.html.twig b/templates/components/ManualTimeEntry.html.twig new file mode 100644 index 0000000..7345047 --- /dev/null +++ b/templates/components/ManualTimeEntry.html.twig @@ -0,0 +1,62 @@ +
+ {{ form_start(form, { + attr: { + 'data-action': 'live#action:prevent', + 'data-live-action-param': 'save', + }, + }) }} + + {{ form_row(form.description, {'attr': {'autocomplete': 'off'}}) }} + +
+ {# TomSelect owns the DOM inside these two wrappers, so Live must not + morph them — same arrangement as the tracker bar. The modal-prefill + controller talks to TomSelect's own API to seed the project. #} +
+ +
+ {{ form_widget(form.project) }} +
+
+
+ +
+ {{ form_widget(form.tags) }} +
+
+
+ +
+
+ {{ form_row(form.dateStart) }} +
+
+ {{ form_row(form.dateEnd) }} + {% if duration %} +
+ {{ ux_icon('tabler:clock', {class: 'icon icon-sm'}) }} + +
+ {% endif %} +
+
+ +
+ {{ form_row(form.billable) }} +
+ + {# The action row lives inside the component, not the modal's footer block: + a button outside the component root would be beyond Live's reach. #} +
+ + +
+ + {{ form_end(form) }} +
diff --git a/templates/components/ProjectList.html.twig b/templates/components/ProjectList.html.twig index 905d7d4..cd4b032 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 btn-icon', + }) }} {{ 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..502617e --- /dev/null +++ b/templates/components/_manual_entry_button.html.twig @@ -0,0 +1,20 @@ +{# 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..60baead --- /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..2fd2ffc --- /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('2 hours 30 minutes', (string) $entry->getDuration()); + 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; + } +} From 28fc604a8b1bd3082a05c0a0753eb0ab0c999d82 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Wed, 9 Sep 2026 13:33:19 +0200 Subject: [PATCH 2/5] Fix collapsed manual time entry button in the project and client grids The icon-only trigger rendered as a 2px sliver next to Edit/Delete. Tabler's `.btn-icon` negative-margins the icon by the button's padding-x on all four sides, expecting `.btn`'s min-width to absorb it. That min-width calc references `--tblr-btn-line-height`, which is unset here, so it resolves to `auto`, the icon's width cancels out, and the button collapses to its borders. Use a plain `btn-sm` instead, which sizes from the icon's own min-width. Also whitespace-control the label so an empty one leaves no stray text node beside the icon. --- templates/components/ClientList.html.twig | 2 +- templates/components/ProjectList.html.twig | 2 +- templates/components/_manual_entry_button.html.twig | 9 +++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/templates/components/ClientList.html.twig b/templates/components/ClientList.html.twig index 94deaa0..6ec4314 100644 --- a/templates/components/ClientList.html.twig +++ b/templates/components/ClientList.html.twig @@ -59,7 +59,7 @@ {{ include('components/_manual_entry_button.html.twig', { 'client': row.client, 'label': '', - 'class': 'btn-sm btn-icon', + 'class': 'btn-sm', }) }} {{ ux_icon('tabler:pencil', {class: 'icon'}) }} diff --git a/templates/components/ProjectList.html.twig b/templates/components/ProjectList.html.twig index cd4b032..d11f08c 100644 --- a/templates/components/ProjectList.html.twig +++ b/templates/components/ProjectList.html.twig @@ -64,7 +64,7 @@ {{ include('components/_manual_entry_button.html.twig', { 'project': row.project, 'label': '', - 'class': 'btn-sm btn-icon', + 'class': 'btn-sm', }) }} {{ ux_icon('tabler:eye', {class: 'icon'}) }} diff --git a/templates/components/_manual_entry_button.html.twig b/templates/components/_manual_entry_button.html.twig index 502617e..1949121 100644 --- a/templates/components/_manual_entry_button.html.twig +++ b/templates/components/_manual_entry_button.html.twig @@ -15,6 +15,11 @@ {% if project is defined and project %}data-prefill-project="{{ project.id }}"{% endif %} {% if client is defined and client %}data-prefill-client="{{ client.id }}"{% endif %} {% if label is empty %}aria-label="{{ 'Add time entry'|trans }}" title="{{ 'Add time entry'|trans }}"{% endif %}> - {{ ux_icon('tabler:clock-plus', {class: 'icon'}) }} - {{ label }} + {#- Whitespace-controlled so an empty label leaves no stray text node beside the + icon. Note for icon-only use: pass `btn-sm` alone, NOT Tabler's `.btn-icon` — + that class negative-margins the icon by the button's padding-x and relies on + `.btn`'s min-width to absorb it, but that min-width calc resolves to `auto` + here, so the icon's width cancels out and the button collapses to its borders. -#} + {{- ux_icon('tabler:clock-plus', {class: 'icon'}) -}} + {%- if label is not empty %} {{ label }}{% endif -%} From 9bf638e9235233815f2a1795e1c413521001b019 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Wed, 9 Sep 2026 13:51:43 +0200 Subject: [PATCH 3/5] Fix collapsed cancel button in the inline rate editor Same `btn-sm btn-icon` collapse as the manual time entry trigger: with `btn-sm` the button's min-width resolves to 0, the icon's negative margins cancel its width, and the button renders as a 2px sliver. Measured 2x12 before, 36x28 after. `btn-icon` is fine at the default size (min-width 40px), so the mobile-only "Create new" buttons on the project, client and tag index pages are unaffected. Claude-Session: https://claude.ai/code/session_01M1uNeTMKW9ZWuRtKvTpR95 --- templates/components/InlineRateEdit.html.twig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/templates/components/InlineRateEdit.html.twig b/templates/components/InlineRateEdit.html.twig index 51550d4..7ffd35d 100644 --- a/templates/components/InlineRateEdit.html.twig +++ b/templates/components/InlineRateEdit.html.twig @@ -14,7 +14,9 @@ >