Compare commits

...

191 Commits

Author SHA1 Message Date
Gregor Vostrak
d5e5e915ab add invoice clone to openapi client, expose DetailedInvoice type 2026-05-29 15:40:37 +02:00
Gregor Vostrak
1cc000a584 fix local storage filter migration state for visibility filter 2026-05-26 11:37:24 +02:00
Gregor Vostrak
1a754f6756 improve modal and field group spacing for project modal layout 2026-05-26 11:15:15 +02:00
Gregor Vostrak
d69d25d059 add project table visibility filter 2026-05-26 11:15:15 +02:00
Gregor Vostrak
0e15d9d9c2 add project visibility ui 2026-05-26 11:15:15 +02:00
dependabot[bot]
7d9ecd9526 Bump aglipanci/laravel-pint-action from 2.5 to 2.6
Bumps [aglipanci/laravel-pint-action](https://github.com/aglipanci/laravel-pint-action) from 2.5 to 2.6.
- [Release notes](https://github.com/aglipanci/laravel-pint-action/releases)
- [Commits](https://github.com/aglipanci/laravel-pint-action/compare/2.5...2.6)

---
updated-dependencies:
- dependency-name: aglipanci/laravel-pint-action
  dependency-version: '2.6'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:28:48 +02:00
dependabot[bot]
3a17f80f99 Bump codecov/codecov-action from 5.4.3 to 5.5.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.3 to 5.5.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.4.3...v5.5.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:14:44 +02:00
dependabot[bot]
e29ea2ea42 Bump actions/setup-node from 4 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:13:14 +02:00
dependabot[bot]
fb6e4639ce Bump actions/download-artifact from 4 to 6
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:12:01 +02:00
dependabot[bot]
69bc41988a Bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:11:19 +02:00
Gregor Vostrak
f7663b1c8b Clarify out of scope items for vulnerability reports
Added out of scope section for vulnerability reporting.
2026-05-18 19:21:32 +02:00
Gregor Vostrak
793bd11dcf remove member, invitation, and owner email disclosure from Teams/Show inertia props
The Teams/Show Inertia page serialized members, pending invitations, and the
owner email into props using only a belongsToTeam authorization gate, while
the corresponding API endpoints correctly enforced members:view and
invitations:view. The serialized data was unused by the live UI (the
TeamMemberManager partial that referenced it was orphaned), so dropping the
fields removes the disclosure surface without functional impact. The owner
card retains name and photo.
2026-05-18 19:04:57 +02:00
Gregor Vostrak
77a62afd69 add alphabetic sorting to multiselect dropdowns 2026-04-29 18:32:05 +02:00
Gregor Vostrak
b73aa543fd Merge commit from fork 2026-04-21 21:12:30 +02:00
Gregor Vostrak
2d6f9e514f add groupSimilarTimeEntries to TimeEntryGroupedTable 2026-04-21 20:44:33 +02:00
Gregor Vostrak
f8e668790b Fix typo in project name in README.md 2026-04-18 04:27:50 +02:00
utlark
77a5e979c6 Added the ability to disable group similar time entries (#1054)
* Added the ability to disable group similar time entries

* Fix E2E test for Group similar time entries

* Simplify `TimeEntryGroupedTable` by replacing ternary with early return logic

* Refactor time entry grouping settings: rename storage key, move logic into a dedicated module

* Replace fixed `waitForTimeout` calls in E2E tests with element-based waits and assertions

* Run frontend linting and formatting for changes
2026-04-17 16:44:59 +02:00
Gregor Vostrak
353a579850 chore: bump ui package version 2026-04-17 14:46:36 +02:00
Gregor Vostrak
bd44a2b376 fix e2e tests for new duration reporting format logic 2026-04-17 14:36:56 +02:00
Gregor Vostrak
277dbaf6eb promote duration formats that omit seconds to HH:mm:ss in reporting
views and exports
2026-04-17 12:15:26 +02:00
Gregor Vostrak
1cf33ddb3f improve dark mode color palette; rework font weights throughout the
interface
2026-04-15 15:35:20 +02:00
Gregor Vostrak
84cd0d572d bump ui package version 2026-04-08 23:18:29 +02:00
Gregor Vostrak
f37b86f377 chore: remove unused formatActivityDuration function 2026-04-08 14:49:37 +02:00
Gregor Vostrak
1e7364fc4b show calendar activities more prominently when no time entry exists 2026-04-08 14:43:09 +02:00
Gregor Vostrak
8cbc9838c9 fix minimal layout shift on time entry select and migrate to ui button 2026-04-07 21:42:34 +02:00
Gregor Vostrak
71c8992e31 Fix getLocalizedDayJsFromMinutes handling negative minute values 2026-03-31 13:56:30 +02:00
Gregor Vostrak
53d91b65d6 fix: use timezoned dates in public report endpoint tests
Replace travelTo + now() with Carbon::now($timezone)->startOfDay() to eliminate flakiness when tests run near midnight UTC, where the UTC and Vienna dates can differ.
2026-03-31 13:21:54 +02:00
Gregor Vostrak
0c88a10eb5 improve calendar current day styling 2026-03-30 00:58:40 +02:00
Gregor Vostrak
dd7b23958a fix gotenberg url in CI 2026-03-30 00:07:57 +02:00
Gregor Vostrak
1eb066f5aa Add E2E test for project name prefill 2026-03-29 23:55:10 +02:00
ShrootBuck
b1287c6a0a Prefill project name in create modal
Add optional initialProjectName prop to ProjectCreateModal and use it
to initialize the project's name. Pass the TimeTracker dropdown's
searchValue as initial-project-name so the create form is prefilled.
2026-03-29 23:55:10 +02:00
Gregor Vostrak
815abb5980 improve drag handle hit area and activity tooltip placement 2026-03-29 23:14:01 +02:00
Gregor Vostrak
e2f859be27 fix calendar scroll down on load; bump ui package version 2026-03-29 23:02:22 +02:00
Gregor Vostrak
3d26fcaefe Fix DST-related timezone offset when creating/resizing/dragging calendar
events
2026-03-29 22:55:50 +02:00
Gregor Vostrak
1e73a90f9d chore: bump ui version 2026-03-29 22:09:01 +02:00
Gregor Vostrak
0f8f906e5c clarify naming on activity type 2026-03-27 00:37:29 +01:00
Gregor Vostrak
797fddf638 chore: Add zod/type deps and tighten TimeTracker types 2026-03-24 17:41:26 +01:00
Gregor Vostrak
d07294ae7c add zodios to external ui package dependencies 2026-03-23 19:55:26 +01:00
Gregor Vostrak
1f49940805 Use Bundler moduleResolution and add PostCSS config for ui package 2026-03-23 19:38:07 +01:00
Gregor Vostrak
6be6a48e0d Use relative cn imports in UI package to improve isolation and fix
package build
2026-03-23 19:16:31 +01:00
Gregor Vostrak
b94a04dca0 Move useCssVariable into ui package 2026-03-23 19:02:20 +01:00
Gregor Vostrak
bd3b8f265f chore: cleanup old tabs reexports and ui version bump 2026-03-23 17:57:28 +01:00
Gregor Vostrak
c19a0f9acc Move tabs and TabBar into UI package 2026-03-23 17:43:46 +01:00
Gregor Vostrak
5c6d84dc38 fix e2e tests timing issues with cut off time entries at the start of
the day
2026-03-23 17:43:46 +01:00
Gregor Vostrak
5c67709746 Add clearable DatePicker and report tests 2026-03-23 17:43:46 +01:00
Gregor Vostrak
a2b0828c54 Fix flaky e2e tests for calendar and projects 2026-03-23 17:43:46 +01:00
Gregor Vostrak
b94872b07b Add size prop to DatePicker and fix range end 2026-03-23 17:43:46 +01:00
Gregor Vostrak
12bbbf64e9 Add context menu actions and tests 2026-03-23 17:43:46 +01:00
Gregor Vostrak
c07ac4b0e4 add random identifier to exports to avoid path conflicts, fixes #1035 2026-03-23 17:43:46 +01:00
Gregor Vostrak
a58566d002 fix design inconsistencies in time entry edit modal 2026-03-23 17:43:46 +01:00
Gregor Vostrak
57ed6036e6 Add context menu to time entry rows 2026-03-23 17:43:46 +01:00
Gregor Vostrak
ef7569b63b only show calendar toolbar after load complete to avoid layout shift 2026-03-23 17:43:46 +01:00
Gregor Vostrak
19c789b78e fix flaky firefox e2e test 2026-03-23 17:43:46 +01:00
Gregor Vostrak
49548037b3 fix calendar and calendar settings e2e test regressions after migration 2026-03-23 17:43:46 +01:00
Gregor Vostrak
97df779d1e Use locale-aware parseTimeInput for duration inputs 2026-03-23 17:43:46 +01:00
Gregor Vostrak
a1d5563fc4 fix window type error for activity test data injection 2026-03-23 17:43:46 +01:00
Gregor Vostrak
c94ca804f8 add Progress component and Reorganize UI exports 2026-03-23 17:43:46 +01:00
Gregor Vostrak
189682cfaf Replace FullCalendar with custom calendar UI 2026-03-23 17:43:46 +01:00
Gregor Vostrak
8d16503541 Adjust UI sizing and spacing 2026-03-23 17:43:46 +01:00
Gregor Vostrak
e43ce477b8 externalize npm packages in ui package 2026-03-23 17:43:46 +01:00
Gregor Vostrak
5646aedb25 add lucide-vue-next to peer dependencies 2026-03-23 17:43:46 +01:00
Gregor Vostrak
2b46e568e0 Use nearest-grid snapping for event resize 2026-03-23 17:43:46 +01:00
Gregor Vostrak
89a4a1962a Replace fullcalendar calendar header with custom toolbar 2026-03-23 17:43:46 +01:00
Gregor Vostrak
c581ad8854 move calendar, dropdown-menu, select, dialog, number-field components to
the ui package
2026-03-23 17:43:46 +01:00
Gregor Vostrak
bce6cb9395 Move dropdown menu into UI package 2026-03-23 17:43:46 +01:00
Gregor Vostrak
1cdae98ed9 Add context menu actions for running entries in calendar 2026-03-23 17:43:46 +01:00
Gregor Vostrak
02f6436fd0 keep calendar event data while resizing event 2026-03-23 17:43:46 +01:00
Gregor Vostrak
452acca942 add context menus to calendar view + ui package 2026-03-23 17:43:46 +01:00
Gregor Vostrak
192c8c3b88 fix IDOR private projects 2026-03-19 13:52:28 +01:00
Gregor Vostrak
6218ffceb5 update composer dependencies 2026-03-03 12:27:42 +01:00
Gregor Vostrak
ba32be0543 update npm dependencies 2026-03-02 18:19:11 +01:00
Gregor Vostrak
bd817db06f only use xsrf token for organization requests 2026-03-02 17:18:21 +01:00
Gregor Vostrak
97f4bce676 bump retries and wait for networkidle in retry 2026-03-02 17:18:21 +01:00
Gregor Vostrak
6962b668fb add retries to api data token setup and xsrf token fallback 2026-03-02 17:18:21 +01:00
Gregor Vostrak
be8091296c use api tokens to create e2e test data 2026-03-02 17:18:21 +01:00
Gregor Vostrak
84c4750c9b Add warning for AI slop pull requests
Added a warning about AI slop pull requests and potential bans.
2026-02-27 20:18:44 +01:00
Gregor Vostrak
f582adab0d fix time entries incorrectly not updating in calendar
the synced snapDuration cause incorrect noops on updates f.e. 15:55-16:00 on a 15 minute snap
2026-02-24 19:38:55 +01:00
Gregor Vostrak
c60cff04ce fix calendar flickering on move for non-aligned entries
this is a trade-off where for non grid aligned entries, the cursor position is a bit off, but data and visual are stil in sync. otherwise fc overrides height on drag, causing flickers.
2026-02-24 15:30:18 +01:00
Gregor Vostrak
cae41e4b4f improve visual snapping boundaries 2026-02-24 14:02:18 +01:00
Gregor Vostrak
8973be9dab filament minor version update 2026-02-24 13:43:21 +01:00
Gregor Vostrak
2a0b8d31e6 add calendar settings + custom visual snapping 2026-02-24 12:41:15 +01:00
Gregor Vostrak
d2f3fe411a add missing query invalidation after report create 2026-02-18 23:58:39 +01:00
Gregor Vostrak
f880f9f730 fix firefox flaky input in e2e test 2026-02-18 23:22:04 +01:00
Gregor Vostrak
556bbedeca add dynamic loading of paginated endpoints above page_limit
add request classes and fix collection typing for clients, tasks and tags
2026-02-18 22:32:56 +01:00
Gregor Vostrak
eed638d0aa add default sorting to task, project, member, invitation, api token endpoints 2026-02-18 19:16:14 +01:00
Gregor Vostrak
864f41bda6 fix project member query invalidations after update, query key change regression 2026-02-18 18:51:21 +01:00
Gregor Vostrak
26524c5f40 fix member edit modal ui regression from field component migration 2026-02-18 17:57:11 +01:00
Gregor Vostrak
cf98fabe0a add table sorting to members, clients and tags table 2026-02-18 17:41:36 +01:00
Gregor Vostrak
88c0c334e9 add project progress sorting and fix direction ui for number based
columns in the project table
2026-02-18 16:45:17 +01:00
Gregor Vostrak
0fc325363d update query keys to include org id, preventing stale data after organization switch 2026-02-18 12:53:22 +01:00
Gregor Vostrak
1afc16573a cleanup postcss config dependency in ui package 2026-02-17 18:06:35 +01:00
Gregor Vostrak
147514a606 convert billable query string to boolean for shared report + e2e tests #876 2026-02-17 17:08:38 +01:00
Gregor Vostrak
435522b502 make OrganizationPolicy use “organizations:update” to remove jetstream inconsistencies
The frontend did not show organization settings for admin users because of the team ownership check
2026-02-17 14:35:52 +01:00
Gregor Vostrak
f1d001e03e add lazy loading to modals and dropdowns to improve time page render performance 2026-02-17 13:54:26 +01:00
Gregor Vostrak
7f145cf1c2 make sure cost column shows in shared report view, #1019 2026-02-17 13:42:22 +01:00
Gregor Vostrak
b579ed1075 bump ui package version to 0.0.16 2026-02-16 18:31:11 +01:00
Gregor Vostrak
ed2b7476ae clear inertia cache on organization change to fix wrongly loaded stale pages 2026-02-16 16:44:20 +01:00
Gregor Vostrak
8107c6a208 improve activitygraphcard chart coloring steps 2026-02-16 15:29:46 +01:00
Gregor Vostrak
6dc517e07d make sure days with low tracked time are clearly distinguished from no time in activity graph, fixes #447 2026-02-16 15:24:50 +01:00
Gregor Vostrak
2c60d04ba4 override current_team_id in makeMemberToPlaceholder to avoid fk constraint error on user delete, fixes #989 2026-02-16 15:02:42 +01:00
Gregor Vostrak
2c222f3f67 fix time loading spinner flashing “no time entries” on direct load 2026-02-13 15:35:54 +01:00
Gregor Vostrak
c5c1a7af13 add project and task prefetches to the dashboard prefetch 2026-02-13 13:52:49 +01:00
Gregor Vostrak
22cf7cf74d limit initially loaded time entries on the time page to 50 2026-02-13 13:44:33 +01:00
Gregor Vostrak
cfbfbd4b6a remove no tags option from timetracker tag dropdown 2026-02-13 12:30:54 +01:00
Gregor Vostrak
6629482a0e set maximum-scale=1 to prevent weird ios zoom behaviours 2026-02-12 18:12:05 +01:00
Gregor Vostrak
38457cae4d make sure e2e tests use the visible timer button only 2026-02-12 17:43:04 +01:00
Gregor Vostrak
0e63ecb520 improve timetracker on mobile; fix select all checkbox with 0 time
entries; add minimal padding to mobile dialogs
2026-02-12 17:06:20 +01:00
Gregor Vostrak
6f207a4926 hide "All time entries are loaded" when no time entries are created yet 2026-02-12 13:58:08 +01:00
Gregor Vostrak
052424a581 add animation to the mobile sidebar 2026-02-12 13:51:53 +01:00
Gregor Vostrak
b258717211 improve reporting page responsive layout; standardize button sizing;
prevent mobile input zoom; increase CI playwright shards
2026-02-12 13:30:11 +01:00
Gregor Vostrak
685cc29282 improve layout consistency between project and project show page, fix
client status indicator, fixes #814
2026-02-11 18:17:08 +01:00
Gregor Vostrak
c78c681ec4 Conditionally show cost column in report tables; Task/Project Modal
Field cleanup; improve estimated time UX
2026-02-11 17:29:41 +01:00
Gregor Vostrak
2d9f33387e improve format settings e2e test consistency; improve euro icon sizing
consistency
2026-02-11 17:29:41 +01:00
Gregor Vostrak
b68d68a2a2 make sure that 404 current time entry requests do not override local
state while preparing new time entry
2026-02-11 17:29:41 +01:00
Gregor Vostrak
a9e03f3b29 responsive time entry modal fixes 2026-02-11 17:29:41 +01:00
Gregor Vostrak
474b294a18 fix reporting tab selectors in e2e test 2026-02-11 17:29:41 +01:00
Gregor Vostrak
334a98016f use frankenphp in the playwright tests CI to handle parallel requests
better
2026-02-11 17:29:41 +01:00
Gregor Vostrak
8be55359ce add e2e tests for employee restrictions 2026-02-11 17:29:41 +01:00
Gregor Vostrak
e45662c715 add sharding for e2e tests in CI 2026-02-11 17:29:41 +01:00
Gregor Vostrak
f3217baed1 Add Tag Edit Modal and UI 2026-02-11 17:29:41 +01:00
Gregor Vostrak
562ee234a8 Add Euro Symbol as Billable Icon when EUR is the organization currency.
fixes #423
2026-02-11 17:29:41 +01:00
Gregor Vostrak
15e61e9789 Add Field component system and migrate UI 2026-02-11 17:29:41 +01:00
Gregor Vostrak
125f6f062f Expand e2e test coverage migrate to API-based data setup 2026-02-11 17:29:41 +01:00
Gregor Vostrak
f75a19bccd improve time estimate input, responsive time entry create modal fixes,
fixes #460, #800
2026-02-11 17:29:41 +01:00
Gregor Vostrak
c17d87b710 Allow updating public_until on already-public reports 2026-02-11 17:29:41 +01:00
Gregor Vostrak
a154293348 migrate datepickers to shadcn, Fixes #877, #807 2026-02-11 17:29:41 +01:00
Gregor Vostrak
9832c688fe fix desync of checkboxes on the reporting detailed page, fixes #892 2026-02-11 17:29:41 +01:00
Gregor Vostrak
6804eb098d Make sure that time entry billable status updates when project changes,
fixes #981
2026-02-11 17:29:41 +01:00
Gregor Vostrak
531443f0df fix admin panel time entry save and update, fixes #997 2026-02-11 17:29:41 +01:00
Gregor Vostrak
bd2d57dfd1 Improve Time page responsiveness and compact tags, fixes #896 2026-02-11 17:29:41 +01:00
Gregor Vostrak
73c92fad47 fix responsive issues in timetracker recently tracked entries dropdown 2026-02-11 17:29:41 +01:00
Gregor Vostrak
537a023ab9 Add calendar query prefetch 2026-02-11 17:29:41 +01:00
Gregor Vostrak
28fc324c6a Allow NONE filter value to shared reports and add shared-report tests 2026-02-11 17:29:41 +01:00
Gregor Vostrak
9379c191be Add Mailpit SMTP and refine Playwright tests 2026-02-11 17:29:41 +01:00
Gregor Vostrak
ff06d4d2f3 fix Y-Label ui regression from echarts update 2026-02-11 17:29:41 +01:00
Gregor Vostrak
7efb7e6071 Enable npm workspaces and update dependencies 2026-02-11 17:29:41 +01:00
Gregor Vostrak
b2af9c6bf1 Add client_ids filter to time entry export 2026-02-11 17:29:41 +01:00
Gregor Vostrak
73b4d66386 Add reporting e2e helpers and detailed tests 2026-02-11 17:29:41 +01:00
Gregor Vostrak
cb7baef0ba Update openapi api client spec 2026-02-11 17:29:41 +01:00
Gregor Vostrak
dd75a80df7 add no project, no task, no client, no task, no tag support to the API 2026-02-11 17:29:41 +01:00
Gregor Vostrak
bc562bf76f refactor: extract ReportingFilterBar and migrate reporting to TanStack Query 2026-02-11 17:29:41 +01:00
Gregor Vostrak
756b423295 migrate select/multiselect components to Radix Vue primitives 2026-02-11 17:29:41 +01:00
Gregor Vostrak
3707f2469c fix styling inconsistencies 2026-02-11 17:29:41 +01:00
Gregor Vostrak
c6c1434430 fix: display custom billable rate correctly on project detail page 2026-02-11 17:29:41 +01:00
Gregor Vostrak
70b78e41c3 add command palette 2026-02-11 17:29:41 +01:00
Gregor Vostrak
8c16302f17 add outline and secondary variants to TimeTrackerStartStop button to reduce visual complexity 2026-02-11 17:29:41 +01:00
Gregor Vostrak
bfc369794e remove redundant projects pinia store after tanstack query migration 2026-02-11 17:29:41 +01:00
Gregor Vostrak
3c2ea0e645 load time entries above pagination limit for calendar, fixes #995 2026-02-11 17:29:41 +01:00
Gregor Vostrak
b0d28f2f6d fix e2e project filtering in reporting e2e test 2026-02-11 17:29:41 +01:00
Gregor Vostrak
6555bca5f1 use tanstack query in ProjectMultiselectDropdown, ClientTableRow and ProjectDropdown; fix e2e 2026-02-11 17:29:41 +01:00
Gregor Vostrak
81d9561656 refactor timeentries queries and mutations, improve activitygraph, add dashboard reporting table 2026-02-11 17:29:41 +01:00
Gregor Vostrak
0a6bde8bc6 upgrade inertia v2; add prefetching; migrate queries to tanstack query
vue
2026-02-11 17:29:41 +01:00
Constantin Graf
51af3db305 Add test to TimeEntryEndpointTest 2026-01-28 12:56:58 +01:00
Gregor Vostrak
f242ce48b5 change rounding up on boundaries so it does not round up but keeps the value, fixes #994 2026-01-28 12:56:58 +01:00
Gregor Vostrak
19064cdc3d make time entry calendar use seconds as a duration basis, fixes #996 2026-01-15 17:07:50 +01:00
Gregor Vostrak
5a05ee35e0 change dashboard card colors and input background colors 2026-01-09 01:16:23 +01:00
Gregor Vostrak
00d9d1488e improve time entry heading contrast in light mode 2026-01-08 20:17:54 +01:00
Gregor Vostrak
9bbbfdfafe improve visual hierarchy in time view 2026-01-08 19:53:53 +01:00
Gregor Vostrak
d27f023e16 refactor BaseFilterBadge to use DropdownMenuTrigger directly and avoid class merging conflicts 2026-01-08 19:14:59 +01:00
Gregor Vostrak
db57055941 add filters and sorting to projects table 2026-01-08 18:07:17 +01:00
Gregor Vostrak
743c64909a restrict time entries create endpoints for employees to only projects where they have access to 2025-12-17 12:54:07 +01:00
Gregor Vostrak
de97d15925 add tailwind theme and css variables to files export, bump ui package version 2025-12-09 16:44:55 +01:00
Gregor Vostrak
0691fe10ef add direct axios dependency to package, bump package versions 2025-12-09 16:44:55 +01:00
Gregor Vostrak
513b2048ee move TimezonMismatchModal to ui package 2025-12-09 16:44:55 +01:00
Gregor Vostrak
3acf9b8b07 add support for window activities in the calendar view plugin 2025-12-09 16:44:55 +01:00
Gregor Vostrak
814d539fb0 move rangecalendar, popover and daterangepicker to ui package 2025-12-09 16:44:55 +01:00
Gregor Vostrak
7a51fca2f9 only show Weekly Billable Amount of current organization on dashboard, fixes #977 2025-12-02 13:30:08 +01:00
Gregor Vostrak
280032ee02 allow employee manage task setting to organization 2025-11-25 15:39:20 +01:00
Gregor Vostrak
b1bb7245b0 use default api limit for fetching time entries 2025-11-20 17:30:13 +01:00
Gregor Vostrak
6f37ad500a limit initially loaded time entries on time page 2025-11-20 16:58:53 +01:00
Gregor Vostrak
500ccd5719 fix container queries for time entry rows 2025-11-20 16:52:08 +01:00
Gregor Vostrak
bacd6f4222 include the currently running time entry in the calendar header 2025-11-20 13:17:48 +01:00
Gregor Vostrak
022caf59ee bump solidtime ui package version to 0.0.13 2025-11-19 17:34:21 +01:00
Gregor Vostrak
f955ab3135 fix display problems caused by minimum height of calendar events 2025-11-19 17:34:21 +01:00
Gregor Vostrak
5b491b0da2 add support for currently running time entry 2025-11-19 17:34:21 +01:00
Gregor Vostrak
249ab67ac8 improve idle indicator colors, fix typescript issues 2025-11-19 17:34:21 +01:00
Gregor Vostrak
1bd2c28b37 add tooltips to idlestatus indicators 2025-11-19 17:34:21 +01:00
Gregor Vostrak
33ac994cc0 add activity status plugin to calendar 2025-11-19 17:34:21 +01:00
Gregor Vostrak
8d3ee58bed improve initial mount performance for groupedtimeentrytable by streaming in the rows
mounting the rows mounts lots of nested components which results in a delay on the initial mount.
2025-11-19 17:34:21 +01:00
Gregor Vostrak
8a2c260533 use container queries for time entry table 2025-11-19 17:34:21 +01:00
Gregor Vostrak
95ab1699c4 make sure that CreateTimeEntry modal always starts with times that have 0 seconds 2025-11-19 17:34:21 +01:00
Gregor Vostrak
306a081a3d prevent seconds update on timepicker when nothing else changes 2025-11-19 17:34:21 +01:00
Gregor Vostrak
878ac4ab81 add tooltip component 2025-11-19 17:34:21 +01:00
Gregor Vostrak
947550d639 move css variables and tailwind theme config into ui package 2025-11-19 17:34:21 +01:00
Gregor Vostrak
09fb5aa48e make sure that timepicker and calendar set seconds to 0 on update, fixes #968 2025-11-19 17:34:21 +01:00
Gregor Vostrak
9b9371e5a5 move button component to ui package 2025-11-19 17:34:21 +01:00
Gregor Vostrak
0648437478 design fixes, improve component encapsulation 2025-11-19 17:34:21 +01:00
Gregor Vostrak
8ba04eca0c move currency and cancreateproject permission to props to decouple TimeEntryCreateModal from web 2025-11-19 17:34:21 +01:00
Gregor Vostrak
8a2f35de0c fix package build error dependencies 2025-11-19 17:34:21 +01:00
Gregor Vostrak
b7dafb0892 bump api and ui package versions 2025-11-19 17:34:21 +01:00
Gregor Vostrak
6eca0c2c76 fix archived_at timestamp of client in exporter 2025-11-11 12:55:33 +01:00
515 changed files with 34728 additions and 15251 deletions

12
.env.ci
View File

@@ -34,7 +34,12 @@ SESSION_DRIVER=database
SESSION_LIFETIME=120
# Mail
MAIL_MAILER=log
MAIL_MAILER=smtp
MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="solidtime"
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
@@ -55,4 +60,7 @@ AUDITING_ENABLED=true
TELESCOPE_ENABLED=false
# Services
GOTENBERG_URL=http://0.0.0.0:3000
GOTENBERG_URL=http://localhost:3000
# Octane
OCTANE_SERVER=frankenphp

View File

@@ -77,6 +77,9 @@ TELESCOPE_ENABLED=false
# Services
GOTENBERG_URL=http://gotenberg:3000
# Octane
OCTANE_SERVER=frankenphp
# Local setup
NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing

View File

@@ -91,7 +91,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -177,7 +177,7 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -68,12 +68,12 @@ jobs:
run: cat .env
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: "Checkout billing extension"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: solidtime-io/extension-billing
path: extensions/Billing
@@ -93,7 +93,7 @@ jobs:
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: solidtime-io/extension-services
path: extensions/Services
@@ -111,7 +111,7 @@ jobs:
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -92,7 +92,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -169,7 +169,7 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -11,7 +11,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2
@@ -24,7 +24,7 @@ jobs:
run: composer install -n --prefer-dist
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -9,10 +9,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -11,10 +11,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -11,11 +11,11 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -11,9 +11,9 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -10,7 +10,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2
@@ -23,7 +23,7 @@ jobs:
run: composer install -n --prefer-dist
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
@@ -48,7 +48,7 @@ jobs:
- name: "Run composer install"
run: composer install -n --prefer-dist
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.3
uses: codecov/codecov-action@v5.5.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -9,9 +9,9 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Check code style"
uses: aglipanci/laravel-pint-action@2.5
uses: aglipanci/laravel-pint-action@2.6
with:
configPath: "pint.json"

View File

@@ -6,10 +6,18 @@ jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
services:
mailpit:
image: 'axllent/mailpit:latest'
ports:
- 1025:1025
- 8025:8025
pgsql_test:
image: postgres:15
env:
@@ -27,10 +35,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup node"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -57,22 +65,63 @@ jobs:
- name: "Build Frontend"
run: npm run build
- name: "Run Laravel Server"
run: php artisan serve > /dev/null 2>&1 &
- name: "Install FrankenPHP"
run: |
ARCH="$(uname -m)"
curl -fsSL "https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}" -o /usr/local/bin/frankenphp
chmod +x /usr/local/bin/frankenphp
- name: "Run Laravel Octane Server"
run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 &
env:
OCTANE_SERVER: frankenphp
- name: "Install Playwright Browsers"
run: npx playwright install --with-deps
- name: "Run Playwright tests"
run: npx playwright test
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
MAILPIT_BASE_URL: 'http://localhost:8025'
- name: "Upload test results"
- name: "Upload blob report"
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: test-results/
retention-days: 30
name: blob-report-${{ matrix.shardIndex }}
path: blob-report/
retention-days: 7
merge-reports:
if: always()
needs: [test]
runs-on: ubuntu-latest
steps:
- name: "Checkout code"
uses: actions/checkout@v4
- name: "Setup node"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Install dependencies"
run: npm ci
- name: "Download blob reports"
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: "Merge reports"
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: "Upload merged HTML report"
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30

View File

@@ -1,4 +1,4 @@
# solidtime - The modern Open-Source Time Tracker
# solidtime - The modern Open-Source TimeTracker
[![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
[![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime)
@@ -37,6 +37,8 @@ If you have a **feature request**, please [**create a discussion**](https://gith
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

View File

@@ -3,3 +3,18 @@
## Reporting a Vulnerability
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
## Out of scope
Reports we typically won't issue an advisory for:
* Theoretical findings without a working PoC
* Raw scanner output without manual validation
* Missing/weak security headers in isolation (CSP, X-Frame-Options, HSTS, etc.)
* SPF/DKIM/DMARC on non-mail-sending domains; missing DNSSEC/CAA; TLS cipher preferences
* Self-XSS; CSRF on non-state-changing endpoints (logout, theme)
* CSV / spreadsheet formula injection in exports — treated as a spreadsheet-application issue
* Org owners or admins acting destructively within their own organization
* Anything requiring direct DB, shell, or filesystem access on a self-hosted instance
* Missing OAuth Scope enforcement (this is not implemented yet, but AI scanners flag it which is why it is included in this list until we actually support it)

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TimeEntryResource\Pages;
use App\Models\Member;
use App\Models\TimeEntry;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
@@ -16,6 +17,7 @@ use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class TimeEntryResource extends Resource
{
@@ -51,15 +53,23 @@ class TimeEntryResource extends Resource
->rules([
'after_or_equal:start',
]),
Select::make('user_id')
->relationship(name: 'user', titleAttribute: 'email')
->searchable(['name', 'email'])
Select::make('member_id')
->relationship(
name: 'member',
titleAttribute: 'id',
modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])
)
->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')
->searchable()
->required(),
Select::make('project_id')
->relationship(name: 'project', titleAttribute: 'name')
->searchable(['name'])
->nullable(),
// TODO
Select::make('task_id')
->relationship(name: 'task', titleAttribute: 'name')
->searchable(['name'])
->nullable(),
]);
}

View File

@@ -5,9 +5,28 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Resources\Pages\CreateRecord;
class CreateTimeEntry extends CreateRecord
{
protected static string $resource = TimeEntryResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
if (isset($data['member_id'])) {
/** @var Member|null $member */
$member = Member::query()->find($data['member_id']);
if ($member !== null) {
$data['user_id'] = $member->user_id;
$data['organization_id'] = $member->organization_id;
}
}
return $data;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
@@ -19,4 +20,22 @@ class EditTimeEntry extends EditRecord
->icon('heroicon-m-trash'),
];
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
if (isset($data['member_id'])) {
/** @var Member|null $member */
$member = Member::query()->find($data['member_id']);
if ($member !== null) {
$data['user_id'] = $member->user_id;
$data['organization_id'] = $member->organization_id;
}
}
return $data;
}
}

View File

@@ -35,6 +35,7 @@ class ApiTokenController extends Controller
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
})
->orderBy('created_at', 'desc')
->get();
return new ApiTokenCollection($tokens);

View File

@@ -102,7 +102,7 @@ class ChartController extends Controller
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 100);
return response()->json($dailyTrackedHours);
}

View File

@@ -41,6 +41,7 @@ class InvitationController extends Controller
$this->checkPermission($organization, 'invitations:view');
$invitations = $organization->teamInvitations()
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
return InvitationCollection::make($invitations);

View File

@@ -60,6 +60,7 @@ class MemberController extends Controller
$members = Member::query()
->whereBelongsTo($organization, 'organization')
->with(['user'])
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
return MemberCollection::make($members);

View File

@@ -46,6 +46,9 @@ class OrganizationController extends Controller
if ($request->getEmployeesCanSeeBillableRates() !== null) {
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
}
if ($request->getEmployeesCanManageTasks() !== null) {
$organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks();
}
if ($request->getNumberFormat() !== null) {
$organization->number_format = $request->getNumberFormat();
}

View File

@@ -60,7 +60,9 @@ class ProjectController extends Controller
$projectsQuery->whereNull('archived_at');
}
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
$projects = $projectsQuery
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
@@ -76,7 +78,7 @@ class ProjectController extends Controller
*/
public function show(Organization $organization, Project $project): JsonResource
{
$this->checkPermission($organization, 'projects:view', $project);
$this->checkPermission($organization, 'projects:view:all', $project);
// Note: There is currently no need to check if a user is a member of the project,
// since this is only relevant for users with the role "employee" and they can not access this endpoint.

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
use App\Http\Requests\V1\ProjectMember\ProjectMemberIndexRequest;
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
@@ -41,12 +42,13 @@ class ProjectMemberController extends Controller
*
* @operationId getProjectMembers
*/
public function index(Organization $organization, Project $project): ProjectMemberCollection
public function index(Organization $organization, Project $project, ProjectMemberIndexRequest $request): ProjectMemberCollection
{
$this->checkPermission($organization, 'project-members:view', $project);
$projectMembers = ProjectMember::query()
->whereBelongsTo($project, 'project')
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
return new ProjectMemberCollection($projectMembers);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Weekday;
use App\Http\Requests\V1\Report\ReportIndexRequest;
use App\Http\Requests\V1\Report\ReportStoreRequest;
use App\Http\Requests\V1\Report\ReportUpdateRequest;
use App\Http\Resources\V1\Report\DetailedReportResource;
@@ -40,7 +41,7 @@ class ReportController extends Controller
*
* @operationId getReports
*/
public function index(Organization $organization): ReportCollection
public function index(Organization $organization, ReportIndexRequest $request): ReportCollection
{
$this->checkPermission($organization, 'reports:view');
@@ -150,6 +151,9 @@ class ReportController extends Controller
$report->share_secret = null;
$report->public_until = null;
}
} elseif ($report->is_public && $request->has('public_until')) {
// Allow updating expiration date on already-public reports
$report->public_until = $request->getPublicUntil();
}
$report->save();

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Tag\TagIndexRequest;
use App\Http\Requests\V1\Tag\TagStoreRequest;
use App\Http\Requests\V1\Tag\TagUpdateRequest;
use App\Http\Resources\V1\Tag\TagCollection;
@@ -34,7 +35,7 @@ class TagController extends Controller
*
* @throws AuthorizationException
*/
public function index(Organization $organization): TagCollection
public function index(Organization $organization, TagIndexRequest $request): TagCollection
{
$this->checkPermission($organization, 'tags:view');

View File

@@ -11,6 +11,7 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
use App\Http\Resources\V1\Task\TaskCollection;
use App\Http\Resources\V1\Task\TaskResource;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
@@ -27,6 +28,26 @@ class TaskController extends Controller
}
}
/**
* Check scoped permission and verify user has access to the project
*
* @throws AuthorizationException
*/
private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void
{
$this->checkPermission($organization, $permission);
$user = $this->user();
$hasAccess = Project::query()
->where('id', $project->id)
->visibleByEmployee($user)
->exists();
if (! $hasAccess) {
throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');
}
}
/**
* Get tasks
*
@@ -61,7 +82,9 @@ class TaskController extends Controller
$query->whereNull('done_at');
}
$tasks = $query->paginate(config('app.pagination_per_page_default'));
$tasks = $query
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
return new TaskCollection($tasks);
}
@@ -75,7 +98,15 @@ class TaskController extends Controller
*/
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
{
$this->checkPermission($organization, 'tasks:create');
/** @var Project $project */
$project = Project::query()->findOrFail($request->input('project_id'));
if ($this->hasPermission($organization, 'tasks:create:all')) {
$this->checkPermission($organization, 'tasks:create:all');
} else {
$this->checkScopedPermissionForProject($organization, $project, 'tasks:create');
}
$task = new Task;
$task->name = $request->input('name');
$task->project_id = $request->input('project_id');
@@ -97,7 +128,17 @@ class TaskController extends Controller
*/
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
{
$this->checkPermission($organization, 'tasks:update', $task);
// Check task belongs to organization
if ($task->organization_id !== $organization->id) {
throw new AuthorizationException('Task does not belong to organization');
}
if ($this->hasPermission($organization, 'tasks:update:all')) {
$this->checkPermission($organization, 'tasks:update:all');
} else {
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');
}
$task->name = $request->input('name');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$task->estimated_time = $request->getEstimatedTime();
@@ -119,7 +160,16 @@ class TaskController extends Controller
*/
public function destroy(Organization $organization, Task $task): JsonResponse
{
$this->checkPermission($organization, 'tasks:delete', $task);
// Check task belongs to organization
if ($task->organization_id !== $organization->id) {
throw new AuthorizationException('Task does not belong to organization');
}
if ($this->hasPermission($organization, 'tasks:delete:all')) {
$this->checkPermission($organization, 'tasks:delete:all');
} else {
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');
}
if ($task->timeEntries()->exists()) {
throw new EntityStillInUseApiException('task', 'time_entry');

View File

@@ -53,6 +53,7 @@ use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory;
@@ -246,7 +247,7 @@ class TimeEntryController extends Controller
'user',
'tagsRelation',
]);
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension();
$folderPath = 'exports';
$path = $folderPath.'/'.$filename;
$localizationService = LocalizationService::forOrganization($organization);
@@ -469,7 +470,7 @@ class TimeEntryController extends Controller
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
$localizationService = LocalizationService::forOrganization($organization);
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension();
$folderPath = 'exports';
$path = $folderPath.'/'.$filename;
@@ -628,9 +629,9 @@ class TimeEntryController extends Controller
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
$this->checkPermission($organization, 'time-entries:update:own');
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:update:all');
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
}
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {

View File

@@ -21,6 +21,11 @@ class InvitationIndexRequest extends BaseFormRequest
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -21,6 +21,11 @@ class MemberIndexRequest extends BaseFormRequest
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -39,6 +39,9 @@ class OrganizationUpdateRequest extends BaseFormRequest
'employees_can_see_billable_rates' => [
'boolean',
],
'employees_can_manage_tasks' => [
'boolean',
],
'prevent_overlapping_time_entries' => [
'boolean',
],
@@ -102,6 +105,11 @@ class OrganizationUpdateRequest extends BaseFormRequest
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
}
public function getEmployeesCanManageTasks(): ?bool
{
return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null;
}
public function getPreventOverlappingTimeEntries(): ?bool
{
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class ProjectMemberIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class ReportIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -10,9 +10,11 @@ use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
/**
@@ -23,7 +25,7 @@ class ReportStoreRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule>>
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
*/
public function rules(): array
{
@@ -81,7 +83,14 @@ class ReportStoreRequest extends BaseFormRequest
],
'properties.client_ids.*' => [
'string',
'uuid',
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
if (! Str::isUuid($value)) {
$fail('The '.$attribute.' must be a valid UUID.');
}
},
],
// Filter by project IDs, project IDs are OR combined
'properties.project_ids' => [
@@ -90,7 +99,14 @@ class ReportStoreRequest extends BaseFormRequest
],
'properties.project_ids.*' => [
'string',
'uuid',
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
if (! Str::isUuid($value)) {
$fail('The '.$attribute.' must be a valid UUID.');
}
},
],
// Filter by tag IDs, tag IDs are OR combined
'properties.tag_ids' => [
@@ -99,7 +115,14 @@ class ReportStoreRequest extends BaseFormRequest
],
'properties.tag_ids.*' => [
'string',
'uuid',
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
if (! Str::isUuid($value)) {
$fail('The '.$attribute.' must be a valid UUID.');
}
},
],
'properties.task_ids' => [
'nullable',
@@ -107,7 +130,14 @@ class ReportStoreRequest extends BaseFormRequest
],
'properties.task_ids.*' => [
'string',
'uuid',
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
if (! Str::isUuid($value)) {
$fail('The '.$attribute.' must be a valid UUID.');
}
},
],
'properties.group' => [
'required',

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
class TagIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
];
}
}

View File

@@ -26,6 +26,11 @@ class TaskIndexRequest extends BaseFormRequest
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
'max:2147483647',
],
'project_id' => [
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */

View File

@@ -16,6 +16,7 @@ use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
@@ -30,7 +31,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
*/
public function rules(): array
{
@@ -94,10 +95,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
],
'project_ids.*' => [
'string',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
@@ -106,10 +112,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
],
'client_ids.*' => [
'string',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [
@@ -118,10 +129,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
],
'tag_ids.*' => [
'string',
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
@@ -130,9 +146,14 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
],
'task_ids.*' => [
'string',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [

View File

@@ -14,6 +14,7 @@ use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
@@ -28,7 +29,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
*/
public function rules(): array
{
@@ -80,10 +81,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
],
'project_ids.*' => [
'string',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
@@ -92,10 +98,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
],
'client_ids.*' => [
'string',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [
@@ -104,10 +115,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
],
'tag_ids.*' => [
'string',
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
@@ -116,9 +132,14 @@ class TimeEntryAggregateRequest extends BaseFormRequest
],
'task_ids.*' => [
'string',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [

View File

@@ -6,11 +6,13 @@ namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryRoundingType;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
@@ -25,7 +27,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
*/
public function rules(): array
{
@@ -57,6 +59,23 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
'array',
'min:1',
],
'client_ids.*' => [
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by project IDs, project IDs are OR combined
'project_ids' => [
'array',
@@ -64,11 +83,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
'project_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [
@@ -77,11 +100,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
'tag_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
@@ -90,11 +117,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
'task_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [

View File

@@ -12,6 +12,7 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\Rule as RuleContract;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
@@ -26,7 +27,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|RuleContract>>
* @return array<string, array<string|ValidationRule|RuleContract|\Closure>>
*/
public function rules(): array
{
@@ -58,10 +59,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
],
'client_ids.*' => [
'string',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by project IDs, project IDs are OR combined
'project_ids' => [
@@ -70,10 +76,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
],
'project_ids.*' => [
'string',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [
@@ -82,10 +93,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
],
'tag_ids.*' => [
'string',
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
@@ -94,10 +110,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
],
'task_ids.*' => [
'string',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
},
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [

View File

@@ -10,8 +10,10 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -42,7 +44,16 @@ class TimeEntryStoreRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
$builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:create:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
})->uuid(),
],
// ID of the task that the time entry should belong to

View File

@@ -10,8 +10,10 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -54,7 +56,16 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
$builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
})->uuid(),
],
// ID of the task that the time entry should belong to

View File

@@ -10,8 +10,10 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -42,7 +44,16 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
$builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
})->uuid(),
],
// ID of the task that the time entry should belong to

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Client;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ClientCollection extends ResourceCollection
class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
{
/**
* The resource that this resource collects.

View File

@@ -53,6 +53,8 @@ class OrganizationResource extends BaseResource
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var string $currency Currency code (ISO 4217) */

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Tag;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class TagCollection extends ResourceCollection
class TagCollection extends ResourceCollection implements PaginatedResourceCollection
{
/**
* The resource that this resource collects.

View File

@@ -35,6 +35,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property int|null $billable_rate
* @property string $user_id
* @property bool $employees_can_see_billable_rates
* @property bool $employees_can_manage_tasks
* @property User $owner
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
@@ -70,6 +71,7 @@ class Organization extends JetstreamTeam implements AuditableContract
'personal_team' => 'boolean',
'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean',
'employees_can_manage_tasks' => 'boolean',
'prevent_overlapping_time_entries' => 'boolean',
'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class,

View File

@@ -6,6 +6,7 @@ namespace App\Policies;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use Filament\Facades\Filament;
use Illuminate\Auth\Access\HandlesAuthorization;
@@ -58,7 +59,7 @@ class OrganizationPolicy
return true;
}
return $user->ownsTeam($organization);
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
}
/**

View File

@@ -94,8 +94,11 @@ class JetstreamServiceProvider extends ServiceProvider
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
@@ -158,8 +161,11 @@ class JetstreamServiceProvider extends ServiceProvider
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
@@ -219,8 +225,11 @@ class JetstreamServiceProvider extends ServiceProvider
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
@@ -295,28 +304,8 @@ class JetstreamServiceProvider extends ServiceProvider
'owner' => [
'id' => $owner->getKey(),
'name' => $owner->name,
'email' => $owner->email,
'profile_photo_url' => $owner->profile_photo_url,
],
'users' => $teamModel->users->map(function (User $user): array {
return [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
'profile_photo_url' => $user->profile_photo_url,
'membership' => [
'id' => $user->membership->id,
'role' => $user->membership->role,
],
];
}),
'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array {
return [
'id' => $invitation->getKey(),
'email' => $invitation->email,
'role' => $invitation->role,
];
}),
],
'currencies' => array_map(function (Currency $currency): string {
return $currency->getName();

View File

@@ -266,7 +266,8 @@ class DashboardService
) as aggregate'))
->where('billable', '=', true)
->whereNotNull('billable_rate')
->where('user_id', '=', $user->id);
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey());
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
/** @var Collection<int, object{aggregate: int}> $resultDb */

View File

@@ -8,6 +8,7 @@ use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
@@ -174,7 +175,7 @@ class ReportPropertiesDto implements Castable
if (! is_string($id)) {
throw new \InvalidArgumentException('The given ID is not a string');
}
if (! Str::isUuid($id)) {
if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) {
throw new \InvalidArgumentException('The given ID is not a valid UUID');
}
$collection->push($id);

View File

@@ -167,7 +167,7 @@ class ExportService
$client->id,
$client->name,
$client->organization_id,
$client->archived_at ?? '',
$client->archived_at?->toIso8601ZuluString() ?? '',
$client->created_at?->toIso8601ZuluString() ?? '',
$client->updated_at?->toIso8601ZuluString() ?? '',
]);

View File

@@ -96,6 +96,30 @@ class LocalizationService
}
}
/**
* Format a duration for reporting contexts (PDF reports, places that display duration
* directly next to cost). Promotes the verbose `Hh Mm` format to the compact `HH:MM:SS`
* so totals stay narrow and reconcile with cost, which is always computed to the second.
*/
public function formatIntervalForReporting(CarbonInterval $interval): string
{
$promoted = [
IntervalFormat::HoursMinutes,
IntervalFormat::HoursMinutesColonSeparated,
];
if (! in_array($this->intervalFormat, $promoted, true)) {
return $this->formatInterval($interval);
}
$previous = $this->intervalFormat;
$this->intervalFormat = IntervalFormat::HoursMinutesSecondsColonSeparated;
try {
return $this->formatInterval($interval);
} finally {
$this->intervalFormat = $previous;
}
}
public function formatCurrency(Money $money): string
{
$currencyService = app(CurrencyService::class);

View File

@@ -196,6 +196,7 @@ class MemberService
$placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true;
$placeholderUser->current_team_id = $member->organization_id;
$placeholderUser->save();
$member->user()->associate($placeholderUser);

View File

@@ -71,7 +71,19 @@ class PermissionStore
/** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role);
return $roleObj->permissions ?? [];
$permissions = $roleObj->permissions ?? [];
// If the organization allows employees to manage tasks and the user is an employee,
// add the task management permissions for accessible projects
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
$permissions = array_merge($permissions, [
'tasks:create',
'tasks:update',
'tasks:delete',
]);
}
return $permissions;
}
/**

View File

@@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Log;
class TimeEntryFilter
{
public const string NONE_VALUE = 'none';
/**
* @var Builder<TimeEntry>
*/
@@ -149,7 +151,17 @@ class TimeEntryFilter
if ($clientIds === null) {
return $this;
}
$this->builder->whereIn('client_id', $clientIds);
$includeNone = in_array(self::NONE_VALUE, $clientIds, true);
$clientIds = array_values(array_filter($clientIds, fn (string $id): bool => $id !== self::NONE_VALUE));
$this->builder->where(function (Builder $builder) use ($clientIds, $includeNone): void {
if (count($clientIds) > 0) {
$builder->whereIn('client_id', $clientIds);
}
if ($includeNone) {
$builder->orWhereNull('client_id');
}
});
return $this;
}
@@ -162,7 +174,17 @@ class TimeEntryFilter
if ($projectIds === null) {
return $this;
}
$this->builder->whereIn('project_id', $projectIds);
$includeNone = in_array(self::NONE_VALUE, $projectIds, true);
$projectIds = array_values(array_filter($projectIds, fn (string $id): bool => $id !== self::NONE_VALUE));
$this->builder->where(function (Builder $builder) use ($projectIds, $includeNone): void {
if (count($projectIds) > 0) {
$builder->whereIn('project_id', $projectIds);
}
if ($includeNone) {
$builder->orWhereNull('project_id');
}
});
return $this;
}
@@ -175,10 +197,18 @@ class TimeEntryFilter
if ($tagIds === null) {
return $this;
}
$this->builder->where(function (Builder $builder) use ($tagIds): void {
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
foreach ($tagIds as $tagId) {
$builder->orWhereJsonContains('tags', $tagId);
}
if ($includeNone) {
$builder->orWhere(function (Builder $query): void {
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
});
}
});
return $this;
@@ -192,7 +222,17 @@ class TimeEntryFilter
if ($taskIds === null) {
return $this;
}
$this->builder->whereIn('task_id', $taskIds);
$includeNone = in_array(self::NONE_VALUE, $taskIds, true);
$taskIds = array_values(array_filter($taskIds, fn (string $id): bool => $id !== self::NONE_VALUE));
$this->builder->where(function (Builder $builder) use ($taskIds, $includeNone): void {
if (count($taskIds) > 0) {
$builder->whereIn('task_id', $taskIds);
}
if ($includeNone) {
$builder->orWhereNull('task_id');
}
});
return $this;
}

View File

@@ -31,12 +31,17 @@ class TimeEntryService
throw new LogicException('Rounding minutes must be greater than 0');
}
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
} elseif ($roundingType === TimeEntryRoundingType::Up) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
// If end is already on a boundary, keep it; otherwise round up to next boundary
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
'THEN '.$end.' '.
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
'END';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
}
}
}

2066
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('employees_can_manage_tasks');
});
}
};

View File

@@ -107,7 +107,7 @@ services:
- sail
- reverse-proxy
playwright:
image: mcr.microsoft.com/playwright:v1.51.1-jammy
image: mcr.microsoft.com/playwright:v1.58.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:

View File

@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { getPasswordResetUrl } from './utils/mailpit';
async function registerNewUser(page, email, password) {
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
@@ -35,14 +36,198 @@ test('can register and delete account', async ({ page }) => {
await registerNewUser(page, email, password);
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await page.getByRole('button', { name: 'Delete Account' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByPlaceholder('Password').fill(password);
await page.getByRole('button', { name: 'Delete Account' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete Account' }).click();
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByRole('paragraph')).toContainText(
await expect(page.getByRole('alert')).toContainText(
'These credentials do not match our records.'
);
});
test('shows error for invalid email on forgot password', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
// Request password reset with non-existent email
await page.getByLabel('Email').fill('nonexistent@example.com');
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
// Should show error message
await expect(page.getByText("We can't find a user with that email address.")).toBeVisible();
});
test('shows browser validation for invalid email format on forgot password', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
// Request password reset with invalid email format
const emailInput = page.getByLabel('Email');
await emailInput.fill('notanemail');
// Check for browser validation - the input should be invalid
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid);
expect(isInvalid).toBe(true);
});
test('shows browser validation for empty email on forgot password', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
// The email input is required, so it should be invalid when empty
const emailInput = page.getByLabel('Email');
// Check for browser validation - the input should be invalid because it's required and empty
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valueMissing);
expect(isInvalid).toBe(true);
});
test('can reset password via email link', async ({ page, request }) => {
// First register a new user
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
const originalPassword = 'suchagreatpassword123';
const newPassword = 'mynewsecurepassword456';
await registerNewUser(page, email, originalPassword);
// Log out
await page.getByTestId('current_user_button').click();
await page.getByText('Log Out').click();
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
// Request password reset
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
// Get password reset URL from email
const resetUrl = await getPasswordResetUrl(request, email);
// Navigate to reset page
await page.goto(resetUrl);
// Fill in new password
await page.getByLabel('Password', { exact: true }).fill(newPassword);
await page.getByLabel('Confirm Password').fill(newPassword);
await page.getByRole('button', { name: 'Reset Password' }).click();
// Should redirect to login page after successful reset
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
// Try logging in with new password
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(newPassword);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByTestId('dashboard_view')).toBeVisible();
});
test('shows validation error for password mismatch on reset', async ({ page, request }) => {
// First register a new user
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
const originalPassword = 'suchagreatpassword123';
await registerNewUser(page, email, originalPassword);
// Log out
await page.getByTestId('current_user_button').click();
await page.getByText('Log Out').click();
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
// Request password reset
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
// Get password reset URL from email
const resetUrl = await getPasswordResetUrl(request, email);
// Navigate to reset page
await page.goto(resetUrl);
// Fill in mismatched passwords
await page.getByLabel('Password', { exact: true }).fill('newpassword123');
await page.getByLabel('Confirm Password').fill('differentpassword456');
await page.getByRole('button', { name: 'Reset Password' }).click();
// Should show validation error
await expect(page.getByText('The password field confirmation does not match.')).toBeVisible();
});
test('shows validation error for short password on reset', async ({ page, request }) => {
// First register a new user
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
const originalPassword = 'suchagreatpassword123';
await registerNewUser(page, email, originalPassword);
// Log out
await page.getByTestId('current_user_button').click();
await page.getByText('Log Out').click();
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
// Request password reset
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
// Get password reset URL from email
const resetUrl = await getPasswordResetUrl(request, email);
// Navigate to reset page
await page.goto(resetUrl);
// Fill in short password
await page.getByLabel('Password', { exact: true }).fill('short');
await page.getByLabel('Confirm Password').fill('short');
await page.getByRole('button', { name: 'Reset Password' }).click();
// Should show validation error about minimum length
await expect(page.getByText('must be at least')).toBeVisible();
});
test('shows error for invalid login credentials', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
await page.getByLabel('Email').fill('nonexistent@example.com');
await page.getByLabel('Password').fill('wrongpassword123');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByText('These credentials do not match our records.')).toBeVisible();
});
test('shows error when registering with existing email', async ({ page }) => {
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
const password = 'suchagreatpassword123';
// Register first user
await registerNewUser(page, email, password);
// Log out
await page.getByTestId('current_user_button').click();
await page.getByText('Log Out').click();
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
// Try to register with the same email
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
await page.getByLabel('Name').fill('Another User');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
await page.getByLabel('Confirm Password').fill(password);
await page.getByLabel('I agree to the Terms of').click();
await page.getByRole('button', { name: 'Register' }).click();
// Should show error about email already taken
await expect(page.getByText('The resource already exists.')).toBeVisible();
});
test('shows validation error for weak password on registration', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
await page.getByLabel('Name').fill('Weak Password User');
await page.getByLabel('Email').fill(`weak+${Math.round(Math.random() * 10000)}@test.com`);
await page.getByLabel('Password', { exact: true }).fill('short');
await page.getByLabel('Confirm Password').fill('short');
await page.getByLabel('I agree to the Terms of').click();
await page.getByRole('button', { name: 'Register' }).click();
await expect(page.getByText('must be at least')).toBeVisible();
});

View File

@@ -0,0 +1,689 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { createBareTimeEntryViaApi, createTimeEntryWithTimestampsViaApi } from './utils/api';
async function goToCalendar(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
await expect(page.locator('.fc')).toBeVisible({ timeout: 10000 });
}
async function openSettingsPopover(page: Page) {
await page.getByRole('button', { name: 'Calendar settings' }).click();
await expect(page.getByText('Calendar Settings')).toBeVisible();
}
async function clearCalendarSettings(page: Page) {
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));
}
function getCalendarTitle(page: Page) {
return page.getByTestId('calendar-title');
}
async function scrollCalendarToTime(page: Page, time: string) {
await page.evaluate((t) => {
const slot = document.querySelector(`.fc-timegrid-slot-lane[data-time="${t}"]`);
if (slot) slot.scrollIntoView({ block: 'start' });
}, time);
await page.waitForTimeout(300);
}
async function getSlotHeight(page: Page): Promise<number> {
return await page.evaluate(() => {
const slots = Array.from(document.querySelectorAll('.fc-timegrid-slot-lane'));
for (let i = 0; i < slots.length; i++) {
const h = slots[i].getBoundingClientRect().height;
if (h > 0) return h;
}
return 20;
});
}
test.describe('Calendar Settings', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
});
test('settings popover shows all fields with correct defaults', async ({ page }) => {
await goToCalendar(page);
await openSettingsPopover(page);
await expect(page.getByLabel('Snap Interval')).toContainText('15 min');
await expect(page.getByLabel('Start Time')).toContainText('12:00 AM');
await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)');
await expect(page.getByLabel('Grid Scale')).toContainText('15 min');
});
test('snap interval can be changed and persists across reload', async ({ page }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Change snap interval to 30 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '30 min' }).click();
// Close the popover by pressing Escape
await page.keyboard.press('Escape');
// Verify localStorage was updated
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}')
);
expect(stored.snapMinutes).toBe(30);
// Reload and verify persistence
await page.reload();
await expect(page.locator('.fc')).toBeVisible();
await openSettingsPopover(page);
await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
});
test('start time change is applied to calendar and rejects invalid values', async ({
page,
}) => {
await goToCalendar(page);
// Verify 7 AM slot exists with default start (00:00)
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).not.toHaveCount(0);
await openSettingsPopover(page);
// Set end time to 6 PM first
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
// Change start time to 8 AM (valid)
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
// Try to set start time to 6 PM (invalid: equals end time) — should be rejected
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
// Should be rejected — start time stays at 8 AM
await expect(page.getByLabel('Start Time')).toContainText('8:00 AM');
// Close the popover
await page.keyboard.press('Escape');
// Calendar should no longer show hours before 8 AM
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
});
test('end time change is applied to calendar and rejects invalid values', async ({ page }) => {
await goToCalendar(page);
// Verify 19:00 slot exists with default end (24:00)
await expect(page.locator('.fc-timegrid-slot[data-time="19:00:00"]')).not.toHaveCount(0);
await openSettingsPopover(page);
// Set start time to 8 AM first
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
// Change end time to 6 PM (valid)
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '6:00 PM' }).click();
// Try to set end time to 8 AM (invalid: equals start time) — should be rejected
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
// Should be rejected — end time stays at 6 PM
await expect(page.getByLabel('End Time')).toContainText('6:00 PM');
// Close the popover
await page.keyboard.press('Escape');
// Calendar should no longer show hours at or after 6 PM
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
});
test('grid scale affects number of calendar slots', async ({ page }) => {
await goToCalendar(page);
// Count slots with default 15-min scale
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
// Change to 30 min scale (should halve the slots)
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Wait for FullCalendar to re-render with new slot count
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot').count();
expect(count).toBeLessThan(defaultSlotCount);
}).toPass({ timeout: 5000 });
const largerSlotCount = await page.locator('.fc-timegrid-slot').count();
// Navigate away and back to get a clean calendar mount
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await goToCalendar(page);
// Change to 5 min scale (many more slots)
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.keyboard.press('Escape');
// Wait for FullCalendar to re-render with new slot count
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot').count();
expect(count).toBeGreaterThan(largerSlotCount);
}).toPass({ timeout: 5000 });
});
test('all settings persist across navigation', async ({ page }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Change every setting
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '6:00 AM' }).click();
await page.getByLabel('End Time').click();
await page.getByRole('option', { name: '10:00 PM' }).click();
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
// Close the popover
await page.keyboard.press('Escape');
// Navigate away and back
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await goToCalendar(page);
// Verify all settings persisted
await openSettingsPopover(page);
await expect(page.getByLabel('Snap Interval')).toContainText('5 min');
await expect(page.getByLabel('Start Time')).toContainText('6:00 AM');
await expect(page.getByLabel('End Time')).toContainText('10:00 PM');
await expect(page.getByLabel('Grid Scale')).toContainText('30 min');
});
});
test.describe('Calendar Toolbar', () => {
test('prev and next buttons navigate the calendar', async ({ page }) => {
await goToCalendar(page);
// Use column headers to detect navigation (title only shows month which may not change)
const getHeaderTexts = async () => {
const headers = page.locator('.fc-col-header-cell');
return headers.allTextContents();
};
const initialHeaders = await getHeaderTexts();
// Click next
await page.getByRole('button', { name: 'Next', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
const nextHeaders = await getHeaderTexts();
expect(nextHeaders).not.toEqual(initialHeaders);
// Click prev — should go back to original
await page.getByRole('button', { name: 'Previous', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
const backHeaders = await getHeaderTexts();
expect(backHeaders).toEqual(initialHeaders);
});
test('today button returns to current week', async ({ page }) => {
await goToCalendar(page);
// Use column headers to detect navigation (title only shows month which may not change)
const getHeaderTexts = async () => {
const headers = page.locator('.fc-col-header-cell');
return headers.allTextContents();
};
const initialHeaders = await getHeaderTexts();
// Navigate away
await page.getByRole('button', { name: 'Next', exact: true }).click();
await page.getByRole('button', { name: 'Next', exact: true }).click();
const awayHeaders = await getHeaderTexts();
expect(awayHeaders).not.toEqual(initialHeaders);
// Click today
await page.getByRole('button', { name: 'today', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
const todayHeaders = await getHeaderTexts();
expect(todayHeaders).toEqual(initialHeaders);
});
test('view switcher toggles between week and day views', async ({ page }) => {
await goToCalendar(page);
// Default should be week view — verify multiple day columns exist
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
// Switch to day view
await page.getByRole('tab', { name: 'day', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
// Day view should show exactly 1 day column
await expect(page.locator('.fc-col-header-cell')).toHaveCount(1);
// Switch back to week view
await page.getByRole('tab', { name: 'week', exact: true }).click();
await expect(page.locator('.fc')).toBeVisible();
// Week view should show multiple day columns again
await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1);
});
});
test.describe('Visual Snapping', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
});
test('snap interval of 1 minute allows fine-grained positioning', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set snap interval to 1 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '1 min' }).click();
await page.keyboard.press('Escape');
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Snap 1min test', '1h');
await goToCalendar(page);
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Get target slot at a non-15-min boundary time
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
// Drag event to a position offset from the 15-min boundary
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
const minutes = startDate.getMinutes();
// With 1-min snap, any minute value is valid (0-59)
expect(minutes).toBeGreaterThanOrEqual(0);
expect(minutes).toBeLessThanOrEqual(59);
});
test('snap interval of 60 minutes creates hour-aligned entries', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set snap interval to 60 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '1 hour' }).click();
await page.keyboard.press('Escape');
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Snap 60min test', '1h');
await goToCalendar(page);
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Get target slot
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
// Drag event
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
const minutes = startDate.getMinutes();
// With 60-min snap, minutes should be 0 (on the hour)
expect(minutes).toBe(0);
});
test('changing snap interval mid-session affects next drag', async ({ page, ctx }) => {
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Snap change test', '1h');
await goToCalendar(page);
// Set snap to 15 min
await openSettingsPopover(page);
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '15 min' }).click();
await page.keyboard.press('Escape');
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Drag event to 14:00 area
const targetSlot14 = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox14 = await targetSlot14.boundingBox();
expect(targetBox14).not.toBeNull();
const putResponsePromise1 = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox14!.x + targetBox14!.width / 2, targetBox14!.y + 5, {
steps: 10,
});
await page.mouse.up();
const putResponse1 = await putResponsePromise1;
expect(putResponse1.status()).toBe(200);
const body1 = await putResponse1.json();
const startDate1 = new Date(body1.data.start);
expect(startDate1.getMinutes() % 15).toBe(0);
// Wait for query re-fetch/re-renders to fully settle after drag
await page.waitForTimeout(1500);
// Change snap to 30 min
// Use Escape first to ensure no stale popover is open, then re-open
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await openSettingsPopover(page);
await page.waitForTimeout(300);
await page.getByLabel('Snap Interval').click({ force: true });
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Scroll the calendar so the 10:00 target area is visible
await scrollCalendarToTime(page, '09:00:00');
// Drag event to 10:00 area
const targetSlot10 = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first();
const targetBox10 = await targetSlot10.boundingBox();
expect(targetBox10).not.toBeNull();
const putResponsePromise2 = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox10!.x + targetBox10!.width / 2, targetBox10!.y + 5, {
steps: 10,
});
await page.mouse.up();
const putResponse2 = await putResponsePromise2;
expect(putResponse2.status()).toBe(200);
const body2 = await putResponse2.json();
const startDate2 = new Date(body2.data.start);
expect(startDate2.getMinutes() % 30).toBe(0);
});
test('snap with different grid scale (slot != snap)', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set grid scale to 30 min, snap to 5 min
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.keyboard.press('Escape');
// Wait for re-render with 30-min grid
await expect(async () => {
const slotCount = await page.locator('.fc-timegrid-slot-lane').count();
// 24 hours * 2 slots/hour = 48 slots for 30-min grid
expect(slotCount).toBeLessThanOrEqual(48);
}).toPass({ timeout: 5000 });
// Verify grid is 30-min (fewer slots than default 15-min)
const slotCount = await page.locator('.fc-timegrid-slot-lane').count();
// Default 15-min grid has 96 slots; 30-min grid should have 48
expect(slotCount).toBeLessThanOrEqual(48);
// Create a 1h time entry and go to calendar
await createBareTimeEntryViaApi(ctx, 'Grid snap test', '1h');
await goToCalendar(page);
// Re-apply settings since goToCalendar navigates
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '5 min', exact: true }).click();
await page.keyboard.press('Escape');
// Scroll so both the event (9:00) and target (14:00) are in viewport
await scrollCalendarToTime(page, '08:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Capture target coordinates after scroll is settled
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
// Snap is 5 min, so minutes should be divisible by 5
expect(startDate.getMinutes() % 5).toBe(0);
});
});
test.describe('Calendar Settings Effects', () => {
test.beforeEach(async ({ page }) => {
await clearCalendarSettings(page);
});
test('start/end time hides slots outside visible range', async ({ page, ctx }) => {
// Create a time entry at 6 AM today
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 6, 0, 0);
const end = new Date(start.getTime() + 3600 * 1000); // 7 AM
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Early morning entry',
start: start.toISOString().replace(/\.\d{3}Z$/, 'Z'),
end: end.toISOString().replace(/\.\d{3}Z$/, 'Z'),
});
await goToCalendar(page);
// Verify 6 AM slot is visible with default settings
await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).not.toHaveCount(0);
// Set start time to 8 AM
await openSettingsPopover(page);
await page.getByLabel('Start Time').click();
await page.getByRole('option', { name: '8:00 AM' }).click();
await page.keyboard.press('Escape');
// 6 AM slot should be hidden
await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).toHaveCount(0);
// 8 AM slot should be visible
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
});
test('grid scale affects event visual height proportionally', async ({ page, ctx }) => {
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Height test', '1h');
await goToCalendar(page);
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
await event.scrollIntoViewIfNeeded();
// Get event height with default 15-min grid scale
const box15 = await event.boundingBox();
expect(box15).not.toBeNull();
const height15 = box15!.height;
// Change grid scale to 60 min
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '1 hour' }).click();
await page.keyboard.press('Escape');
// Wait for re-render and scroll event into view
await event.scrollIntoViewIfNeeded();
await expect(async () => {
const box = await event.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).not.toBe(height15);
}).toPass({ timeout: 5000 });
const box60 = await event.boundingBox();
expect(box60).not.toBeNull();
const height60 = box60!.height;
// Event should appear smaller with larger grid scale
expect(height15).toBeGreaterThan(height60);
});
test('snap interval affects drag granularity', async ({ page, ctx }) => {
await goToCalendar(page);
await openSettingsPopover(page);
// Set snap to 30 min
await page.getByLabel('Snap Interval').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Create a 1h time entry
await createBareTimeEntryViaApi(ctx, 'Drag granularity test', '1h');
await goToCalendar(page);
// Scroll the calendar so the 14:00 target area is visible
await scrollCalendarToTime(page, '13:00:00');
const event = page.locator('.fc-event').first();
await expect(event).toBeVisible();
// Get target slot
const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first();
const targetBox = await targetSlot.boundingBox();
expect(targetBox).not.toBeNull();
// Drag event
const putResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT'
);
await event.hover();
await page.mouse.down();
await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
const putResponse = await putResponsePromise;
expect(putResponse.status()).toBe(200);
const body = await putResponse.json();
const startDate = new Date(body.data.start);
const minutes = startDate.getMinutes();
// With 30-min snap, minutes should be 0 or 30
expect(minutes % 30).toBe(0);
});
test('settings apply immediately without page reload', async ({ page }) => {
await goToCalendar(page);
// Count slots with default grid scale (15 min)
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
// Change grid scale to 30 min
await openSettingsPopover(page);
await page.getByLabel('Grid Scale').click();
await page.getByRole('option', { name: '30 min' }).click();
await page.keyboard.press('Escape');
// Verify slot count changed without navigation
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot').count();
expect(count).toBeLessThan(defaultSlotCount);
}).toPass({ timeout: 5000 });
// Wait for FullCalendar to fully stabilize after re-render
await page.waitForTimeout(2000);
await expect(page.locator('.fc')).toBeVisible();
// Change start time to 8 AM
// FullCalendar re-render from grid scale change can make popover elements unstable.
// Retry the open+click sequence if it fails.
await expect(async () => {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await page.getByRole('button', { name: 'Calendar settings' }).click();
await expect(page.getByText('Calendar Settings')).toBeVisible();
const startTimeBtn = page.getByLabel('Start Time');
await expect(startTimeBtn).toBeVisible();
await startTimeBtn.click({ timeout: 3000 });
}).toPass({ timeout: 10000 });
await page.getByRole('option', { name: '8:00 AM' }).click();
await page.keyboard.press('Escape');
// Verify 7 AM slot is hidden without reload
await expect(async () => {
const count = await page.locator('.fc-timegrid-slot[data-time="07:00:00"]').count();
expect(count).toBe(0);
}).toPass({ timeout: 5000 });
});
});

2882
e2e/calendar.spec.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,23 @@
import { expect, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import {
createClientViaApi,
createProjectMemberViaApi,
createProjectViaApi,
createPublicProjectViaApi,
} from './utils/api';
import { getTableRowNames } from './utils/table';
async function goToProjectsOverview(page: Page) {
async function goToClientsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
}
// Create new project via modal
// Create new client via modal
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await goToClientsOverview(page);
await page.getByRole('button', { name: 'Create Client' }).click();
await page.getByPlaceholder('Client Name').fill(newClientName);
await Promise.all([
@@ -26,7 +34,7 @@ test('test that creating and deleting a new client via the modal works', async (
await expect(page.getByTestId('client_table')).toContainText(newClientName);
const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']");
moreButton.click();
await moreButton.click();
const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']");
await Promise.all([
@@ -41,13 +49,11 @@ test('test that creating and deleting a new client via the modal works', async (
await expect(page.getByTestId('client_table')).not.toContainText(newClientName);
});
test('test that archiving and unarchiving clients works', async ({ page }) => {
test('test that archiving and unarchiving clients works', async ({ page, ctx }) => {
const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Client' }).click();
await page.getByLabel('Client Name').fill(newClientName);
await createClientViaApi(ctx, { name: newClientName });
await page.getByRole('button', { name: 'Create Client' }).click();
await goToClientsOverview(page);
await expect(page.getByText(newClientName)).toBeVisible();
await page.getByRole('row').first().getByRole('button').click();
@@ -71,4 +77,300 @@ test('test that archiving and unarchiving clients works', async ({ page }) => {
]);
});
// TODO: Add Name Update Test
test('test that editing a client name works', async ({ page, ctx }) => {
const originalName = 'Original Client ' + Math.floor(1 + Math.random() * 10000);
const updatedName = 'Updated Client ' + Math.floor(1 + Math.random() * 10000);
await createClientViaApi(ctx, { name: originalName });
await goToClientsOverview(page);
await expect(page.getByText(originalName)).toBeVisible();
// Open edit modal via actions menu
const moreButton = page.locator("[aria-label='Actions for Client " + originalName + "']");
await moreButton.click();
await page.getByTestId('client_edit').click();
// Update the client name
await page.getByPlaceholder('Client Name').fill(updatedName);
await Promise.all([
page.getByRole('button', { name: 'Update Client' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/clients') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
// Verify updated name is shown and old name is gone
await expect(page.getByTestId('client_table')).toContainText(updatedName);
await expect(page.getByTestId('client_table')).not.toContainText(originalName);
});
test('test that deleting a client via actions menu works', async ({ page, ctx }) => {
const clientName = 'DeleteMe Client ' + Math.floor(1 + Math.random() * 10000);
await createClientViaApi(ctx, { name: clientName });
await goToClientsOverview(page);
await expect(page.getByTestId('client_table')).toContainText(clientName);
const moreButton = page.locator("[aria-label='Actions for Client " + clientName + "']");
await moreButton.click();
const deleteButton = page.locator("[aria-label='Delete Client " + clientName + "']");
await Promise.all([
deleteButton.click(),
page.waitForResponse(
(response) =>
response.url().includes('/clients') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
]);
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
});
// =============================================
// Context Menu Tests
// =============================================
test('test that client context menu edit updates the client', async ({ page, ctx }) => {
const clientName = 'CtxEditClient ' + Math.floor(1 + Math.random() * 10000);
const updatedName = 'CtxUpdatedClient ' + Math.floor(1 + Math.random() * 10000);
await createClientViaApi(ctx, { name: clientName });
await goToClientsOverview(page);
const row = page.getByRole('row').filter({ hasText: clientName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByPlaceholder('Client Name').fill(updatedName);
await Promise.all([
page.getByRole('button', { name: 'Update Client' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/clients') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
await expect(page.getByTestId('client_table')).toContainText(updatedName);
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
});
test('test that client context menu archive archives the client', async ({ page, ctx }) => {
const clientName = 'CtxArchiveClient ' + Math.floor(1 + Math.random() * 10000);
await createClientViaApi(ctx, { name: clientName });
await goToClientsOverview(page);
const row = page.getByRole('row').filter({ hasText: clientName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/clients') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('menuitem', { name: 'Archive' }).click(),
]);
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
});
test('test that client context menu delete deletes the client', async ({ page, ctx }) => {
const clientName = 'CtxDeleteClient ' + Math.floor(1 + Math.random() * 10000);
await createClientViaApi(ctx, { name: clientName });
await goToClientsOverview(page);
const row = page.getByRole('row').filter({ hasText: clientName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/clients') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
});
// =============================================
// Sorting Tests
// =============================================
async function clearClientTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('client-table-state');
});
}
test('test that sorting clients by name and status works', async ({ page, ctx }) => {
await createClientViaApi(ctx, { name: 'AAA SortClient' });
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
await expect(table).toBeVisible();
// -- Name sorting (default is name asc) --
let names = await getTableRowNames(table);
expect(names.indexOf('AAA SortClient')).toBeLessThan(names.indexOf('ZZZ SortClient'));
const nameHeader = table.getByText('Name').first();
await nameHeader.click(); // toggle to desc
names = await getTableRowNames(table);
expect(names.indexOf('ZZZ SortClient')).toBeLessThan(names.indexOf('AAA SortClient'));
// -- Status sorting --
const statusHeader = table.getByText('Status').first();
await statusHeader.click(); // asc
await expect(statusHeader.locator('svg')).toBeVisible();
await statusHeader.click(); // desc
await expect(statusHeader.locator('svg')).toBeVisible();
});
test('test that sorting clients by project count works', async ({ page, ctx }) => {
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
// Create projects for the first client
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
await expect(table).toBeVisible();
// Click Projects header - first click should sort desc (most projects first)
const projectsHeader = table.getByText('Projects').first();
await projectsHeader.click();
await expect(projectsHeader.locator('svg')).toBeVisible();
let names = await getTableRowNames(table);
expect(names.indexOf('ManyProjects Client')).toBeLessThan(names.indexOf('NoProjects Client'));
// Second click toggles to asc (least projects first)
await projectsHeader.click();
names = await getTableRowNames(table);
expect(names.indexOf('NoProjects Client')).toBeLessThan(names.indexOf('ManyProjects Client'));
});
test('test that client sort state persists after page reload', async ({ page }) => {
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
await expect(table).toBeVisible();
const nameHeader = table.getByText('Name').first();
await nameHeader.click(); // toggle to desc
await expect(nameHeader.locator('svg')).toBeVisible();
await page.reload();
await expect(page.getByTestId('client_table')).toBeVisible();
await expect(
page.getByTestId('client_table').getByText('Name').first().locator('svg')
).toBeVisible();
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Clients Restrictions', () => {
test('employee can view clients but cannot create', async ({ ctx, employee }) => {
// Create a client with a public project so the employee can see the client
const clientName = 'EmpViewClient ' + Math.floor(Math.random() * 10000);
const client = await createClientViaApi(ctx, { name: clientName });
await createPublicProjectViaApi(ctx, { name: 'EmpClientProj', client_id: client.id });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
await expect(employee.page.getByTestId('clients_view')).toBeVisible({
timeout: 10000,
});
// Employee can see the client
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
// Employee cannot see Create Client button
await expect(
employee.page.getByRole('button', { name: 'Create Client' })
).not.toBeVisible();
});
test('employee cannot see edit/delete/archive actions on clients', async ({
ctx,
employee,
}) => {
const clientName = 'EmpActionsClient ' + Math.floor(Math.random() * 10000);
const client = await createClientViaApi(ctx, { name: clientName });
await createPublicProjectViaApi(ctx, { name: 'EmpClientActProj', client_id: client.id });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
// Click the actions dropdown trigger to open the menu
const actionsButton = employee.page.locator(
`[aria-label='Actions for Client ${clientName}']`
);
await actionsButton.click();
// The dropdown menu items (Edit, Archive, Delete) should NOT be visible
await expect(
employee.page.locator(`[aria-label='Edit Client ${clientName}']`)
).not.toBeVisible();
await expect(
employee.page.locator(`[aria-label='Archive Client ${clientName}']`)
).not.toBeVisible();
await expect(
employee.page.locator(`[aria-label='Delete Client ${clientName}']`)
).not.toBeVisible();
});
test('employee can see client when they are a member of its private project', async ({
ctx,
employee,
}) => {
const clientName = 'EmpPrivateClient ' + Math.floor(Math.random() * 10000);
const client = await createClientViaApi(ctx, { name: clientName });
// Create a private project under this client
const project = await createProjectViaApi(ctx, {
name: 'PrivateProj',
client_id: client.id,
is_public: false,
});
// Add the employee as a project member
await createProjectMemberViaApi(ctx, project.id, {
member_id: employee.memberId,
});
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
await expect(employee.page.getByTestId('clients_view')).toBeVisible({
timeout: 10000,
});
// Employee can see the client because they are a member of its private project
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
});
});

474
e2e/command-palette.spec.ts Normal file
View File

@@ -0,0 +1,474 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page } from '@playwright/test';
const TIMER_BUTTON_SELECTOR = '[data-testid="dashboard_timer"] [data-testid="timer_button"]';
async function goToDashboard(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
}
async function openCommandPalette(page: Page) {
await page.getByTestId('command_palette_button').click();
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
}
async function closeCommandPalette(page: Page) {
await page.keyboard.press('Escape');
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
}
async function searchInCommandPalette(page: Page, query: string) {
await page.locator('[role="dialog"] input').fill(query);
// Wait for search debounce to settle (command palette uses a debounced search)
await page.waitForTimeout(300);
}
async function selectCommand(page: Page, name: string) {
const option = page.getByRole('option', { name, exact: true });
await option.scrollIntoViewIfNeeded();
await option.click();
}
async function assertTimerIsRunning(page: Page) {
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
/bg-red-400\/80/,
{
timeout: 10000,
}
);
}
async function assertTimerIsStopped(page: Page) {
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
/bg-accent-300\/70/,
{
timeout: 10000,
}
);
}
test.describe('Command Palette', () => {
test.describe('Opening and Closing', () => {
test('opens via search button and closes with Escape', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await expect(
page.locator('[role="dialog"] input[placeholder*="command"]')
).toBeVisible();
await closeCommandPalette(page);
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
test('opens with keyboard shortcut', async ({ page }) => {
await goToDashboard(page);
// Click on body to ensure page has focus
await page.locator('body').click();
// Use ControlOrMeta which resolves to Ctrl on Linux/Windows and Meta on macOS
await page.keyboard.press('ControlOrMeta+k');
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
});
test('clears search on close', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'dashboard');
await closeCommandPalette(page);
await openCommandPalette(page);
await expect(page.locator('[role="dialog"] input')).toHaveValue('');
});
});
test.describe('Command Display', () => {
test('displays navigation and timer commands', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
// Navigation commands
await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Go to Time' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();
// Timer commands
await expect(page.getByRole('option', { name: 'Start Timer' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Create Time Entry' })).toBeVisible();
});
test('displays create commands', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Create Client' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Create Tag' })).toBeVisible();
});
});
test.describe('Navigation Commands', () => {
// Tests use element visibility assertions for consistency with codebase patterns
const navigationTests = [
['Go to Dashboard', 'dashboard_view', '/time'],
['Go to Time', 'time_view', '/dashboard'],
['Go to Calendar', 'calendar_view', '/dashboard'],
['Go to Projects', 'projects_view', '/dashboard'],
['Go to Clients', 'clients_view', '/dashboard'],
['Go to Members', 'members_view', '/dashboard'],
['Go to Tags', 'tags_view', '/dashboard'],
] as const;
for (const [commandName, expectedTestId, startUrl] of navigationTests) {
test(`${commandName}`, async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + startUrl);
await openCommandPalette(page);
await searchInCommandPalette(page, commandName.replace('Go to ', ''));
await selectCommand(page, commandName);
await expect(page.getByTestId(expectedTestId)).toBeVisible({ timeout: 10000 });
});
}
test('Go to Profile', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'Profile');
await selectCommand(page, 'Go to Profile');
// Profile page doesn't have a testId, so check for a unique element
await expect(page.getByRole('heading', { name: 'Profile Information' })).toBeVisible({
timeout: 10000,
});
});
test('Go to Reporting Overview', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'Reporting Overview');
await selectCommand(page, 'Go to Reporting Overview');
await expect(page.getByTestId('reporting_view')).toBeVisible({ timeout: 10000 });
});
test('Go to Settings', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'Settings');
await selectCommand(page, 'Go to Settings');
// Settings page uses team settings which has an h3 heading
await expect(
page.getByRole('heading', { name: 'Organization Name', level: 3 })
).toBeVisible({
timeout: 10000,
});
});
});
test.describe('Search and Filtering', () => {
test('filters commands when searching', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'dashboard');
await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
await searchInCommandPalette(page, 'calendar');
await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();
});
test('search is case insensitive', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'DASHBOARD');
await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
});
test('partial word search works', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'proj');
await expect(page.getByRole('option', { name: 'Go to Projects' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible();
});
test('keyboard navigation and selection works', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});
});
test.describe('Theme Commands', () => {
test('switches to dark theme', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'Dark Theme');
await selectCommand(page, 'Switch to Dark Theme');
await expect(page.locator('html')).toHaveClass(/dark/);
});
test('switches to light theme', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'Light Theme');
await selectCommand(page, 'Switch to Light Theme');
await expect(page.locator('html')).toHaveClass(/light/);
});
});
test.describe('Timer Commands', () => {
test('starts and stops timer', async ({ page }) => {
await goToDashboard(page);
// Start timer
await openCommandPalette(page);
await searchInCommandPalette(page, 'Start Timer');
await selectCommand(page, 'Start Timer');
await assertTimerIsRunning(page);
// Stop timer
await openCommandPalette(page);
await searchInCommandPalette(page, 'Stop Timer');
await selectCommand(page, 'Stop Timer');
await assertTimerIsStopped(page);
});
test('shows active timer commands when running', async ({ page }) => {
await goToDashboard(page);
// Start timer
await openCommandPalette(page);
await searchInCommandPalette(page, 'Start Timer');
await selectCommand(page, 'Start Timer');
await assertTimerIsRunning(page);
// Check active timer commands - search for them to ensure visibility
await openCommandPalette(page);
await searchInCommandPalette(page, 'Set Project');
await expect(page.getByRole('option', { name: 'Set Project' })).toBeVisible();
});
});
test.describe('Create Commands', () => {
test('opens create time entry modal', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'Create Time Entry');
await selectCommand(page, 'Create Time Entry');
await expect(
page.locator('[role="dialog"]').getByText('Create manual time entry')
).toBeVisible();
});
test('opens create project modal', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'Create Project');
await selectCommand(page, 'Create Project');
await expect(
page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Project' })
).toBeVisible();
});
test('opens create client modal', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'Create Client');
await selectCommand(page, 'Create Client');
await expect(
page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Client' })
).toBeVisible();
});
test('opens create tag modal', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'Create Tag');
await selectCommand(page, 'Create Tag');
await expect(page.locator('[role="dialog"]').getByText('Create Tags')).toBeVisible();
});
test('opens invite member modal', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
await searchInCommandPalette(page, 'Invite Member');
await selectCommand(page, 'Invite Member');
// Modal has title with "Invite Member" text - use first() to get the title span
await expect(
page.locator('[role="dialog"]').getByText('Invite Member').first()
).toBeVisible();
});
});
test.describe('Entity Search', () => {
test('searches for projects and navigates on selection', async ({ page }) => {
const projectName = 'CmdPalette' + Math.floor(Math.random() * 10000);
// Create project first
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByPlaceholder('The next big thing').fill(projectName);
await page.getByRole('button', { name: 'Create Project' }).click();
// Wait for project to be created and page to update
await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });
// Search from the projects page where the query cache now has the new project
await openCommandPalette(page);
await searchInCommandPalette(page, projectName);
// Wait for entity search to return results
const projectOption = page.getByRole('option').filter({ hasText: projectName });
await expect(projectOption).toBeVisible({
timeout: 5000,
});
// Select the project from search results
await projectOption.click();
});
});
test.describe('Organization Switching', () => {
test('shows switch commands only when multiple organizations exist', async ({ page }) => {
await goToDashboard(page);
await openCommandPalette(page);
// With only one org, no switch commands should appear
await searchInCommandPalette(page, 'Switch to');
// Check that no organization switch commands appear (only theme switch commands)
const switchOptions = page.getByRole('option', { name: /^Switch to (?!.*Theme)/ });
await expect(switchOptions).toHaveCount(0);
});
test('switches organization via command palette', async ({ page }) => {
const newOrgName = 'TestOrg' + Math.floor(Math.random() * 10000);
// Create a new organization
await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create');
await page.getByLabel('Organization Name').fill(newOrgName);
await page.getByRole('button', { name: 'Create' }).click();
// Wait for navigation to new org's dashboard
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
// Use visible switcher (desktop sidebar has one, mobile header has another)
const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible');
// Verify we're in the new org by checking the switcher
await expect(orgSwitcher).toContainText(newOrgName);
// Get the original org name from switcher dropdown
await orgSwitcher.click();
await expect(page.getByText('Switch Organizations')).toBeVisible();
// Find the other organization button (has ArrowRightIcon, not CheckCircleIcon)
// The button contains an SVG and a div with the org name
const otherOrgItem = page.locator('form button').filter({ hasText: /.+/ }).first();
await expect(otherOrgItem).toBeVisible();
const originalOrgName = (await otherOrgItem.innerText()).trim();
await page.keyboard.press('Escape'); // Close dropdown
// Now use command palette to switch back to original org
await openCommandPalette(page);
await searchInCommandPalette(page, 'Switch to');
// Should see the switch command for the original org
const switchCommand = page.getByRole('option', {
name: new RegExp(`Switch to ${originalOrgName}`),
});
await expect(switchCommand).toBeVisible();
await switchCommand.click();
// Wait for organization switch to complete
await expect(orgSwitcher).toContainText(originalOrgName, {
timeout: 10000,
});
});
test('organization switch commands appear in Organization group', async ({ page }) => {
const newOrgName = 'GroupTestOrg' + Math.floor(Math.random() * 10000);
// Create a new organization to ensure we have multiple
await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create');
await page.getByLabel('Organization Name').fill(newOrgName);
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
// Open command palette and check for Organization group heading
await openCommandPalette(page);
// The Organization group should be visible when there are switch commands
await expect(page.getByText('Organization', { exact: true })).toBeVisible();
});
});
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Command Palette Restrictions', () => {
test('employee command palette does not show restricted navigation commands', async ({
employee,
}) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// Open command palette
await employee.page.getByTestId('command_palette_button').click();
await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
// Available navigation commands
await expect(employee.page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
await expect(employee.page.getByRole('option', { name: 'Go to Time' })).toBeVisible();
await expect(employee.page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();
// Restricted commands should NOT be visible
await expect(
employee.page.getByRole('option', { name: 'Go to Members' })
).not.toBeVisible();
await expect(
employee.page.getByRole('option', { name: 'Go to Settings' })
).not.toBeVisible();
});
test('employee command palette does not show create commands for restricted entities', async ({
employee,
}) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// Open command palette
await employee.page.getByTestId('command_palette_button').click();
await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
// Search for "Create" to filter
await employee.page.locator('[role="dialog"] input').fill('Create');
await employee.page.waitForTimeout(300);
// Should NOT see create commands for restricted entities
await expect(
employee.page.getByRole('option', { name: 'Create Project' })
).not.toBeVisible();
await expect(
employee.page.getByRole('option', { name: 'Create Client' })
).not.toBeVisible();
await expect(employee.page.getByRole('option', { name: 'Create Tag' })).not.toBeVisible();
await expect(
employee.page.getByRole('option', { name: 'Invite Member' })
).not.toBeVisible();
// Should still see Create Time Entry (employees can create time entries)
await expect(
employee.page.getByRole('option', { name: 'Create Time Entry' })
).toBeVisible();
});
});

198
e2e/dashboard.spec.ts Normal file
View File

@@ -0,0 +1,198 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page } from '@playwright/test';
import {
assertThatTimerHasStarted,
assertThatTimerIsStopped,
newTimeEntryResponse,
startOrStopTimerWithButton,
stoppedTimeEntryResponse,
} from './utils/currentTimeEntry';
import {
createBareTimeEntryViaApi,
createPublicProjectViaApi,
createTimeEntryViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
async function goToDashboard(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
}
test('test that dashboard loads with all expected sections', async ({ page }) => {
await goToDashboard(page);
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
// Timer section (scoped to dashboard_timer to avoid matching sidebar timer)
await expect(page.getByTestId('time_entry_description')).toBeVisible();
await expect(
page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
).toBeVisible();
// Dashboard cards
await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible();
await expect(page.getByText('Last 7 Days', { exact: true })).toBeVisible();
await expect(page.getByText('Activity Graph', { exact: true })).toBeVisible();
await expect(page.getByText('Team Activity', { exact: true })).toBeVisible();
// Weekly overview section
await expect(page.getByText('This Week', { exact: true })).toBeVisible();
});
test('test that dashboard shows time entry data after creating entries', async ({ page, ctx }) => {
await createBareTimeEntryViaApi(ctx, 'Dashboard test entry', '1h');
await goToDashboard(page);
await expect(page.getByTestId('dashboard_view')).toBeVisible();
// The "Last 7 Days" or "This Week" section should reflect tracked time
await expect(page.getByText('This Week', { exact: true })).toBeVisible();
});
test('test that timer on dashboard can start and stop', async ({ page }) => {
await goToDashboard(page);
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerIsStopped(page);
});
test('test that weekly overview section displays stat cards', async ({ page, ctx }) => {
await createBareTimeEntryViaApi(ctx, 'Stats test entry', '2h');
await goToDashboard(page);
// Verify stat card labels are visible
await expect(page.getByText('Spent Time')).toBeVisible();
await expect(page.getByText('Billable Time')).toBeVisible();
await expect(page.getByText('Billable Amount')).toBeVisible();
});
test('test that stopping timer refreshes dashboard data', async ({ page }) => {
await goToDashboard(page);
// Start timer
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
// Stop timer and verify dashboard queries are refetched
await Promise.all([
stoppedTimeEntryResponse(page),
page.waitForResponse(
(response) =>
response.url().includes('/charts/') &&
response.request().method() === 'GET' &&
response.status() === 200
),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Dashboard Restrictions', () => {
test('employee dashboard loads and timer is functional', async ({ employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// Timer should be available
await expect(
employee.page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(employee.page.locator(':visible'))
).toBeVisible();
await expect(employee.page.getByTestId('time_entry_description')).toBeEditable();
});
test('employee cannot see Team Activity card', async ({ employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// Other dashboard cards should be visible
await expect(employee.page.getByText('Recent Time Entries', { exact: true })).toBeVisible();
// Team Activity should NOT be visible for employees
await expect(employee.page.getByText('Team Activity', { exact: true })).not.toBeVisible();
});
test('employee cannot see Cost column in This Week table by default', async ({
ctx,
employee,
}) => {
const project = await createPublicProjectViaApi(ctx, {
name: 'EmpDashBillProj',
is_billable: true,
billable_rate: 10000,
});
await createTimeEntryViaApi(
{ ...ctx, memberId: employee.memberId },
{
description: 'Emp dashboard cost entry',
duration: '1h',
projectId: project.id,
billable: true,
}
);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// This Week table should be visible
await expect(employee.page.getByText('This Week', { exact: true })).toBeVisible();
// Duration column should be visible, but Cost column should NOT
await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible();
await expect(employee.page.getByText('Cost', { exact: true })).not.toBeVisible();
});
test('employee can see Cost column in This Week table when employees_can_see_billable_rates is enabled', async ({
ctx,
employee,
}) => {
await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true });
const project = await createPublicProjectViaApi(ctx, {
name: 'EmpDashBillVisProj',
is_billable: true,
billable_rate: 10000,
});
await createTimeEntryViaApi(
{ ...ctx, memberId: employee.memberId },
{
description: 'Emp dashboard cost visible entry',
duration: '1h',
projectId: project.id,
billable: true,
}
);
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// Both Duration and Cost columns should be visible
await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible();
await expect(employee.page.getByText('Cost', { exact: true })).toBeVisible();
// 1h at 100.00/h = 100.00 EUR cost should be visible
await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible();
});
});

154
e2e/import-export.spec.ts Normal file
View File

@@ -0,0 +1,154 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page } from '@playwright/test';
import path from 'path';
async function goToImportExport(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/import');
}
test('test that import page loads with type dropdown and file upload', async ({ page }) => {
await goToImportExport(page);
await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 });
// Import section
await expect(page.getByRole('heading', { name: 'Import Data' })).toBeVisible();
await expect(page.locator('#importType')).toBeVisible();
// Export section
await expect(page.getByRole('heading', { name: 'Export Data' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible();
});
test('test that selecting an import type shows instructions', async ({ page }) => {
await goToImportExport(page);
// Select a Toggl import type
await page.getByLabel('Import Type').selectOption({ index: 1 });
// Instructions should appear
await expect(page.getByText('Instructions:')).toBeVisible();
});
test('test that importing without selecting type shows error', async ({ page }) => {
await goToImportExport(page);
// Click Import Data without selecting a type
await page.getByRole('button', { name: 'Import Data' }).click();
// Should show an error notification
await expect(page.getByText('Please select the import type')).toBeVisible();
});
test('test that importing without selecting file shows error', async ({ page }) => {
await goToImportExport(page);
// Select an import type first
await page.getByLabel('Import Type').selectOption({ index: 1 });
// Click Import Data without selecting a file
await page.getByRole('button', { name: 'Import Data' }).click();
// Should show an error notification
await expect(
page.getByText('Please select the CSV or ZIP file that you want to import')
).toBeVisible();
});
test('test that export button triggers export and shows success modal', async ({ page }) => {
await goToImportExport(page);
await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible();
// Override window.open to prevent the page from navigating away to the
// download URL (the app uses window.open(url, '_self') which would navigate
// away before we can verify the success modal)
await page.evaluate(() => {
window.open = () => null;
});
// Click Export Organization Data and wait for the API response
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/export') &&
response.request().method() === 'POST' &&
response.status() === 200,
{ timeout: 60000 }
),
page.getByRole('button', { name: 'Export Organization Data' }).click(),
]);
// Success modal should appear after export completes
await expect(page.getByText('The export was successful!')).toBeVisible();
});
test('test that import type dropdown has multiple options', async ({ page }) => {
await goToImportExport(page);
// The dropdown should load with options from the API
await page.waitForResponse(
(response) =>
response.url().includes('/importers') &&
response.request().method() === 'GET' &&
response.status() === 200
);
// Verify the select has options besides the default placeholder
const options = page.getByLabel('Import Type').locator('option');
const count = await options.count();
// Should have at least the placeholder + some import types
expect(count).toBeGreaterThan(1);
});
test('test that importing a generic time entries CSV works', async ({ page }) => {
await goToImportExport(page);
await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 });
// Select "Generic Time Entries" import type
await page.getByLabel('Import Type').selectOption({ label: 'Generic Time Entries' });
await expect(page.getByText('Instructions:')).toBeVisible();
// Upload the test CSV file
const csvPath = path.resolve('resources/testfiles/generic_time_entries_import_test_1.csv');
await page.locator('#file-upload').setInputFiles(csvPath);
// Click Import and wait for the API response
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/import') &&
response.request().method() === 'POST' &&
response.status() === 200,
{ timeout: 30000 }
),
page.getByRole('button', { name: 'Import Data' }).click(),
]);
// Verify success modal with import results
await expect(page.getByRole('heading', { name: 'Import Result' })).toBeVisible();
await expect(page.getByText('The import was successful!')).toBeVisible();
// The CSV has 2 time entries, 1 client, 2 projects, 1 task
await expect(page.getByText('Time entries created:').locator('..')).toContainText('2');
await expect(page.getByText('Projects created:').locator('..')).toContainText('2');
await expect(page.getByText('Clients created:').locator('..')).toContainText('1');
await expect(page.getByText('Tasks created:').locator('..')).toContainText('1');
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Import Restrictions', () => {
test('employee does not see Import / Export link in the sidebar', async ({ employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// The Import / Export link should NOT be visible in the sidebar for employees
await expect(
employee.page.getByRole('link', { name: 'Import / Export' })
).not.toBeVisible();
});
});

View File

@@ -3,53 +3,69 @@
// TODO: Remove Invitation
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page } from '@playwright/test';
import { inviteAndAcceptMember } from './utils/members';
import {
createPlaceholderMemberViaImportApi,
getMembersViaApi,
updateMemberBillableRateViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import { getTableRowNames } from './utils/table';
async function goToMembersPage(page) {
// Tests that invite + accept members need more time
test.describe.configure({ timeout: 45000 });
async function goToMembersPage(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
}
async function openInviteMemberModal(page) {
async function openInviteMemberModal(page: Page) {
await Promise.all([
page.getByRole('button', { name: 'Invite Member' }).click(),
expect(page.getByPlaceholder('Member Email')).toBeVisible(),
]);
}
test('test that new manager can be invited', async ({ page }) => {
test('test that new manager can be invited and accepted', async ({ page, browser }) => {
const memberId = Math.round(Math.random() * 100000);
const memberEmail = `manager+${memberId}@invite.test`;
await inviteAndAcceptMember(page, browser, 'Invited Mgr', memberEmail, 'Manager');
// Verify the member appears in the members table with the correct role
await goToMembersPage(page);
await openInviteMemberModal(page);
const editorId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Manager' }).click();
await Promise.all([
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
]);
const memberRow = page.getByRole('row').filter({ hasText: 'Invited Mgr' });
await expect(memberRow).toBeVisible();
await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible();
});
test('test that new employee can be invited', async ({ page }) => {
test('test that new employee can be invited and accepted', async ({ page, browser }) => {
const memberId = Math.round(Math.random() * 100000);
const memberEmail = `employee+${memberId}@invite.test`;
await inviteAndAcceptMember(page, browser, 'Invited Emp', memberEmail, 'Employee');
// Verify the member appears in the members table with the correct role
await goToMembersPage(page);
await openInviteMemberModal(page);
const editorId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
await expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
]);
const memberRow = page.getByRole('row').filter({ hasText: 'Invited Emp' });
await expect(memberRow).toBeVisible();
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
});
test('test that new admin can be invited', async ({ page }) => {
test('test that new admin can be invited and accepted', async ({ page, browser }) => {
const memberId = Math.round(Math.random() * 100000);
const memberEmail = `admin+${memberId}@invite.test`;
await inviteAndAcceptMember(page, browser, 'Invited Adm', memberEmail, 'Administrator');
// Verify the member appears in the members table with the correct role
await goToMembersPage(page);
await openInviteMemberModal(page);
const adminId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
await page.getByRole('button', { name: 'Administrator' }).click();
await Promise.all([
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
expect(page.getByRole('main')).toContainText(`new+${adminId}@admin.test`),
]);
const memberRow = page.getByRole('row').filter({ hasText: 'Invited Adm' });
await expect(memberRow).toBeVisible();
await expect(memberRow.getByText('Admin', { exact: true })).toBeVisible();
});
test('test that error shows if no role is selected', async ({ page }) => {
await goToMembersPage(page);
await openInviteMemberModal(page);
@@ -69,8 +85,8 @@ test('test that organization billable rate can be updated with all existing time
const newBillableRate = Math.round(Math.random() * 10000);
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click();
await page.getByText('Organization Default Rate').click();
await page.getByText('Custom Rate').click();
await page.getByRole('combobox').last().click();
await page.getByRole('option', { name: 'Custom Rate' }).click();
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Member' }).click();
@@ -91,3 +107,835 @@ test('test that organization billable rate can be updated with all existing time
),
]);
});
test('test that switching member billable rate from custom back to default rate works', async ({
page,
ctx,
}) => {
// Set a known org billable rate
await updateOrganizationSettingViaApi(ctx, { billable_rate: 12000 });
// Create a placeholder member with a custom billable rate
await createPlaceholderMemberViaImportApi(ctx, 'CustomToDefault Member');
const members = await getMembersViaApi(ctx);
const member = members.find((m) => m.name === 'CustomToDefault Member');
expect(member).toBeDefined();
await updateMemberBillableRateViaApi(ctx, member!.id, 25000);
await goToMembersPage(page);
const memberRow = page.getByRole('row').filter({ hasText: 'CustomToDefault Member' });
await expect(memberRow).toBeVisible();
// Open edit modal
await memberRow.getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Verify it starts on Custom Rate
const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();
await expect(billableCombobox).toContainText('Custom Rate');
// Switch to Default Rate
await billableCombobox.click();
await page.getByRole('option', { name: 'Default Rate' }).click();
await expect(billableCombobox).toContainText('Default Rate');
// Verify the billable rate input is disabled
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
// Submit — billable_rate changes from 25000 to null, so confirmation dialog appears
await page.getByRole('button', { name: 'Update Member' }).click();
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
await expect(page.getByText('the default rate of the organization')).toBeVisible();
// Confirm the update
await Promise.all([
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
page.waitForRequest(
(request) =>
request.url().includes('/members/') &&
request.method() === 'PUT' &&
request.postDataJSON().billable_rate === null
),
]);
// Verify both dialogs are closed
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('test that default rate shows disabled input with organization billable rate', async ({
page,
ctx,
}) => {
// Set a known org billable rate (150.00)
await updateOrganizationSettingViaApi(ctx, { billable_rate: 15000 });
await goToMembersPage(page);
// Open edit modal for the owner (who uses default rate by default)
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Verify it's on Default Rate
const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();
await expect(billableCombobox).toContainText('Default Rate');
// Verify the input is disabled and shows the org rate (formatted with currency)
const billableInput = page.getByPlaceholder('Billable Rate');
await expect(billableInput).toBeDisabled();
await expect(billableInput).toHaveAttribute('aria-valuenow', '150');
// Close the dialog
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('test that cancelling the billable rate confirmation dialog does not update the member', async ({
page,
ctx,
}) => {
// Create a placeholder member with a custom billable rate
await createPlaceholderMemberViaImportApi(ctx, 'CancelConfirm Member');
const members = await getMembersViaApi(ctx);
const member = members.find((m) => m.name === 'CancelConfirm Member');
expect(member).toBeDefined();
await updateMemberBillableRateViaApi(ctx, member!.id, 10000);
await goToMembersPage(page);
const memberRow = page.getByRole('row').filter({ hasText: 'CancelConfirm Member' });
await expect(memberRow).toBeVisible();
// Open edit modal
await memberRow.getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Change the billable rate
await page.getByPlaceholder('Billable Rate').fill('200');
// Click Update Member — confirmation dialog should appear
await page.getByRole('button', { name: 'Update Member' }).click();
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
// Set up listener to verify no PUT request is sent after cancel
let putRequestSent = false;
page.on('request', (request) => {
if (request.url().includes('/members/') && request.method() === 'PUT') {
putRequestSent = true;
}
});
// Click Cancel on the confirmation dialog
await page.getByRole('button', { name: 'Cancel' }).click();
// Verify confirmation dialog is closed
await expect(
page.getByRole('heading', { name: 'Update Member Billable Rate' })
).not.toBeVisible();
// Verify no API call was made
expect(putRequestSent).toBe(false);
});
test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => {
const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000);
// Create a placeholder member via import
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
// Go to members page and verify placeholder exists with role "Placeholder"
await goToMembersPage(page);
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
await expect(memberRow).toBeVisible();
await expect(memberRow.getByText('Placeholder', { exact: true })).toBeVisible();
// Open the edit modal for the placeholder member
await memberRow.getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Change role to Employee
const roleSelect = page.getByRole('dialog').getByRole('combobox').first();
await roleSelect.click();
await expect(page.getByRole('option', { name: 'Employee' })).toBeVisible();
await page.getByRole('option', { name: 'Employee' }).click();
await expect(roleSelect).toContainText('Employee');
// Submit the change - the API should reject it with 400
await Promise.all([
page.getByRole('button', { name: 'Update Member' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/members/') &&
response.request().method() === 'PUT' &&
response.status() === 400
),
]);
// Verify error notification is shown
await expect(page.getByText('Failed to update member')).toBeVisible();
});
test('test that changing member role updates the role in the member table', async ({
page,
browser,
}) => {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `member+${memberId}@rolechange.test`;
// Invite and accept a new Employee member
await inviteAndAcceptMember(page, browser, 'Jane Smith', memberEmail, 'Employee');
// Verify the new member appears with the Employee role
await goToMembersPage(page);
const memberRow = page.getByRole('row').filter({ hasText: 'Jane Smith' });
await expect(memberRow).toBeVisible();
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
// Open the edit modal
await memberRow.getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Change role to Manager
const roleSelect = page.getByRole('dialog').getByRole('combobox').first();
await roleSelect.click();
await expect(page.getByRole('option', { name: 'Manager' })).toBeVisible();
await page.getByRole('option', { name: 'Manager' }).click();
await expect(roleSelect).toContainText('Manager');
// Submit the change and verify the API call succeeds
await Promise.all([
page.getByRole('button', { name: 'Update Member' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/members/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
// Verify dialog closed
await expect(page.getByRole('dialog')).not.toBeVisible();
// Verify the role updated in the table
await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible();
});
test('test that merging a placeholder member works', async ({ page, ctx }) => {
const placeholderName = 'Merge Target ' + Math.floor(Math.random() * 10000);
// Create a placeholder member via import
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
// Go to members page
await goToMembersPage(page);
await expect(page.getByText(placeholderName)).toBeVisible();
// Find the placeholder member row and open actions menu
const placeholderRow = page.getByRole('row').filter({ hasText: placeholderName });
await placeholderRow.getByRole('button').click();
// Click Merge
await page.getByTestId('member_merge').click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible();
// Select the current user (the owner) as merge target via MemberCombobox
// The MemberCombobox renders a Button as trigger; clicking it opens the popover with the combobox input
await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click();
// Wait for dropdown options to load
const firstOption = page.getByRole('option').first();
await expect(firstOption).toBeVisible({ timeout: 10000 });
await firstOption.click();
// Submit merge
await Promise.all([
page.getByRole('button', { name: 'Merge Member' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/member/') &&
response.url().includes('/merge-into') &&
response.ok()
),
]);
// Wait for merge dialog to close after successful merge
await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible();
// Verify placeholder member is no longer in the members table
await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible();
});
test('test that deleting a placeholder member works', async ({ page, ctx }) => {
const placeholderName = 'Delete Target ' + Math.floor(Math.random() * 10000);
// Create a placeholder member via import
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
// Go to members page
await goToMembersPage(page);
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
await expect(memberRow).toBeVisible();
// Open actions menu and click Delete
await memberRow.getByRole('button').click();
await page.getByRole('menuitem').getByText('Delete').click();
// Verify delete modal is shown
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible();
// Try to delete without checking the confirmation checkbox
await page.getByRole('button', { name: 'Delete Member' }).click();
// Should show validation error
await expect(
page.getByText('You must confirm that you understand the consequences of this action')
).toBeVisible();
// Check the confirmation checkbox
await page.getByRole('checkbox').click();
// Click Delete Member button and wait for API response
await Promise.all([
page.getByRole('button', { name: 'Delete Member' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/members/') &&
response.request().method() === 'DELETE' &&
response.ok()
),
]);
// Verify modal is closed
await expect(page.getByRole('dialog')).not.toBeVisible();
// Verify member is removed from the table
await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible();
});
test('test that member delete modal can be cancelled', async ({ page, ctx }) => {
const placeholderName = 'Delete Cancel ' + Math.floor(Math.random() * 10000);
// Create a placeholder member via import
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
// Go to members page
await goToMembersPage(page);
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
await expect(memberRow).toBeVisible();
// Open actions menu and click Delete
await memberRow.getByRole('button').click();
await page.getByRole('menuitem').getByText('Delete').click();
// Verify delete modal is shown
await expect(page.getByRole('dialog')).toBeVisible();
// Set up listener to verify no DELETE request is sent
let deleteRequestSent = false;
page.on('request', (request) => {
if (request.url().includes('/members/') && request.method() === 'DELETE') {
deleteRequestSent = true;
}
});
// Click Cancel
await page.getByRole('button', { name: 'Cancel' }).click();
// Verify modal is closed
await expect(page.getByRole('dialog')).not.toBeVisible();
// Verify member is still in the table
await expect(memberRow).toBeVisible();
// Verify no DELETE request was sent
expect(deleteRequestSent).toBe(false);
});
test('test that organization owner cannot be deleted', async ({ page }) => {
await goToMembersPage(page);
// Find the owner row (John Doe with Owner role)
const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' });
await expect(ownerRow).toBeVisible();
// Open the actions menu for the owner
await ownerRow.getByRole('button').click();
// Click Delete
await page.getByRole('menuitem').getByText('Delete').click();
// Verify delete modal is shown
await expect(page.getByRole('dialog')).toBeVisible();
// Check the confirmation checkbox
await page.getByRole('checkbox').click();
// Try to delete - should fail with 400 error
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/members/') && response.request().method() === 'DELETE'
);
await page.getByRole('button', { name: 'Delete Member' }).click();
const response = await responsePromise;
// Verify the API returned an error status
expect(response.status()).toBe(400);
// Close the modal by pressing Escape
await page.keyboard.press('Escape');
// Refresh and verify the owner is still there
await goToMembersPage(page);
await expect(page.getByRole('row').filter({ hasText: 'Owner' })).toBeVisible();
});
// =============================================
// Context Menu Tests
// =============================================
test('test that member context menu edit updates the member billable rate', async ({
page,
ctx,
}) => {
const memberName = 'CtxEditMember ' + Math.floor(1 + Math.random() * 10000);
await createPlaceholderMemberViaImportApi(ctx, memberName);
await goToMembersPage(page);
const row = page.getByRole('row').filter({ hasText: memberName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Change billable rate from default to custom
const billableRateSelect = page.getByRole('dialog').getByRole('combobox').last();
await billableRateSelect.click();
await page.getByRole('option', { name: 'Custom Rate' }).click();
// Set a custom billable rate
await page.getByPlaceholder('Billable Rate').fill('150');
// Click Update Member — confirmation dialog should appear
await page.getByRole('button', { name: 'Update Member' }).click();
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
// Confirm the billable rate change
await Promise.all([
page.getByRole('button', { name: 'Yes, update existing time entries' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/members/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
// Verify dialog closed
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('test that member context menu merge merges the member', async ({ page, ctx }) => {
const memberName = 'CtxMergeMember ' + Math.floor(1 + Math.random() * 10000);
await createPlaceholderMemberViaImportApi(ctx, memberName);
await goToMembersPage(page);
const row = page.getByRole('row').filter({ hasText: memberName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Merge' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible();
// Select the first available member as merge target
await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click();
const firstOption = page.getByRole('option').first();
await expect(firstOption).toBeVisible({ timeout: 10000 });
await firstOption.click();
// Submit merge
await Promise.all([
page.getByRole('button', { name: 'Merge Member' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/member/') &&
response.url().includes('/merge-into') &&
response.ok()
),
]);
// Verify placeholder member is no longer visible
await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible();
await expect(page.getByRole('main').getByText(memberName)).not.toBeVisible();
});
test('test that member context menu deactivate deactivates the member', async ({
page,
browser,
}) => {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `member+${memberId}@deactivate.test`;
const memberName = 'Deactivate Target';
// Invite and accept a new Employee member
await inviteAndAcceptMember(page, browser, memberName, memberEmail, 'Employee');
await goToMembersPage(page);
const row = page.getByRole('row').filter({ hasText: memberName }).first();
await expect(row).toBeVisible();
// Open context menu and click Deactivate
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Deactivate' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Deactivate User' })).toBeVisible();
// Confirm deactivation
await Promise.all([
page.getByRole('button', { name: 'Deactivate' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/make-placeholder') &&
response.request().method() === 'POST' &&
response.ok()
),
]);
// Verify dialog closed and member role changed to Placeholder
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(row.getByText('Placeholder', { exact: true })).toBeVisible();
});
test('test that member context menu delete deletes the member', async ({ page, ctx }) => {
const memberName = 'CtxDeleteMember ' + Math.floor(1 + Math.random() * 10000);
await createPlaceholderMemberViaImportApi(ctx, memberName);
await goToMembersPage(page);
const row = page.getByRole('row').filter({ hasText: memberName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible();
// Check the confirmation checkbox
await page.getByRole('checkbox').click();
// Click Delete Member button and wait for API response
await Promise.all([
page.getByRole('button', { name: 'Delete Member' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/members/') &&
response.request().method() === 'DELETE' &&
response.ok()
),
]);
// Verify modal closed and member removed from table
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByRole('main').getByText(memberName)).not.toBeVisible();
});
// =============================================
// Invitations Tab Tests
// =============================================
test('test that invitation shows in invitations tab and can be revoked', async ({ page }) => {
const inviteEmail = `invite+${Math.floor(Math.random() * 100000)}@pending.test`;
await goToMembersPage(page);
await openInviteMemberModal(page);
await page.getByPlaceholder('Member Email').fill(inviteEmail);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
// Wait for modal to close
await expect(page.getByPlaceholder('Member Email')).not.toBeVisible();
// Switch to Invitations tab and verify the invitation is visible
await page.getByText('Invitations', { exact: true }).click();
await expect(page.getByText(inviteEmail)).toBeVisible();
// Find and click the actions menu for this invitation
const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail });
await invitationRow.getByRole('button').click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/invitations/') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
page.getByRole('menuitem').getByText('Delete').click(),
]);
// Verify invitation is removed
await expect(page.getByText(inviteEmail)).not.toBeVisible();
});
test('test that invitation can be resent', async ({ page }) => {
const inviteEmail = `resend+${Math.floor(Math.random() * 100000)}@invite.test`;
await goToMembersPage(page);
await openInviteMemberModal(page);
await page.getByPlaceholder('Member Email').fill(inviteEmail);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
// Wait for modal to close
await expect(page.getByPlaceholder('Member Email')).not.toBeVisible();
// Switch to Invitations tab
await page.getByText('Invitations', { exact: true }).click();
await expect(page.getByText(inviteEmail)).toBeVisible();
// Find and click the actions menu, then resend
const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail });
await invitationRow.getByRole('button').click();
// Wait for dropdown menu to appear
await expect(page.getByRole('menuitem').getByText('Resend Invitation')).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/resend') && response.request().method() === 'POST'
),
page.getByRole('menuitem').getByText('Resend Invitation').click(),
]);
});
test('test that admin user cannot transfer ownership', async ({ page, browser }) => {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `admin+${memberId}@perms.test`;
// Invite and accept an admin member
await inviteAndAcceptMember(
page,
browser,
'Admin User ' + memberId,
memberEmail,
'Administrator'
);
// Go to members page and verify the admin exists
await goToMembersPage(page);
const adminRow = page.getByRole('row').filter({ hasText: 'Admin User' });
await expect(adminRow).toBeVisible();
// The owner should still be the owner
const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' });
await expect(ownerRow).toBeVisible();
// Open actions menu for the admin - should NOT have "Transfer Ownership" option
await adminRow.getByRole('button').click();
await expect(page.getByRole('menuitem').getByText('Edit')).toBeVisible();
});
test('test that accepted invitation disappears from invitations tab', async ({ page, browser }) => {
const memberId = Math.round(Math.random() * 100000);
const memberEmail = `accepted+${memberId}@invite.test`;
// Invite and accept the member
await inviteAndAcceptMember(page, browser, 'Accepted Member', memberEmail, 'Employee');
// Go to members page and switch to Invitations tab
await goToMembersPage(page);
await page.getByRole('tab', { name: 'Invitations' }).click();
// The accepted invitation should not be visible
await expect(page.getByText(memberEmail)).not.toBeVisible();
});
// =============================================
// Sorting Tests
// =============================================
// Helper to clear localStorage before tests that check sorting
async function clearMemberTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('member-table-state');
});
}
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
// Create two placeholder members with names that sort predictably around "John Doe"
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
await goToMembersPage(page);
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
await expect(table).toBeVisible();
// -- Name sorting (default is already name asc after clearing state) --
const nameHeader = table.getByText('Name').first();
let names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
await nameHeader.click(); // toggle to desc
names = await getTableRowNames(table);
expect(names.indexOf('ZZZ SortLast')).toBeLessThan(names.indexOf('AAA SortFirst'));
// -- Role sorting --
const roleHeader = table.getByText('Role').first();
await roleHeader.click(); // asc: Owner(0) < Placeholder(4)
names = await getTableRowNames(table);
const ownerIdx = names.indexOf('John Doe');
const placeholderIdx = names.indexOf('AAA SortFirst');
expect(ownerIdx).toBeLessThan(placeholderIdx);
await roleHeader.click(); // desc: Placeholder first
names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
// -- Status sorting --
const statusHeader = table.getByText('Status').first();
await statusHeader.click(); // asc: Active(0) < Inactive(1)
names = await getTableRowNames(table);
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
await statusHeader.click(); // desc: Inactive first
names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
// -- Email: just verify sort indicator appears --
const emailHeader = table.getByText('Email').first();
await emailHeader.click();
await expect(emailHeader.locator('svg')).toBeVisible();
});
test('test that member sort state persists after page reload', async ({ page }) => {
await goToMembersPage(page);
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
await expect(table).toBeVisible();
// Click Role header twice to set descending sort
const roleHeader = table.getByText('Role').first();
await roleHeader.click();
await expect(roleHeader.locator('svg')).toBeVisible();
await roleHeader.click();
await expect(roleHeader.locator('svg')).toBeVisible();
// Reload the page
await page.reload();
// Verify the sort indicator is still visible on Role column
await expect(page.getByTestId('member_table')).toBeVisible();
await expect(
page.getByTestId('member_table').getByText('Role').first().locator('svg')
).toBeVisible();
});
test('test that sorting members by billable rate works', async ({ page, ctx }) => {
// Create two placeholder members and set different billable rates
await createPlaceholderMemberViaImportApi(ctx, 'HighRate Member');
await createPlaceholderMemberViaImportApi(ctx, 'LowRate Member');
const members = await getMembersViaApi(ctx);
const highRateMember = members.find((m) => m.name === 'HighRate Member');
const lowRateMember = members.find((m) => m.name === 'LowRate Member');
expect(highRateMember).toBeDefined();
expect(lowRateMember).toBeDefined();
await updateMemberBillableRateViaApi(ctx, highRateMember!.id, 20000);
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
await goToMembersPage(page);
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
await expect(table).toBeVisible();
// First click = desc (highest first), null rates last
const billableHeader = table.getByText('Billable Rate').first();
await billableHeader.click();
await expect(billableHeader.locator('svg')).toBeVisible();
let names = await getTableRowNames(table);
expect(names.indexOf('HighRate Member')).toBeLessThan(names.indexOf('LowRate Member'));
// Second click = asc (lowest first), null rates still last
await billableHeader.click();
names = await getTableRowNames(table);
expect(names.indexOf('LowRate Member')).toBeLessThan(names.indexOf('HighRate Member'));
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Sidebar Navigation', () => {
test('employee sidebar shows correct navigation links', async ({ employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
timeout: 10000,
});
// Visible links
await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Tags' })).toBeVisible();
// Hidden links
await expect(employee.page.getByRole('link', { name: 'Members' })).not.toBeVisible();
await expect(
employee.page.getByRole('link', { name: 'Settings', exact: true })
).not.toBeVisible();
});
test('employee cannot see members list or invite members', async ({ employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/members');
// Page loads but the members API returns 403 (no members:view permission)
await expect(employee.page.getByRole('heading', { name: 'Members' })).toBeVisible({
timeout: 10000,
});
// Member table is empty — no rows rendered (only headers)
await expect(employee.page.getByTestId('member_table').locator('[role="row"]')).toHaveCount(
0
);
// Employee should NOT see the Invite Member button
await expect(
employee.page.getByRole('button', { name: 'Invite member' })
).not.toBeVisible();
});
});

View File

@@ -223,9 +223,211 @@ test('test that format settings are reflected in the dashboard', async ({ page }
// check that the current date is displayed in the dd/mm/yyyy format on the time page
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
// Wait for time entries to load so organization data is available for date formatting
await page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 200
);
await expect(
page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)
).toBeVisible();
).toBeVisible({ timeout: 10000 });
});
// TODO: Test 12-hour clock format
test('test that organization time entry settings can be toggled', async ({ page }) => {
await goToOrganizationSettings(page);
const preventOverlappingCheckbox = page.getByLabel(
'Prevent overlapping time entries (new entries only)'
);
const manageTasksCheckbox = page.getByLabel('Allow Employees to manage tasks');
// Get current states and toggle both
const wasOverlappingChecked = await preventOverlappingCheckbox.isChecked();
const wasManageTasksChecked = await manageTasksCheckbox.isChecked();
if (wasOverlappingChecked) {
await preventOverlappingCheckbox.uncheck();
} else {
await preventOverlappingCheckbox.check();
}
if (wasManageTasksChecked) {
await manageTasksCheckbox.uncheck();
} else {
await manageTasksCheckbox.check();
}
// Save
const settingsForm = page.locator('form').filter({ hasText: 'Prevent overlapping' });
await Promise.all([
settingsForm.getByRole('button', { name: 'Save' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.prevent_overlapping_time_entries ===
!wasOverlappingChecked
),
]);
// Reload and verify both settings persisted
await page.reload();
await expect(preventOverlappingCheckbox).toBeChecked({ checked: !wasOverlappingChecked });
await expect(manageTasksCheckbox).toBeChecked({ checked: !wasManageTasksChecked });
// Toggle both back to restore original state
if (!wasOverlappingChecked) {
await preventOverlappingCheckbox.uncheck();
} else {
await preventOverlappingCheckbox.check();
}
if (!wasManageTasksChecked) {
await manageTasksCheckbox.uncheck();
} else {
await manageTasksCheckbox.check();
}
await Promise.all([
settingsForm.getByRole('button', { name: 'Save' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.prevent_overlapping_time_entries ===
wasOverlappingChecked
),
]);
});
test('test that 12-hour clock format can be set', async ({ page }) => {
await goToOrganizationSettings(page);
await page.getByLabel('Time Format').click();
await page.getByRole('option', { name: '12-hour clock' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Time Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.time_format === '12-hours'
),
]);
// Reload and verify it persisted
await page.reload();
await expect(page.getByLabel('Time Format')).toContainText('12-hour clock');
// Reset back to 24-hour
await page.getByLabel('Time Format').click();
await page.getByRole('option', { name: '24-hour clock' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Time Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.time_format === '24-hours'
),
]);
});
test('test that format settings persist after page reload', async ({ page }) => {
await goToOrganizationSettings(page);
// Set a specific date format
await page.getByLabel('Date Format').click();
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
await Promise.all([
page
.locator('form')
.filter({ hasText: 'Date Format' })
.getByRole('button', { name: 'Save' })
.click(),
page.waitForResponse(
async (response) =>
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
// Reload and verify it persisted
await page.reload();
await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY');
});
// =============================================
// Admin Permission Tests
// =============================================
test.describe('Admin Organization Settings Access', () => {
test('admin can see and edit organization settings', async ({ ctx, admin }) => {
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
// Organization Name section is visible
await expect(
admin.page.getByRole('heading', { name: 'Organization Name', level: 3 })
).toBeVisible({ timeout: 10000 });
// Editable settings sections should be visible
await expect(
admin.page.getByRole('heading', { name: 'Billable Rate', level: 3 })
).toBeVisible();
await expect(
admin.page.getByRole('heading', { name: 'Format Settings', level: 3 })
).toBeVisible();
await expect(
admin.page.getByRole('heading', { name: 'Organization Settings', level: 3 })
).toBeVisible();
// Save buttons should be visible (admin can update)
await expect(admin.page.getByRole('button', { name: 'Save' }).first()).toBeVisible();
// Delete organization should NOT be visible (owner only)
await expect(
admin.page.getByRole('heading', { name: 'Delete Organization' })
).not.toBeVisible();
});
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Organization Settings Restrictions', () => {
test('employee can see org name but not editable settings', async ({ ctx, employee }) => {
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
// Organization Name section is visible (but inputs are disabled)
await expect(
employee.page.getByRole('heading', { name: 'Organization Name', level: 3 })
).toBeVisible({ timeout: 10000 });
// Editable settings sections should NOT be visible
await expect(
employee.page.getByRole('heading', { name: 'Billable Rate', level: 3 })
).not.toBeVisible();
await expect(
employee.page.getByRole('heading', { name: 'Format Settings', level: 3 })
).not.toBeVisible();
await expect(
employee.page.getByRole('heading', { name: 'Organization Settings', level: 3 })
).not.toBeVisible();
// Save button should not be visible (employee cannot update)
await expect(employee.page.getByRole('button', { name: 'Save' })).not.toBeVisible();
});
});

View File

@@ -1,5 +1,10 @@
import { test, expect } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
import type { Page } from '@playwright/test';
async function goToProfilePage(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
}
test('test that user name can be updated', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
@@ -39,6 +44,28 @@ test('test that user can create an API key', async ({ page }) => {
await createNewApiToken(page);
});
test('test that creating an API key with empty name shows validation error', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
// Wait for the API Key Name input to be visible before interacting
const nameInput = page.getByLabel('API Key Name');
await expect(nameInput).toBeVisible();
// Ensure the API Key Name input is empty
await nameInput.fill('');
// Click the create button and wait for the 422 response
const [response] = await Promise.all([
page.waitForResponse('**/users/me/api-tokens'),
page.getByRole('button', { name: 'Create API Key' }).click(),
]);
expect(response.status()).toBe(422);
// Verify that an error notification is shown with validation message about the name field
await expect(page.getByText('name field is required')).toBeVisible({ timeout: 5000 });
});
test('test that user can delete an API key', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
@@ -68,3 +95,285 @@ test('test that user can revoke an API key', async ({ page }) => {
await expect(page.locator('body')).toContainText('NEW API KEY');
await expect(page.locator('body')).toContainText('Revoked');
});
// =============================================
// Update Password Form Tests
// =============================================
test('test that password mismatch shows error', async ({ page }) => {
await goToProfilePage(page);
// Fill in with mismatched passwords
await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);
await page.getByLabel('New Password').fill('newSecurePassword456');
await page.getByLabel('Confirm Password').fill('differentPassword789');
// Find the form containing the Confirm Password field and click its Save button
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/user/password') && response.request().method() === 'PUT'
),
passwordForm.getByRole('button', { name: 'Save' }).click(),
]);
// Verify error message about password confirmation
await expect(page.getByText('confirmation does not match')).toBeVisible();
});
test('test that short password shows validation error', async ({ page }) => {
await goToProfilePage(page);
// Fill in with a too short password
await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);
await page.getByLabel('New Password').fill('short');
await page.getByLabel('Confirm Password').fill('short');
// Find the form containing the Confirm Password field and click its Save button
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/user/password') && response.request().method() === 'PUT'
),
passwordForm.getByRole('button', { name: 'Save' }).click(),
]);
// Verify error message about password length
await expect(page.getByText('must be at least')).toBeVisible();
});
test('test that incorrect current password shows validation error', async ({ page }) => {
await goToProfilePage(page);
// Fill in with wrong current password
await page.getByLabel('Current Password').fill('wrongCurrentPassword123');
await page.getByLabel('New Password').fill('newSecurePassword456');
await page.getByLabel('Confirm Password').fill('newSecurePassword456');
// Find the form containing the Confirm Password field and click its Save button
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/user/password') && response.request().method() === 'PUT'
),
passwordForm.getByRole('button', { name: 'Save' }).click(),
]);
// Verify error message about incorrect password
await expect(page.getByText('does not match')).toBeVisible();
});
test('test that password can be updated successfully', async ({ page }) => {
await goToProfilePage(page);
const newPassword = 'newSecurePassword456';
// Change password to new password
await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);
await page.getByLabel('New Password').fill(newPassword);
await page.getByLabel('Confirm Password').fill(newPassword);
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/user/password') && response.request().method() === 'PUT'
);
await passwordForm.getByRole('button', { name: 'Save' }).click();
const response = await responsePromise;
// Verify successful response (303 is Inertia redirect on success, means password was updated)
expect(response.status()).toBe(303);
// Verify no error messages are displayed
await expect(page.getByText('does not match')).not.toBeVisible();
await expect(page.getByText('must be at least')).not.toBeVisible();
});
// =============================================
// Theme Selection Tests
// =============================================
test('test that theme can be changed to dark and light', async ({ page }) => {
await goToProfilePage(page);
// The theme select is a Reka UI combobox (button), not a native <select>
const themeSelect = page.locator('button[role="combobox"]');
// Change theme to dark
await themeSelect.click();
await page.getByRole('option', { name: 'Dark' }).click();
// Verify the html element has 'dark' class
await expect(page.locator('html')).toHaveClass(/dark/);
// Change theme to light
await themeSelect.click();
await page.getByRole('option', { name: 'Light' }).click();
// Verify the html element has 'light' class and no 'dark' class
await expect(page.locator('html')).toHaveClass(/light/);
await expect(page.locator('html')).not.toHaveClass(/dark/);
// Verify localStorage persists the setting
const storedTheme = await page.evaluate(() => localStorage.getItem('theme'));
expect(storedTheme).toContain('light');
// Reload and verify the theme persists
await page.reload();
await expect(page.locator('html')).toHaveClass(/light/);
// Reset to system
await page.locator('button[role="combobox"]').click();
await page.getByRole('option', { name: 'System' }).click();
await expect(page.getByText('System default:')).toBeVisible();
});
// =============================================
// Group similar time entries
// =============================================
test('test that group similar time entries setting can be toggled', async ({ page }) => {
await goToProfilePage(page);
// Get the checkbox
const checkbox = page.getByLabel('Group similar time entries');
// Get initial value and verify it is checked (default is true)
const initialValue = await checkbox.isChecked();
await expect(checkbox).toBeChecked();
// Toggle the checkbox
await checkbox.click();
// Reload
await page.reload();
// Verify the value is toggled
const afterValue = await page.getByLabel('Group similar time entries').isChecked();
expect(afterValue).toBe(!initialValue);
// Verify localStorage persists the setting
const storedValue = await page.evaluate(() =>
localStorage.getItem('group-similar-time-entries')
);
expect(storedValue).toBe(String(!initialValue));
});
// =============================================
// Two Factor Authentication Tests
// =============================================
test('test that password confirmation modal can be cancelled without sending API request', async ({
page,
}) => {
await goToProfilePage(page);
// Find the Enable button in the 2FA section
const enableButton = page
.getByText('You have not enabled two factor authentication.')
.locator('..')
.getByRole('button', { name: 'Enable' });
await enableButton.click();
// Verify password confirmation modal appears
await expect(page.getByRole('dialog')).toBeVisible();
// Set up listener to verify no POST request is sent to confirm-password
let confirmPasswordRequestSent = false;
page.on('request', (request) => {
if (request.url().includes('/user/confirm-password') && request.method() === 'POST') {
confirmPasswordRequestSent = true;
}
});
// Click Cancel
await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
// Verify modal is closed
await expect(page.getByRole('dialog')).not.toBeVisible();
// Verify no confirm-password request was sent
expect(confirmPasswordRequestSent).toBe(false);
});
test('test that password confirmation modal shows error for incorrect password', async ({
page,
}) => {
await goToProfilePage(page);
// Find the Enable button in the 2FA section
const enableButton = page
.getByText('You have not enabled two factor authentication.')
.locator('..')
.getByRole('button', { name: 'Enable' });
await enableButton.click();
// Verify password confirmation modal appears
await expect(page.getByRole('dialog')).toBeVisible();
// Enter incorrect password and confirm
await page.getByPlaceholder('Password').fill('wrongpassword123');
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
// Should show error message (wait longer for API response)
await expect(page.getByRole('dialog').getByText('incorrect')).toBeVisible({ timeout: 10000 });
});
test('test that 2FA can be enabled with correct password', async ({ page }) => {
await goToProfilePage(page);
// Verify 2FA is not enabled
await expect(page.getByText('You have not enabled two factor authentication.')).toBeVisible();
// Find the Enable button in the 2FA section
const enableButton = page
.getByText('You have not enabled two factor authentication.')
.locator('..')
.getByRole('button', { name: 'Enable' });
await enableButton.click();
// Verify password confirmation modal appears
await expect(page.getByRole('dialog')).toBeVisible();
// Enter correct password and confirm
await page.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await Promise.all([
page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/user/two-factor-authentication') &&
response.request().method() === 'POST'
),
]);
// Verify QR code is shown
await expect(page.getByRole('heading', { name: 'Finish enabling two factor' })).toBeVisible();
await expect(page.getByText('Setup Key:')).toBeVisible();
await expect(page.getByLabel('Code')).toBeVisible();
});
// =============================================
// Logout Other Browser Sessions Tests
// =============================================
test('test that logout other browser sessions works with correct password', async ({ page }) => {
await goToProfilePage(page);
await page.getByRole('button', { name: 'Log Out Other Browser Sessions' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
await Promise.all([
page
.getByRole('dialog')
.getByRole('button', { name: 'Log Out Other Browser Sessions' })
.click(),
page.waitForResponse(
(response) =>
response.url().includes('/user/other-browser-sessions') &&
response.request().method() === 'DELETE'
),
]);
});

View File

@@ -1,33 +1,27 @@
import { expect, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { formatCentsWithOrganizationDefaults } from './utils/money';
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
import { NumberFormat } from '@/packages/ui/src/utils/number';
import { createProjectViaApi, createProjectMemberViaApi, type TestContext } from './utils/api';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
async function createProjectWithMemberViaApi(ctx: TestContext, page: Page, projectName: string) {
const project = await createProjectViaApi(ctx, { name: projectName });
await createProjectMemberViaApi(ctx, project.id, { member_id: ctx.memberId });
// Navigate to the project detail page
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
await expect(page.getByTestId('project_member_table').getByRole('row').first()).toBeVisible();
return project;
}
test('test that updating project member billable rate works for existing time entries', async ({
page,
ctx,
}) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible();
await page.getByText(newProjectName).click();
await page.getByRole('button', { name: 'Add Member' }).click();
await expect(page.getByText('Add Project Member').first()).toBeVisible();
await page.getByRole('button', { name: 'Select a member' }).click();
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Add Project Member' }).click();
await createProjectWithMemberViaApi(ctx, page, newProjectName);
await page
.getByTestId('project_member_table')
@@ -62,3 +56,197 @@ test('test that updating project member billable rate works for existing time en
.getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100))
).toBeVisible();
});
test('test that project member edit modal can be cancelled without sending API request', async ({
page,
ctx,
}) => {
const projectName = 'Cancel Test ' + Math.floor(1 + Math.random() * 10000);
await createProjectWithMemberViaApi(ctx, page, projectName);
// Open the edit modal
await page
.getByTestId('project_member_table')
.getByRole('row')
.first()
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
// Verify the modal is open and shows the member name
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).toBeVisible();
await expect(page.getByRole('dialog').getByText('John Doe')).toBeVisible();
// Enter a new billable rate
await page.getByLabel('Billable Rate').fill('999');
// Set up listener to verify no PUT request is sent
let putRequestSent = false;
page.on('request', (request) => {
if (request.url().includes('/project-members/') && request.method() === 'PUT') {
putRequestSent = true;
}
});
// Click Cancel
await page.getByRole('button', { name: 'Cancel' }).click();
// Verify the modal is closed
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).not.toBeVisible();
// Verify no PUT request was sent
expect(putRequestSent).toBe(false);
});
test('test that project member update without billable rate change skips confirmation and completes', async ({
page,
ctx,
}) => {
const projectName = 'No Change ' + Math.floor(1 + Math.random() * 10000);
await createProjectWithMemberViaApi(ctx, page, projectName);
// Open the edit modal
await page
.getByTestId('project_member_table')
.getByRole('row')
.first()
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
// Click Update without changing anything - no confirmation modal since rate didn't change
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/project-members/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Project Member' }).click(),
]);
// Verify the edit modal is closed (confirmation modal was skipped)
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).not.toBeVisible();
});
test('test that billable rate confirmation modal can be cancelled without sending API request', async ({
page,
ctx,
}) => {
const projectName = 'Rate Cancel ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000);
await createProjectWithMemberViaApi(ctx, page, projectName);
// Open the edit modal
await page
.getByTestId('project_member_table')
.getByRole('row')
.first()
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
// Change the billable rate
await page.getByLabel('Billable Rate').fill(newBillableRate.toString());
// Set up listener to verify no PUT request is sent
let putRequestSent = false;
page.on('request', (request) => {
if (request.url().includes('/project-members/') && request.method() === 'PUT') {
putRequestSent = true;
}
});
// Click Update - this should show the confirmation modal
await page.getByRole('button', { name: 'Update Project Member' }).click();
// Verify the confirmation modal is shown
await expect(page.getByText('update all existing time entries')).toBeVisible();
// Click Cancel to close the confirmation modal without updating
await page.getByRole('button', { name: 'Cancel' }).click();
// Verify the confirmation modal is closed but edit modal is still open
await expect(page.getByText('update all existing time entries')).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).toBeVisible();
// Close the edit modal
await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
// Verify the edit modal is closed
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).not.toBeVisible();
// Verify no PUT request was sent
expect(putRequestSent).toBe(false);
});
test('test that clearing billable rate reverts to project default', async ({ page, ctx }) => {
const projectName = 'Revert Default ' + Math.floor(1 + Math.random() * 10000);
const customRate = Math.round(100 + Math.random() * 10000);
await createProjectWithMemberViaApi(ctx, page, projectName);
// Verify the billable rate shows "--" (project default) initially
await expect(
page.getByTestId('project_member_table').getByRole('row').first().getByText('--')
).toBeVisible();
// Set a custom billable rate
await page
.getByTestId('project_member_table')
.getByRole('row')
.first()
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
await page.getByLabel('Billable Rate').fill(customRate.toString());
await page.getByRole('button', { name: 'Update Project Member' }).click();
// Confirm the billable rate update
await Promise.all([
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/project-members/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
// Verify the custom rate is shown in the table (not "--")
await expect(
page.getByTestId('project_member_table').getByRole('row').first().getByText('--')
).not.toBeVisible();
// Now clear the billable rate to revert to project default
await page
.getByTestId('project_member_table')
.getByRole('row')
.first()
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
// Set billable rate to 0 to revert to project default
await page.getByLabel('Billable Rate').fill('0');
await page.getByRole('button', { name: 'Update Project Member' }).click();
// Confirm the billable rate update
await Promise.all([
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/project-members/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
// Verify the billable rate shows "--" again (project default)
await expect(
page.getByTestId('project_member_table').getByRole('row').first().getByText('--')
).toBeVisible();
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,719 @@
import { expect } from '@playwright/test';
import { test } from '../playwright/fixtures';
import { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting';
import {
createProjectViaApi,
createClientViaApi,
createTaskViaApi,
createTimeEntryViaApi,
createTimeEntryWithTagViaApi,
createBareTimeEntryViaApi,
} from './utils/api';
// Each test registers a new user and creates test data via API
test.describe.configure({ timeout: 30000 });
// ──────────────────────────────────────────────────
// Basic Detailed View Tests
// ──────────────────────────────────────────────────
test('test that detailed view shows time entries correctly', async ({ page, ctx }) => {
const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
// Go to detailed reporting view
await goToReportingDetailed(page);
// Verify the time entry is shown with all details
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1:00:00');
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
});
test('test that updating duration in detailed view works correctly', async ({ page, ctx }) => {
const projectName = 'Duration Update Project ' + Math.floor(Math.random() * 10000);
const initialDuration = '1h';
const updatedDuration = '2h 30min';
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: initialDuration,
projectId: project.id,
});
// Go to detailed reporting view
await goToReportingDetailed(page);
// Find and update the duration
const durationInput = page.locator('input[name="Duration"]').first();
await durationInput.click();
await durationInput.fill(updatedDuration);
await Promise.all([
durationInput.press('Enter'),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 200
),
]);
// Verify the new duration is displayed (reporting views promote to HH:MM:SS format)
await expect(durationInput).toHaveValue('2:30:00');
});
// ──────────────────────────────────────────────────
// Project Filter Tests
// ──────────────────────────────────────────────────
test('test that project multiselect filters work on detailed reporting page', async ({
page,
ctx,
}) => {
const project1 = 'DetailProj1 ' + Math.floor(Math.random() * 10000);
const project2 = 'DetailProj2 ' + Math.floor(Math.random() * 10000);
const p1 = await createProjectViaApi(ctx, { name: project1 });
const p2 = await createProjectViaApi(ctx, { name: project2 });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${project1}`,
duration: '1h',
projectId: p1.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${project2}`,
duration: '2h',
projectId: p2.id,
});
await goToReportingDetailed(page);
// Wait for initial data load
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${project2}`).first()).toBeVisible();
// Open project multiselect and select project1
await page.getByRole('button', { name: 'Projects' }).first().click();
await page.getByRole('option').filter({ hasText: project1 }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Verify only project1 entry is shown
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${project2}`).first()).not.toBeVisible();
});
// ──────────────────────────────────────────────────
// Client Filter Tests
// ──────────────────────────────────────────────────
test('test that client multiselect filters work on detailed reporting page', async ({
page,
ctx,
}) => {
const client1 = 'DetailClient1 ' + Math.floor(Math.random() * 10000);
const project1 = 'DetailClientProj1 ' + Math.floor(Math.random() * 10000);
const project2 = 'DetailClientProj2 ' + Math.floor(Math.random() * 10000);
const c1 = await createClientViaApi(ctx, { name: client1 });
const p1 = await createProjectViaApi(ctx, { name: project1, client_id: c1.id });
const p2 = await createProjectViaApi(ctx, { name: project2 });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${project1}`,
duration: '1h',
projectId: p1.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${project2}`,
duration: '2h',
projectId: p2.id,
});
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${project2}`).first()).toBeVisible();
// Filter by client1
await page.getByRole('button', { name: 'Clients' }).first().click();
await page.getByRole('option').filter({ hasText: client1 }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Only entries for project1 (with client1) should be visible
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${project2}`).first()).not.toBeVisible();
});
// ──────────────────────────────────────────────────
// Task Filter Tests
// ──────────────────────────────────────────────────
test('test that task multiselect dropdown filters reporting by task', async ({ page, ctx }) => {
const projectName = 'TaskFilterProj ' + Math.floor(Math.random() * 10000);
const task1 = 'TaskFilter1 ' + Math.floor(Math.random() * 10000);
const task2 = 'TaskFilter2 ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id });
const t2 = await createTaskViaApi(ctx, { name: task2, project_id: project.id });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName} - ${task1}`,
duration: '1h',
projectId: project.id,
taskId: t1.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName} - ${task2}`,
duration: '2h',
projectId: project.id,
taskId: t2.id,
});
// Use the detailed view to verify task filtering (shows individual entries)
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).toBeVisible();
// Open task multiselect dropdown
await page.getByRole('button', { name: 'Tasks' }).first().click();
// Verify both tasks appear
await expect(page.getByRole('option').filter({ hasText: task1 })).toBeVisible();
await expect(page.getByRole('option').filter({ hasText: task2 })).toBeVisible();
// Select task1
await page.getByRole('option').filter({ hasText: task1 }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Verify badge shows count of 1
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();
// Verify only task1 entry is shown
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).not.toBeVisible();
});
test('test that selecting multiple tasks shows correct badge count', async ({ page, ctx }) => {
const projectName = 'MultiTaskProj ' + Math.floor(Math.random() * 10000);
const task1 = 'MultiTask1 ' + Math.floor(Math.random() * 10000);
const task2 = 'MultiTask2 ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id });
const t2 = await createTaskViaApi(ctx, { name: task2, project_id: project.id });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName} - ${task1}`,
duration: '1h',
projectId: project.id,
taskId: t1.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName} - ${task2}`,
duration: '2h',
projectId: project.id,
taskId: t2.id,
});
// Use the detailed view to verify task filtering
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).toBeVisible();
// Select both tasks
await page.getByRole('button', { name: 'Tasks' }).first().click();
await page.getByRole('option').filter({ hasText: task1 }).click();
await page.getByRole('option').filter({ hasText: task2 }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Verify badge shows count of 2
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('2')).toBeVisible();
// Verify both task entries are shown
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).toBeVisible();
});
test('test that deselecting a task removes the filter', async ({ page, ctx }) => {
const projectName = 'TaskDeselectProj ' + Math.floor(Math.random() * 10000);
const task1 = 'TaskDeselect1 ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName} - ${task1}`,
duration: '1h',
projectId: project.id,
taskId: t1.id,
});
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
// Select task
await page.getByRole('button', { name: 'Tasks' }).first().click();
await page.getByRole('option').filter({ hasText: task1 }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();
// Deselect task
await page.getByRole('button', { name: 'Tasks' }).first().click();
await page.getByRole('option').filter({ hasText: task1 }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
await expect(
page.getByRole('button', { name: 'Tasks' }).first().getByText(/^\d+$/)
).not.toBeVisible();
});
// ──────────────────────────────────────────────────
// Member Filter Tests
// ──────────────────────────────────────────────────
test('test that member multiselect filters work on detailed reporting page', async ({
page,
ctx,
}) => {
const projectName = 'DetailMemberProj ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();
// Filter by the current member
await page.getByRole('button', { name: 'Members' }).first().click();
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Data should still be visible since all entries belong to this member
await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();
// Verify badge shows count of 1
await expect(
page.getByRole('button', { name: 'Members' }).first().getByText('1')
).toBeVisible();
});
// ──────────────────────────────────────────────────
// Tag Filter Tests
// ──────────────────────────────────────────────────
test('test that tag filter works on detailed reporting page', async ({ page, ctx }) => {
const tag1 = 'DetailTag1 ' + Math.floor(Math.random() * 10000);
const tag2 = 'DetailTag2 ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tag1, '1h');
await createTimeEntryWithTagViaApi(ctx, tag2, '2h');
await goToReportingDetailed(page);
await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tag2}`).first()).toBeVisible();
// Filter by tag1
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByRole('option').filter({ hasText: tag1 }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tag2}`).first()).not.toBeVisible();
});
// ──────────────────────────────────────────────────
// Billable Filter Tests
// ──────────────────────────────────────────────────
test('test that billable filter works on detailed reporting page', async ({ page, ctx }) => {
const projectName = 'DetailBillProj ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();
// Filter by billable only
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
await Promise.all([
page.getByRole('option', { name: 'Billable', exact: true }).click(),
waitForDetailedReportingUpdate(page),
]);
// Switch to Non Billable
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
await Promise.all([
page.getByRole('option', { name: 'Non Billable', exact: true }).click(),
waitForDetailedReportingUpdate(page),
]);
// Switch back to Both
await page.getByRole('combobox').filter({ hasText: 'Non Billable' }).click();
await Promise.all([
page.getByRole('option', { name: 'Both' }).click(),
waitForDetailedReportingUpdate(page),
]);
});
// ──────────────────────────────────────────────────
// Combined Filter Tests
// ──────────────────────────────────────────────────
test('test that combining project and task filters narrows results', async ({ page, ctx }) => {
const projectName = 'CombinedProj ' + Math.floor(Math.random() * 10000);
const otherProject = 'OtherCombProj ' + Math.floor(Math.random() * 10000);
const task1 = 'CombinedTask1 ' + Math.floor(Math.random() * 10000);
const p1 = await createProjectViaApi(ctx, { name: projectName });
const p2 = await createProjectViaApi(ctx, { name: otherProject });
const t1 = await createTaskViaApi(ctx, { name: task1, project_id: p1.id });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName} - ${task1}`,
duration: '1h',
projectId: p1.id,
taskId: t1.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${otherProject}`,
duration: '2h',
projectId: p2.id,
});
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${otherProject}`).first()).toBeVisible();
// Filter by project
await page.getByRole('button', { name: 'Projects' }).first().click();
await page.getByRole('option').filter({ hasText: projectName }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Additionally filter by task
await page.getByRole('button', { name: 'Tasks' }).first().click();
await page.getByRole('option').filter({ hasText: task1 }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Verify both badges show count of 1
await expect(
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
).toBeVisible();
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();
// Verify only the combined entry is shown
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${otherProject}`).first()).not.toBeVisible();
});
test('test that combining client and member filters narrows results on detailed page', async ({
page,
ctx,
}) => {
const client1 = 'CombClient ' + Math.floor(Math.random() * 10000);
const project1 = 'CombClientProj ' + Math.floor(Math.random() * 10000);
const project2 = 'CombNoClientProj ' + Math.floor(Math.random() * 10000);
const c1 = await createClientViaApi(ctx, { name: client1 });
const p1 = await createProjectViaApi(ctx, { name: project1, client_id: c1.id });
const p2 = await createProjectViaApi(ctx, { name: project2 });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${project1}`,
duration: '1h',
projectId: p1.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${project2}`,
duration: '2h',
projectId: p2.id,
});
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${project2}`).first()).toBeVisible();
// Filter by client
await page.getByRole('button', { name: 'Clients' }).first().click();
await page.getByRole('option').filter({ hasText: client1 }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Additionally filter by member
await page.getByRole('button', { name: 'Members' }).first().click();
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Only project1 entry should be visible (filtered by client + member)
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${project2}`).first()).not.toBeVisible();
// Both badges should show count of 1
await expect(
page.getByRole('button', { name: 'Clients' }).first().getByText('1')
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Members' }).first().getByText('1')
).toBeVisible();
});
test('test that combining tag and project filters narrows results', async ({ page, ctx }) => {
const tag1 = 'CombTag ' + Math.floor(Math.random() * 10000);
const project1 = 'CombTagProj ' + Math.floor(Math.random() * 10000);
const p1 = await createProjectViaApi(ctx, { name: project1 });
// Create a time entry with a project (no tag)
await createTimeEntryViaApi(ctx, {
description: `Entry for ${project1}`,
duration: '1h',
projectId: p1.id,
});
// Create a time entry with a tag (no specific project)
await createTimeEntryWithTagViaApi(ctx, tag1, '2h');
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible();
// Filter by project
await page.getByRole('button', { name: 'Projects' }).first().click();
await page.getByRole('option').filter({ hasText: project1 }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Only the project entry should be visible (tagged entry has no project)
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tag1}`).first()).not.toBeVisible();
});
// ──────────────────────────────────────────────────
// "No X" Filter Tests
// ──────────────────────────────────────────────────
test('test that "No Project" filter shows entries without a project', async ({ page, ctx }) => {
const project1 = 'NoProj1 ' + Math.floor(Math.random() * 10000);
const p1 = await createProjectViaApi(ctx, { name: project1 });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${project1}`,
duration: '1h',
projectId: p1.id,
});
await createBareTimeEntryViaApi(ctx, 'Bare entry no project', '30min');
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
await expect(page.getByText('Bare entry no project').first()).toBeVisible();
// Open project dropdown and select "No Project"
await page.getByRole('button', { name: 'Projects' }).first().click();
await page.getByRole('option').filter({ hasText: 'No Project' }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Verify badge shows 1
await expect(
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
).toBeVisible();
// Only the bare entry (no project) should be visible
await expect(page.getByText('Bare entry no project').first()).toBeVisible();
await expect(page.getByText(`Entry for ${project1}`).first()).not.toBeVisible();
});
test('test that "No Task" filter shows entries without a task', async ({ page, ctx }) => {
const projectName = 'NoTaskProj ' + Math.floor(Math.random() * 10000);
const task1 = 'NoTaskFilter1 ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName} - ${task1}`,
duration: '1h',
projectId: project.id,
taskId: t1.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '30min',
projectId: project.id,
});
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();
// Open task dropdown and select "No Task"
await page.getByRole('button', { name: 'Tasks' }).first().click();
await page.getByRole('option').filter({ hasText: 'No Task' }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();
// Only the entry without a task should be visible
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).not.toBeVisible();
});
test('test that "No Tag" filter shows entries without tags', async ({ page, ctx }) => {
const tag1 = 'NoTagFilter1 ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tag1, '1h');
await createBareTimeEntryViaApi(ctx, 'Entry without any tag', '30min');
await goToReportingDetailed(page);
await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible();
await expect(page.getByText('Entry without any tag').first()).toBeVisible();
// Open tag dropdown and select "No Tag"
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByRole('option').filter({ hasText: 'No Tag' }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible();
await expect(page.getByText('Entry without any tag').first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tag1}`).first()).not.toBeVisible();
});
test('test that "No Client" filter shows entries without a client', async ({ page, ctx }) => {
const client1 = 'NoClientFilter ' + Math.floor(Math.random() * 10000);
const projectWithClient = 'NoClientProj1 ' + Math.floor(Math.random() * 10000);
const projectNoClient = 'NoClientProj2 ' + Math.floor(Math.random() * 10000);
const c1 = await createClientViaApi(ctx, { name: client1 });
const pWithClient = await createProjectViaApi(ctx, {
name: projectWithClient,
client_id: c1.id,
});
const pNoClient = await createProjectViaApi(ctx, { name: projectNoClient });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectWithClient}`,
duration: '1h',
projectId: pWithClient.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectNoClient}`,
duration: '30min',
projectId: pNoClient.id,
});
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${projectWithClient}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${projectNoClient}`).first()).toBeVisible();
// Open client dropdown and select "No Client"
await page.getByRole('button', { name: 'Clients' }).first().click();
await page.getByRole('option').filter({ hasText: 'No Client' }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
await expect(
page.getByRole('button', { name: 'Clients' }).first().getByText('1')
).toBeVisible();
await expect(page.getByText(`Entry for ${projectNoClient}`).first()).toBeVisible();
await expect(page.getByText(`Entry for ${projectWithClient}`).first()).not.toBeVisible();
});
test('test that combining "No Project" with a project ID shows both', async ({ page, ctx }) => {
const project1 = 'CombNoProj ' + Math.floor(Math.random() * 10000);
const p1 = await createProjectViaApi(ctx, { name: project1 });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${project1}`,
duration: '1h',
projectId: p1.id,
});
await createBareTimeEntryViaApi(ctx, 'Bare combined entry', '30min');
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
await expect(page.getByText('Bare combined entry').first()).toBeVisible();
// Select both "No Project" and the specific project
await page.getByRole('button', { name: 'Projects' }).first().click();
await page.getByRole('option').filter({ hasText: 'No Project' }).click();
await page.getByRole('option').filter({ hasText: project1 }).click();
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Badge should show 2
await expect(
page.getByRole('button', { name: 'Projects' }).first().getByText('2')
).toBeVisible();
// Both entries should be visible
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
await expect(page.getByText('Bare combined entry').first()).toBeVisible();
});
// ──────────────────────────────────────────────────
// Keyboard Navigation Tests
// ──────────────────────────────────────────────────
test('test that keyboard navigation works in multiselect dropdown', async ({ page, ctx }) => {
const project1 = 'KbNavProj1 ' + Math.floor(Math.random() * 10000);
const project2 = 'KbNavProj2 ' + Math.floor(Math.random() * 10000);
const p1 = await createProjectViaApi(ctx, { name: project1 });
const p2 = await createProjectViaApi(ctx, { name: project2 });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${project1}`,
duration: '1h',
projectId: p1.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${project2}`,
duration: '2h',
projectId: p2.id,
});
await goToReportingDetailed(page);
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
// Open project dropdown
await page.getByRole('button', { name: 'Projects' }).first().click();
// The search input should be focused, first item ("No Project") highlighted
await expect(page.getByPlaceholder('Search for a Project...')).toBeFocused();
// Press ArrowDown to move to first project, then Enter to select it
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
// Close dropdown and verify filter applied
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
// Badge should show 1
await expect(
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
).toBeVisible();
});

File diff suppressed because it is too large Load Diff

918
e2e/shared-reports.spec.ts Normal file
View File

@@ -0,0 +1,918 @@
import { expect } from '@playwright/test';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
dayjs.extend(utc);
import {
createProjectViaApi,
createClientViaApi,
createTaskViaApi,
createTimeEntryViaApi,
createTimeEntryWithTagViaApi,
createBareTimeEntryViaApi,
createBillableProjectViaApi,
createTimeEntryWithBillableStatusViaApi,
createTagViaApi,
createReportViaApi,
} from './utils/api';
import {
goToReporting,
goToReportingShared,
waitForReportingUpdate,
saveAsSharedReport,
} from './utils/reporting';
// Each test registers a new user and creates test data via API
test.describe.configure({ timeout: 30000 });
// Date picker button name patterns for different date formats
const DATE_PICKER_BUTTON_PATTERN =
/^Pick a date$|^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
// ──────────────────────────────────────────────────
// Shared Report Lifecycle Tests
// ──────────────────────────────────────────────────
test('test that saving a report creates a shared report and its shareable link shows correct data', async ({
page,
ctx,
}) => {
const projectName = 'SharedProject ' + Math.floor(Math.random() * 10000);
const reportName = 'SharedReport ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
const { shareableLink } = await saveAsSharedReport(page, reportName);
// Verify report appears on shared tab
await goToReportingShared(page);
await expect(page.getByTestId('report_table')).toBeVisible();
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Public', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Copy URL' })).toBeVisible();
// Navigate to shareable link and verify report data
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText(projectName)).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
});
test('test that shared report with invalid secret shows no data', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value');
await expect(page.getByText('No time entries found').first()).toBeVisible();
});
test('test that a shared report can be edited to toggle public/private and then deleted', async ({
page,
ctx,
}) => {
const projectName = 'EditDelProject ' + Math.floor(Math.random() * 10000);
const reportName = 'EditDelReport ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
await saveAsSharedReport(page, reportName);
await goToReportingShared(page);
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Public', { exact: true })).toBeVisible();
// Click more options and edit
await page
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
.click();
await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
// Uncheck public and save
await page.getByLabel('Public').click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reports/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Report' }).click(),
]);
// Verify status changed to private
await expect(page.getByText('Private')).toBeVisible();
await expect(page.getByText('--')).toBeVisible();
// Delete the report
await page
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
.click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reports/') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
page.getByRole('menuitem', { name: /^Delete Report/ }).click(),
]);
await expect(page.getByText('No shared reports found')).toBeVisible();
});
// ──────────────────────────────────────────────────
// Shared Report Filter Tests
// ──────────────────────────────────────────────────
test('test that shared report respects project filter', async ({ page, ctx }) => {
const projectA = 'FilterProjA ' + Math.floor(Math.random() * 10000);
const projectB = 'FilterProjB ' + Math.floor(Math.random() * 10000);
const reportName = 'FilterProjReport ' + Math.floor(Math.random() * 10000);
const projA = await createProjectViaApi(ctx, { name: projectA });
const projB = await createProjectViaApi(ctx, { name: projectB });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectA}`,
duration: '1h',
projectId: projA.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectB}`,
duration: '2h',
projectId: projB.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectA)).toBeVisible();
// Filter by project A
await page.getByRole('button', { name: 'Projects' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: projectA }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText(projectA)).toBeVisible();
await expect(page.getByText(projectB)).not.toBeVisible();
});
test('test that shared report with No Project filter shows entries without a project', async ({
page,
ctx,
}) => {
const projectName = 'NoProjFilter ' + Math.floor(Math.random() * 10000);
const reportName = 'NoProjReport ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await createBareTimeEntryViaApi(ctx, 'Bare entry no project', '2h');
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
// Filter by "No Project"
await page.getByRole('button', { name: 'Projects' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: 'No Project' }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
// The "No Project" group should show, but the project name should not appear as a group
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText(projectName)).not.toBeVisible();
});
test('test that shared report with No Task filter shows entries without a task', async ({
page,
ctx,
}) => {
const projectName = 'NoTaskProj ' + Math.floor(Math.random() * 10000);
const taskName = 'NoTaskFilter ' + Math.floor(Math.random() * 10000);
const reportName = 'NoTaskReport ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
const task = await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName} - ${taskName}`,
duration: '1h',
projectId: project.id,
taskId: task.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '2h',
projectId: project.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
// Filter by "No Task"
await page.getByRole('button', { name: 'Tasks' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: 'No Task' }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
});
test('test that shared report respects task filter', async ({ page, ctx }) => {
const projectName = 'TaskFilterProj ' + Math.floor(Math.random() * 10000);
const taskA = 'TaskA ' + Math.floor(Math.random() * 10000);
const taskB = 'TaskB ' + Math.floor(Math.random() * 10000);
const reportName = 'TaskFilterReport ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
const task = await createTaskViaApi(ctx, { name: taskA, project_id: project.id });
await createTaskViaApi(ctx, { name: taskB, project_id: project.id });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${taskA}`,
duration: '1h',
projectId: project.id,
taskId: task.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName} no task`,
duration: '2h',
projectId: project.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
// Filter by task A
await page.getByRole('button', { name: 'Tasks' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: taskA }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText('1:00:00').first()).toBeVisible();
await expect(page.getByText('3:00:00')).not.toBeVisible();
});
test('test that shared report respects client filter', async ({ page, ctx }) => {
const clientA = 'ClientA ' + Math.floor(Math.random() * 10000);
const clientB = 'ClientB ' + Math.floor(Math.random() * 10000);
const projectA = 'ClientFilterProjA ' + Math.floor(Math.random() * 10000);
const projectB = 'ClientFilterProjB ' + Math.floor(Math.random() * 10000);
const reportName = 'ClientFilterReport ' + Math.floor(Math.random() * 10000);
const cliA = await createClientViaApi(ctx, { name: clientA });
const cliB = await createClientViaApi(ctx, { name: clientB });
const projA = await createProjectViaApi(ctx, { name: projectA, client_id: cliA.id });
const projB = await createProjectViaApi(ctx, { name: projectB, client_id: cliB.id });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${clientA}`,
duration: '1h',
projectId: projA.id,
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${clientB}`,
duration: '2h',
projectId: projB.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectA)).toBeVisible();
// Filter by client A
await page.getByRole('button', { name: 'Clients' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: clientA }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText(projectA)).toBeVisible();
await expect(page.getByText(projectB)).not.toBeVisible();
});
test('test that shared report respects tag filter', async ({ page, ctx }) => {
const tagA = 'TagA ' + Math.floor(Math.random() * 10000);
const tagB = 'TagB ' + Math.floor(Math.random() * 10000);
const reportName = 'TagFilterReport ' + Math.floor(Math.random() * 10000);
const tagObjA = await createTagViaApi(ctx, { name: tagA });
await createTagViaApi(ctx, { name: tagB });
await createTimeEntryViaApi(ctx, {
description: `Entry with ${tagA}`,
duration: '1h',
tags: [tagObjA.id],
});
await createBareTimeEntryViaApi(ctx, 'Entry no tags', '2h');
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText('Total')).toBeVisible();
// Filter by tag A
await page.getByRole('button', { name: 'Tags' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: tagA }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText('1:00:00').first()).toBeVisible();
await expect(page.getByText('3:00:00')).not.toBeVisible();
});
test('test that shared report respects member filter', async ({ page, ctx }) => {
const projectName = 'MemberFilterProj ' + Math.floor(Math.random() * 10000);
const reportName = 'MemberFilterReport ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
// Filter by current member (John Doe)
await page.getByRole('button', { name: 'Members' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: 'John Doe' }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report — should still show data since all entries belong to this member
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText(projectName)).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
});
test('test that shared report with billable filter only shows billable entries', async ({
page,
ctx,
}) => {
const reportName = 'BillableFilterReport ' + Math.floor(Math.random() * 10000);
// Create one billable (1h) and one non-billable (2h) entry
await createTimeEntryWithBillableStatusViaApi(ctx, true, '1h');
await createTimeEntryWithBillableStatusViaApi(ctx, false, '2h');
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText('Total')).toBeVisible();
// Filter by billable only
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
await Promise.all([
page.getByRole('option', { name: 'Billable', exact: true }).click(),
waitForReportingUpdate(page),
]);
// Verify only 1h shows before saving
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
const { shareableLink } = await saveAsSharedReport(page, reportName);
// Navigate to the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
// Shared report should only show the 1h billable entry, not the 2h non-billable
await expect(page.getByText('1:00:00').first()).toBeVisible();
await expect(page.getByText('3:00:00')).not.toBeVisible();
});
// ──────────────────────────────────────────────────
// Report Date Picker Tests
// ──────────────────────────────────────────────────
test('test that creating a report with an expiration date works', async ({ page, ctx }) => {
const projectName = 'DatePickerProj ' + Math.floor(Math.random() * 10000);
const reportName = 'DatePickerReport ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
// Open the save report modal
await page.getByRole('button', { name: 'Save Report' }).click();
await page.getByLabel('Name').fill(reportName);
// The "Public" checkbox should be checked by default, showing the date picker
const datePicker = page
.getByRole('dialog')
.getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN });
await expect(datePicker).toBeVisible();
await datePicker.click();
// Select a date in the next month
const calendarGrid = page.getByRole('grid');
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /Next/i }).click();
await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click();
// Wait for the calendar to close
await expect(calendarGrid).not.toBeVisible();
// Create the report and verify it includes the public_until date
const [response] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reports') &&
response.request().method() === 'POST' &&
response.status() === 201
),
page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(),
]);
const responseBody = await response.json();
expect(responseBody.data.public_until).toBeTruthy();
});
test('test that editing a report to make it public with expiration date works', async ({
page,
ctx,
}) => {
const projectName = 'EditDateProj ' + Math.floor(Math.random() * 10000);
const reportName = 'EditDateReport ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
// Open the save report modal and create a private report
await page.getByRole('button', { name: 'Save Report' }).click();
await page.getByLabel('Name').fill(reportName);
// Uncheck "Public" to create a private report
await page.getByLabel('Public').click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reports') &&
response.request().method() === 'POST' &&
response.status() === 201
),
page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(),
]);
// Go to shared reports and edit
await goToReportingShared(page);
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Private')).toBeVisible();
// Click more options and edit
await page
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
.click();
await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
// Check "Public" to make it public - this should show the date picker
await page.getByLabel('Public').click();
// The date picker should now be visible
const datePicker = page
.getByRole('dialog')
.getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN });
await expect(datePicker).toBeVisible();
await datePicker.click();
// Select a date in the next month
const calendarGrid = page.getByRole('grid');
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /Next/i }).click();
await page.getByRole('gridcell').filter({ hasText: /^20$/ }).first().click();
// Wait for the calendar to close
await expect(calendarGrid).not.toBeVisible();
// Update the report and verify it includes the public_until date
const [response] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reports/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Report' }).click(),
]);
const responseBody = await response.json();
expect(responseBody.data.public_until).toBeTruthy();
expect(responseBody.data.is_public).toBe(true);
});
test('test that shared report with No Client filter shows entries without a client', async ({
page,
ctx,
}) => {
const clientName = 'NoClientCli ' + Math.floor(Math.random() * 10000);
const projectName = 'NoClientProj ' + Math.floor(Math.random() * 10000);
const reportName = 'NoClientReport ' + Math.floor(Math.random() * 10000);
const client = await createClientViaApi(ctx, { name: clientName });
const project = await createProjectViaApi(ctx, { name: projectName, client_id: client.id });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await createBareTimeEntryViaApi(ctx, 'Entry without client', '2h');
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
// Filter by "No Client"
await page.getByRole('button', { name: 'Clients' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: 'No Client' }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText(projectName)).not.toBeVisible();
});
test('test that shared report with No Tag filter shows entries without tags', async ({
page,
ctx,
}) => {
const tagName = 'NoTagFilter ' + Math.floor(Math.random() * 10000);
const reportName = 'NoTagReport ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tagName, '1h');
await createBareTimeEntryViaApi(ctx, 'Entry without tags', '2h');
await goToReporting(page);
await expect(page.getByText('Total')).toBeVisible();
// Filter by "No Tag"
await page.getByRole('button', { name: 'Tags' }).first().click();
await Promise.all([
page.getByRole('option').filter({ hasText: 'No Tag' }).click(),
waitForReportingUpdate(page),
]);
await page.keyboard.press('Escape');
const { shareableLink } = await saveAsSharedReport(page, reportName);
// View the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible();
});
test('test that creating a report with empty name shows validation error', async ({
page,
ctx,
}) => {
const projectName = 'EmptyNameProj ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
// Open the save report modal
await page.getByRole('button', { name: 'Save Report' }).click();
// Leave name empty and try to create
await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click();
// Should show validation error
await expect(page.getByText('The name field is required')).toBeVisible();
});
test('test that updating report name works', async ({ page, ctx }) => {
const projectName = 'UpdateNameProj ' + Math.floor(Math.random() * 10000);
const reportName = 'OriginalName ' + Math.floor(Math.random() * 10000);
const newReportName = 'UpdatedName ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
await saveAsSharedReport(page, reportName);
await goToReportingShared(page);
await expect(page.getByText(reportName)).toBeVisible();
// Click more options and edit
await page
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
.click();
await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
// Update the name
await page.getByLabel('Name', { exact: true }).fill(newReportName);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reports/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Report' }).click(),
]);
// Verify the name was updated in the table
await expect(page.getByText(newReportName)).toBeVisible();
await expect(page.getByText(reportName)).not.toBeVisible();
});
test('test that updating expiration date on already-public report works', async ({ page, ctx }) => {
const projectName = 'UpdateExpDateProj ' + Math.floor(Math.random() * 10000);
const reportName = 'UpdateExpDateReport ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
// Create a public report (already public by default)
await saveAsSharedReport(page, reportName);
// Go to shared reports and edit
await goToReportingShared(page);
await expect(page.getByText(reportName)).toBeVisible();
// Click more options and edit
await page
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
.click();
await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
// The date picker should be visible (report is already public)
const datePicker = page
.getByRole('dialog')
.getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN });
await expect(datePicker).toBeVisible();
await datePicker.click();
// Select the 25th of next month
const calendarGrid = page.getByRole('grid');
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /Next/i }).click();
await page.getByRole('gridcell').filter({ hasText: /^25$/ }).first().click();
// Wait for the calendar to close
await expect(calendarGrid).not.toBeVisible();
// Update the report and verify it includes the correct public_until date
const [response] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reports/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Report' }).click(),
]);
const responseBody = await response.json();
expect(responseBody.data.public_until).toBeTruthy();
// Verify the date is the 25th of a future month
const returnedDate = new Date(responseBody.data.public_until);
expect(returnedDate.getUTCDate()).toBe(25);
// The returned date should be in the future
const now = new Date();
expect(returnedDate.getTime()).toBeGreaterThan(now.getTime());
});
test('test that clearing the expiration date on a report works', async ({ page, ctx }) => {
const reportName = 'ClearExpReport ' + Math.floor(Math.random() * 10000);
// Create a public report with an expiration date via API
await createReportViaApi(ctx, {
name: reportName,
is_public: true,
public_until: dayjs().add(1, 'month').utc().format('YYYY-MM-DDTHH:mm:ss[Z]'),
});
// Go to shared reports and edit the report
await goToReportingShared(page);
await expect(page.getByText(reportName)).toBeVisible();
await page
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
.click();
await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The date picker should show a date (not "Pick a date")
await expect(
page.getByRole('dialog').getByRole('button', { name: 'Pick a date' })
).not.toBeVisible();
// Click the clear button (X icon) to remove the expiration date
const clearButton = page
.getByRole('dialog')
.locator('[role="button"]')
.filter({ has: page.locator('svg.lucide-x') });
await expect(clearButton).toBeVisible();
await clearButton.click();
// The date picker should now show "Pick a date"
await expect(
page.getByRole('dialog').getByRole('button', { name: 'Pick a date' })
).toBeVisible();
// The clear button should no longer be visible
await expect(clearButton).not.toBeVisible();
// Update the report and verify public_until is null
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reports/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Report' }).click(),
]);
const updateBody = await updateResponse.json();
expect(updateBody.data.public_until).toBeNull();
});
test('test that date picker clear button is not visible when no date is set', async ({
page,
ctx,
}) => {
const reportName = 'NoClearReport ' + Math.floor(Math.random() * 10000);
// Create a public report without an expiration date via API
await createReportViaApi(ctx, {
name: reportName,
is_public: true,
public_until: null,
});
// Go to shared reports and edit the report
await goToReportingShared(page);
await expect(page.getByText(reportName)).toBeVisible();
await page
.getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
.click();
await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The date picker should show "Pick a date"
await expect(
page.getByRole('dialog').getByRole('button', { name: 'Pick a date' })
).toBeVisible();
// The clear button should NOT be visible
const clearButton = page
.getByRole('dialog')
.locator('[role="button"]')
.filter({ has: page.locator('svg.lucide-x') });
await expect(clearButton).not.toBeVisible();
});
// ──────────────────────────────────────────────────
// Shared Report Cost Column Tests
// ──────────────────────────────────────────────────
test('test that shared report displays cost column correctly aligned with data rows', async ({
page,
ctx,
}) => {
const projectName = 'BillableProj ' + Math.floor(Math.random() * 10000);
const reportName = 'BillableReport ' + Math.floor(Math.random() * 10000);
const project = await createBillableProjectViaApi(ctx, {
name: projectName,
billable_rate: 10000, // 100.00 per hour
});
await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration: '1h',
projectId: project.id,
billable: true,
});
await goToReporting(page);
await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
const { shareableLink } = await saveAsSharedReport(page, reportName);
// Navigate to the shared report
await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText(projectName)).toBeVisible();
// Verify the table header has all three columns
await expect(page.getByText('Name', { exact: true })).toBeVisible();
await expect(page.getByText('Duration', { exact: true })).toBeVisible();
await expect(page.getByText('Cost', { exact: true })).toBeVisible();
// Verify the Total row displays both duration and cost
await expect(page.getByText('Total')).toBeVisible();
// The data rows should render cost values (not just header + duration)
// With 1h at 100/h the cost should be displayed somewhere in the table
// If showCost is not passed to ReportingRow, only the header "Cost" and
// the Total row cost will render, but individual row costs will be missing
const table = page.locator('[style*="grid-template-columns"]');
// Count elements containing the cost value - header "Cost" + project row cost + total row cost = 3
// If broken (showCost not passed), the project row won't render its cost cell
await expect(table.getByText(/100/).first()).toBeVisible();
// Verify the cost value appears at least twice in the table
// (once for the data row, once for the total) beyond just the header
const costValues = table.getByText(/100/);
await expect(costValues).toHaveCount(2);
});

View File

@@ -1,13 +1,15 @@
import { expect, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { createTagViaApi } from './utils/api';
import { getTableRowNames } from './utils/table';
async function goToTagsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
}
// Create new project via modal
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
test('test that creating and deleting a new tag via the modal works', async ({ page }) => {
const newTagName = 'New Tag ' + Math.floor(1 + Math.random() * 10000);
await goToTagsOverview(page);
await page.getByRole('button', { name: 'Create Tag' }).click();
@@ -40,3 +42,186 @@ test('test that creating and deleting a new client via the modal works', async (
]);
await expect(page.getByTestId('tag_table')).not.toContainText(newTagName);
});
test('test that editing a tag name works', async ({ page, ctx }) => {
const originalTagName = 'Original Tag ' + Math.floor(1 + Math.random() * 10000);
const updatedTagName = 'Updated Tag ' + Math.floor(1 + Math.random() * 10000);
await createTagViaApi(ctx, { name: originalTagName });
await goToTagsOverview(page);
await expect(page.getByTestId('tag_table')).toContainText(originalTagName);
// Open actions menu and click Edit
const moreButton = page.locator("[aria-label='Actions for Tag " + originalTagName + "']");
await moreButton.click();
await page.getByRole('menuitem').getByText('Edit').click();
// Update the tag name in the edit modal
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByPlaceholder('Tag Name').fill(updatedTagName);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/tags/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Tag' }).click(),
]);
// Verify the table shows the updated name
await expect(page.getByTestId('tag_table')).toContainText(updatedTagName);
await expect(page.getByTestId('tag_table')).not.toContainText(originalTagName);
});
test('test that multiple tags can be created via API and displayed in the table', async ({
page,
ctx,
}) => {
const tagName1 = 'TagA ' + Math.floor(1 + Math.random() * 10000);
const tagName2 = 'TagB ' + Math.floor(1 + Math.random() * 10000);
await createTagViaApi(ctx, { name: tagName1 });
await createTagViaApi(ctx, { name: tagName2 });
await goToTagsOverview(page);
await expect(page.getByTestId('tag_table')).toContainText(tagName1);
await expect(page.getByTestId('tag_table')).toContainText(tagName2);
});
// =============================================
// Context Menu Tests
// =============================================
test('test that tag context menu edit updates the tag', async ({ page, ctx }) => {
const tagName = 'CtxEditTag ' + Math.floor(1 + Math.random() * 10000);
const updatedName = 'CtxUpdatedTag ' + Math.floor(1 + Math.random() * 10000);
await createTagViaApi(ctx, { name: tagName });
await goToTagsOverview(page);
const row = page.getByRole('row').filter({ hasText: tagName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByPlaceholder('Tag Name').fill(updatedName);
await Promise.all([
page.getByRole('button', { name: 'Update Tag' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/tags') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
await expect(page.getByTestId('tag_table')).toContainText(updatedName);
await expect(page.getByTestId('tag_table')).not.toContainText(tagName);
});
test('test that tag context menu delete deletes the tag', async ({ page, ctx }) => {
const tagName = 'CtxDeleteTag ' + Math.floor(1 + Math.random() * 10000);
await createTagViaApi(ctx, { name: tagName });
await goToTagsOverview(page);
const row = page.getByRole('row').filter({ hasText: tagName }).first();
await expect(row).toBeVisible();
await row.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/tags') &&
response.request().method() === 'DELETE' &&
response.status() === 204
),
page.getByRole('menuitem', { name: 'Delete' }).click(),
]);
await expect(page.getByTestId('tag_table')).not.toContainText(tagName);
});
// =============================================
// Sorting Tests
// =============================================
async function clearTagTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('tag-table-state');
});
}
test('test that sorting tags by name works', async ({ page, ctx }) => {
await createTagViaApi(ctx, { name: 'AAA SortTag' });
await createTagViaApi(ctx, { name: 'ZZZ SortTag' });
await goToTagsOverview(page);
await clearTagTableState(page);
await page.reload();
const table = page.getByTestId('tag_table');
await expect(table).toBeVisible();
// Default is name asc
let names = await getTableRowNames(table);
expect(names.indexOf('AAA SortTag')).toBeLessThan(names.indexOf('ZZZ SortTag'));
const nameHeader = table.getByText('Name').first();
await nameHeader.click(); // toggle to desc
names = await getTableRowNames(table);
expect(names.indexOf('ZZZ SortTag')).toBeLessThan(names.indexOf('AAA SortTag'));
});
test('test that tag sort state persists after page reload', async ({ page }) => {
await goToTagsOverview(page);
await clearTagTableState(page);
await page.reload();
const table = page.getByTestId('tag_table');
await expect(table).toBeVisible();
const nameHeader = table.getByText('Name').first();
await nameHeader.click(); // toggle to desc
await expect(nameHeader.locator('svg')).toBeVisible();
await page.reload();
await expect(page.getByTestId('tag_table')).toBeVisible();
await expect(
page.getByTestId('tag_table').getByText('Name').first().locator('svg')
).toBeVisible();
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Tags Restrictions', () => {
test('employee can view tags but cannot create', async ({ ctx, employee }) => {
const tagName = 'EmpViewTag ' + Math.floor(Math.random() * 10000);
await createTagViaApi(ctx, { name: tagName });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/tags');
await expect(employee.page.getByTestId('tags_view')).toBeVisible({ timeout: 10000 });
// Employee can see the tag (tags are visible to all members with tags:view)
await expect(employee.page.getByText(tagName)).toBeVisible({ timeout: 10000 });
// Employee cannot see Create Tag button
await expect(employee.page.getByRole('button', { name: 'Create Tag' })).not.toBeVisible();
});
test('employee cannot see edit/delete actions on tags', async ({ ctx, employee }) => {
const tagName = 'EmpActionsTag ' + Math.floor(Math.random() * 10000);
await createTagViaApi(ctx, { name: tagName });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/tags');
await expect(employee.page.getByText(tagName)).toBeVisible({ timeout: 10000 });
// Actions button should not be visible for employee
const actionsButton = employee.page.locator(`[aria-label='Actions for Tag ${tagName}']`);
await expect(actionsButton).not.toBeVisible();
});
});

View File

@@ -1,13 +1,20 @@
import { expect, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import {
createProjectViaApi,
createPublicProjectViaApi,
createTaskViaApi,
createClientViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
}
// Create new project via modal
test('test that creating and deleting a new tag in a new project works', async ({ page }) => {
test('test that creating and deleting a new task in a new project works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
@@ -27,11 +34,9 @@ test('test that creating and deleting a new tag in a new project works', async (
]);
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
await page.getByText(newProjectName).click();
const newTaskName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newTaskName = 'New Task ' + Math.floor(1 + Math.random() * 10000);
await page.getByRole('button', { name: 'Create Task' }).click();
await page.getByPlaceholder('Task Name').fill(newTaskName);
@@ -83,23 +88,14 @@ test('test that creating and deleting a new tag in a new project works', async (
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
});
test('test that archiving and unarchiving tasks works', async ({ page }) => {
test('test that archiving and unarchiving tasks works', async ({ page, ctx }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newTaskName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newTaskName = 'New Task ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible();
await page.getByText(newProjectName).click();
await page.getByRole('button', { name: 'Create Task' }).click();
await page.getByPlaceholder('Task Name').fill(newTaskName);
await page.getByRole('button', { name: 'Create Task' }).click();
const project = await createProjectViaApi(ctx, { name: newProjectName });
await createTaskViaApi(ctx, { name: newTaskName, project_id: project.id });
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
await expect(page.getByRole('table')).toContainText(newTaskName);
await page.getByRole('row').first().getByRole('button').click();
@@ -123,14 +119,194 @@ test('test that archiving and unarchiving tasks works', async ({ page }) => {
]);
});
// Create new project with new Client
test('test that editing a task name works', async ({ page, ctx }) => {
const projectName = 'TaskEdit Project ' + Math.floor(1 + Math.random() * 10000);
const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000);
const updatedTaskName = 'Updated Task ' + Math.floor(1 + Math.random() * 10000);
// Create new project with existing Client
const project = await createProjectViaApi(ctx, { name: projectName });
await createTaskViaApi(ctx, { name: originalTaskName, project_id: project.id });
// Delete project via More Options
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
await expect(page.getByTestId('task_table')).toContainText(originalTaskName);
// Test that project task count is displayed correctly
// Open actions menu and click Edit
const moreButton = page.locator("[aria-label='Actions for Task " + originalTaskName + "']");
await moreButton.click();
await page.getByRole('menuitem').getByText('Edit').click();
// Test that active / archive / all filter works (once implemented)
// Update the task name
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByPlaceholder('Task Name').fill(updatedTaskName);
await Promise.all([
page.getByRole('button', { name: 'Update Task' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/tasks') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
]);
// Test update task name
await expect(page.getByTestId('task_table')).toContainText(updatedTaskName);
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
});
test('test that creating a project with an existing client works', async ({ page, ctx }) => {
const clientName = 'Existing Client ' + Math.floor(1 + Math.random() * 10000);
const projectName = 'Project With Client ' + Math.floor(1 + Math.random() * 10000);
await createClientViaApi(ctx, { name: clientName });
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(projectName);
// Select the existing client
await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click();
await page.getByRole('option', { name: clientName }).click();
await Promise.all([
page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.client_id !== null
),
]);
await expect(page.getByTestId('project_table')).toContainText(projectName);
await expect(page.getByTestId('project_table')).toContainText(clientName);
});
test('test that multiple tasks are displayed on project detail page', async ({ page, ctx }) => {
const projectName = 'TaskCount Project ' + Math.floor(1 + Math.random() * 10000);
const taskName1 = 'CountTask A ' + Math.floor(1 + Math.random() * 10000);
const taskName2 = 'CountTask B ' + Math.floor(1 + Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
await createTaskViaApi(ctx, { name: taskName1, project_id: project.id });
await createTaskViaApi(ctx, { name: taskName2, project_id: project.id });
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
await expect(page.getByText(taskName1)).toBeVisible();
await expect(page.getByText(taskName2)).toBeVisible();
});
test('test that creating a new project from the task create modal project dropdown works', async ({
page,
ctx,
}) => {
const existingProjectName = 'Existing Project ' + Math.floor(1 + Math.random() * 10000);
const newProjectName = 'Dropdown Created Project ' + Math.floor(1 + Math.random() * 10000);
const newTaskName = 'Task With New Project ' + Math.floor(1 + Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: existingProjectName });
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
// Open the Create Task modal
await page.getByRole('button', { name: 'Create Task' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByPlaceholder('Task Name').fill(newTaskName);
// Open the project dropdown (it should show the current project)
await page.getByRole('dialog').getByRole('button', { name: existingProjectName }).click();
// Click "Create new Project" at the bottom of the dropdown
await page.getByText('Create new Project').click();
// The ProjectCreateModal should appear
await expect(page.getByLabel('Project name')).toBeVisible();
await page.getByLabel('Project name').fill(newProjectName);
// Submit the project creation
await Promise.all([
page.getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.name === newProjectName
),
]);
// The project dropdown trigger should now show the new project name
await expect(
page.getByRole('dialog').getByRole('button', { name: newProjectName })
).toBeVisible();
// Submit the task and capture the response to get the new project ID
const [taskResponse] = await Promise.all([
page.waitForResponse(
async (response) =>
response.url().includes('/tasks') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.name === newTaskName
),
page.getByRole('button', { name: 'Create Task' }).click(),
]);
const taskData = await taskResponse.json();
const newProjectId = taskData.data.project_id;
// Navigate to the new project's page and verify the task is there
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + newProjectId);
await expect(page.getByTestId('task_table')).toContainText(newTaskName);
});
// =============================================
// Employee Permission Tests
// =============================================
test.describe('Employee Tasks Restrictions', () => {
test('employee cannot see task management actions when employees_can_manage_tasks is disabled', async ({
ctx,
employee,
}) => {
// Create a public project with a task
const projectName = 'EmpTaskProj ' + Math.floor(Math.random() * 10000);
const taskName = 'EmpTask ' + Math.floor(Math.random() * 10000);
const project = await createPublicProjectViaApi(ctx, { name: projectName });
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
// Navigate to the project detail page
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
await employee.page.getByText(projectName).first().click();
await employee.page.waitForURL(/\/projects\/[a-f0-9-]+/);
// Task should be visible
await expect(employee.page.getByText(taskName)).toBeVisible({ timeout: 10000 });
// Create Task button should not be visible
await expect(employee.page.getByRole('button', { name: 'Create Task' })).not.toBeVisible();
// Task actions button should not be visible
const actionsButton = employee.page.locator(`[aria-label='Actions for Task ${taskName}']`);
await expect(actionsButton).not.toBeVisible();
});
test('employee can manage tasks when employees_can_manage_tasks is enabled', async ({
ctx,
employee,
}) => {
// Enable the setting
await updateOrganizationSettingViaApi(ctx, { employees_can_manage_tasks: true });
const projectName = 'EmpTaskMgmtProj ' + Math.floor(Math.random() * 10000);
await createPublicProjectViaApi(ctx, { name: projectName });
// Navigate to the project detail page
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
await employee.page.getByText(projectName).first().click();
await employee.page.waitForURL(/\/projects\/[a-f0-9-]+/);
// Create Task button SHOULD be visible
await expect(employee.page.getByRole('button', { name: 'Create Task' })).toBeVisible();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,12 @@ import {
startOrStopTimerWithButton,
stoppedTimeEntryResponse,
} from './utils/currentTimeEntry';
import { Page } from '@playwright/test';
import type { Page } from '@playwright/test';
import { newTagResponse } from './utils/tags';
import { createProjectViaApi, updateOrganizationCurrencyViaWeb } from './utils/api';
// Date picker button name patterns for different date formats
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
async function goToDashboard(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
@@ -18,20 +22,35 @@ test('test that starting and stopping a timer without description and project wo
page,
}) => {
await goToDashboard(page);
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
assertThatTimerHasStarted(page),
]);
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerIsStopped(page);
});
test('test that billable icon shows dollar sign for USD currency', async ({ page, ctx }) => {
await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
await goToDashboard(page);
await page.waitForLoadState('networkidle');
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
await expect(billableButton).toBeVisible();
await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 8 14');
});
test('test that billable icon shows euro sign for EUR currency', async ({ page, ctx }) => {
await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
await goToDashboard(page);
await page.waitForLoadState('networkidle');
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
await expect(billableButton).toBeVisible();
await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 12 12');
});
test('test that starting and stopping a timer with a description works', async ({ page }) => {
await goToDashboard(page);
// TODO: Fix flakyness by disabling description input field until timer is loaded
await page.waitForTimeout(500);
// Wait for the description input to be editable before filling
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByTestId('time_entry_description').fill('New Time Entry Description');
await Promise.all([
newTimeEntryResponse(page, {
@@ -57,13 +76,12 @@ test('test that starting the time entry starts the live timer and that it keeps
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(500);
const beforeTimerValue = await page.getByTestId('time_entry_time').inputValue();
await page.waitForTimeout(2000);
const afterWaitTimeValue = await page.getByTestId('time_entry_time').inputValue();
expect(afterWaitTimeValue).not.toEqual(beforeTimerValue);
await page.reload();
await page.waitForTimeout(500);
await expect(page.getByTestId('time_entry_time')).toBeVisible();
const afterReloadTimerValue = await page.getByTestId('time_entry_time').inputValue();
await page.waitForTimeout(2000);
@@ -76,7 +94,7 @@ test('test that starting and updating the description while running works', asyn
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(500);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByTestId('time_entry_description').fill('New Time Entry Description');
await Promise.all([
@@ -86,7 +104,6 @@ test('test that starting and updating the description while running works', asyn
}),
page.getByTestId('time_entry_description').press('Tab'),
]);
await page.waitForTimeout(500);
await Promise.all([
stoppedTimeEntryResponse(page, {
description: 'New Time Entry Description',
@@ -103,7 +120,7 @@ test('test that starting and updating the time while running works', async ({ pa
await startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(500);
await expect(page.getByTestId('time_entry_time')).toBeEditable();
await page.getByTestId('time_entry_time').fill('20min');
await Promise.all([
@@ -127,7 +144,6 @@ test('test that starting and updating the time while running works', async ({ pa
]);
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
await page.waitForTimeout(500);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerIsStopped(page);
});
@@ -143,9 +159,7 @@ test('test that entering a human readable time starts the timer on blur', async
await assertThatTimerHasStarted(page);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
await assertThatTimerIsStopped(page);
});
test('test that entering a number in the time range starts the timer on blur', async ({ page }) => {
@@ -159,9 +173,7 @@ test('test that entering a number in the time range starts the timer on blur', a
await assertThatTimerHasStarted(page);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
await assertThatTimerIsStopped(page);
});
test('test that entering a value with the format hh:mm in the time range starts the timer on blur', async ({
@@ -177,9 +189,7 @@ test('test that entering a value with the format hh:mm in the time range starts
await assertThatTimerHasStarted(page);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
await assertThatTimerIsStopped(page);
});
test('test that entering a random value in the time range does not start the timer on blur', async ({
@@ -187,10 +197,8 @@ test('test that entering a random value in the time range does not start the tim
}) => {
await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('asdasdasd');
await page.getByTestId('time_entry_time').press('Tab'),
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
await page.getByTestId('time_entry_time').press('Tab');
await assertThatTimerIsStopped(page);
});
test('test that entering a time starts the timer on enter', async ({ page }) => {
@@ -218,6 +226,11 @@ test('test that adding a new tag works', async ({ page }) => {
page.getByRole('button', { name: 'Create Tag' }).click(),
]);
// Wait for tags query refetch after invalidation
await page.waitForResponse(
(response) => response.url().includes('/tags') && response.status() === 200
);
await page.getByTestId('tag_dropdown').click();
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
});
@@ -240,7 +253,7 @@ test('test that adding a new tag when the timer is running', async ({ page }) =>
await page.getByTestId('tag_dropdown').click();
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
await page.getByTestId('tag_dropdown_search').press('Escape');
await page.waitForTimeout(1000);
await expect(page.getByTestId('tag_dropdown_search')).not.toBeVisible();
await Promise.all([
stoppedTimeEntryResponse(page, { tags: [tagId] }),
@@ -249,18 +262,182 @@ test('test that adding a new tag when the timer is running', async ({ page }) =>
await assertThatTimerIsStopped(page);
});
// test that search is working
test('test that setting an end time with a different date via the timetracker range selector works', async ({
page,
}) => {
await goToDashboard(page);
// test that adding a tag and project and starting the timer afterwards works and sets the project and tag correctly
// Start a timer
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerHasStarted(page);
// test that changing the project works
// Open the time range dropdown by clicking on the time display
await page.getByTestId('time_entry_time').click();
const rangeStart = page.getByTestId('time_entry_range_start');
await expect(rangeStart).toBeVisible();
// test that sidebar timetracker starts and stops timer
// Click "Set End Time" button
await page.getByRole('button', { name: 'Set End Time' }).click();
// test that sidebar timetracker changes state when tmer on dashboard is started
// The end time picker should now be visible with a Confirm button
const rangeEnd = page.getByTestId('time_entry_range_end');
await expect(rangeEnd).toBeVisible();
const confirmButton = page.getByRole('button', { name: 'Confirm' });
await expect(confirmButton).toBeVisible();
// test billable toggle
// Click the end date picker to change the date
const endDatePickers = page.getByRole('button', { name: DATE_DISPLAY_PATTERN });
// The second date picker is the end date (first is the start date)
const endDatePicker = endDatePickers.nth(1);
await expect(endDatePicker).toBeVisible();
await endDatePicker.click();
// TODO: Test that project can be created in the time tracker row
// Calendar should appear
const calendarGrid = page.getByRole('grid');
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
// Add Test that time tracker starts on enter with description
// Navigate to the next month and select a day to ensure end > start
await page.getByRole('button', { name: /Next/i }).click();
await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click();
// The dropdown should still be open after selecting a date (not auto-closed)
await expect(rangeEnd).toBeVisible();
await expect(confirmButton).toBeVisible();
// Click Confirm to finalize and verify the API call
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
confirmButton.click(),
]);
const updateBody = await updateResponse.json();
expect(updateBody.data.start).toBeTruthy();
expect(updateBody.data.end).toBeTruthy();
});
test('test that timer starts on enter with description', async ({ page }) => {
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByTestId('time_entry_description').fill('Start on Enter');
await Promise.all([
newTimeEntryResponse(page, { description: 'Start on Enter' }),
page.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(page);
await Promise.all([
stoppedTimeEntryResponse(page, { description: 'Start on Enter' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that timer started on dashboard is visible on time page', async ({ page }) => {
await goToDashboard(page);
// Start timer on dashboard
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByTestId('time_entry_description').fill('Sync test');
await Promise.all([
newTimeEntryResponse(page, { description: 'Sync test' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page);
// Navigate to time page
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
// Timer should still be running (the timer button should be red/active)
await expect(
page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
).toHaveClass(/bg-red-400\/80/);
// Stop the timer
await Promise.all([
stoppedTimeEntryResponse(page, { description: 'Sync test' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that creating a new project from the time tracker dropdown prefills the search text', async ({
page,
ctx,
}) => {
const existingProjectName = 'Existing Project ' + Math.floor(Math.random() * 10000);
const searchText = 'PrefillProject ' + Math.floor(Math.random() * 10000);
// Create a project so the dropdown renders (not the "Add new project" button)
await createProjectViaApi(ctx, { name: existingProjectName });
await goToDashboard(page);
// Open the project dropdown
await page.getByRole('button', { name: 'No Project' }).click();
// Type a search term that won't match any existing project
await page.getByTestId('client_dropdown_search').fill(searchText);
// Click "Create new Project"
await page.getByText('Create new Project').click();
// Verify the project name input is pre-filled with the search text
await expect(page.getByLabel('Project name')).toHaveValue(searchText);
// Complete project creation to verify full flow works
await Promise.all([
page.waitForResponse(
async (response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.name === searchText
),
page.getByRole('button', { name: 'Create Project' }).click(),
]);
// The project dropdown should now show the newly created project
await expect(page.getByRole('button', { name: searchText })).toBeVisible();
});
test('test that adding a project and tag before starting timer works', async ({ page }) => {
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
await goToDashboard(page);
// Create and select a tag first
await page.getByTestId('tag_dropdown').click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(newTagName);
const [tagCreateResponse] = await Promise.all([
newTagResponse(page, { name: newTagName }),
page.getByRole('button', { name: 'Create Tag' }).click(),
]);
const tagId = (await tagCreateResponse.json()).data.id;
// Wait for tags query refetch (tag is auto-selected after creation)
await page.waitForResponse(
(response) => response.url().includes('/tags') && response.status() === 200
);
// Fill description and start
await page.getByTestId('time_entry_description').fill('Entry with tag');
await Promise.all([
newTimeEntryResponse(page, { description: 'Entry with tag', tags: [tagId] }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page);
await Promise.all([
stoppedTimeEntryResponse(page, { description: 'Entry with tag', tags: [tagId] }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});

766
e2e/utils/api.ts Normal file
View File

@@ -0,0 +1,766 @@
import { expect } from '@playwright/test';
import type { APIRequestContext, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
// ──────────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────────
export interface TestContext {
request: APIRequestContext;
orgId: string;
memberId: string;
}
// ──────────────────────────────────────────────────
// Auth helpers
// ──────────────────────────────────────────────────
/**
* Create a Passport API token by calling the token endpoint from the browser.
*
* The browser's native fetch includes the laravel_token cookie (set by
* CreateFreshApiToken during the dashboard page load), so authentication
* is handled by the browser's own cookie jar. The returned Bearer token is
* then used for all subsequent API calls, making them independent of cookie state.
*
* If the first attempt returns 401 (Octane hasn't fully committed the session yet),
* we reload the page to trigger a fresh CreateFreshApiToken and retry.
*/
async function createApiToken(page: Page): Promise<string> {
for (let attempt = 0; attempt < 3; attempt++) {
const result = await page.evaluate(async (baseUrl) => {
const xsrfCookie = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='));
const xsrfToken = xsrfCookie
? decodeURIComponent(xsrfCookie.split('=').slice(1).join('='))
: '';
const res = await fetch(`${baseUrl}/api/v1/users/me/api-tokens`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': xsrfToken,
},
body: JSON.stringify({ name: 'playwright-test' }),
});
if (!res.ok) {
return null;
}
const body = await res.json();
return body.data.access_token as string;
}, PLAYWRIGHT_BASE_URL);
if (result) {
return result;
}
// Reload to get a fresh laravel_token cookie and retry.
// networkidle gives Octane time to fully commit the session.
await page.reload({ waitUntil: 'networkidle' });
}
throw new Error('Failed to create API token after retries');
}
function bearerHeaders(token: string): Record<string, string> {
return {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
};
}
// ──────────────────────────────────────────────────
// Context setup
// ──────────────────────────────────────────────────
export async function setupTestContext(page: Page): Promise<TestContext> {
const token = await createApiToken(page);
const request = page.request;
const headers = bearerHeaders(token);
const orgId = await getOrganizationId(request, headers);
const memberId = await getCurrentMemberId(request, orgId, headers);
return { request: createAuthenticatedRequest(request, headers), orgId, memberId };
}
function createAuthenticatedRequest(
request: APIRequestContext,
headers: Record<string, string>
): APIRequestContext {
// Wrap the request to always include auth headers
return new Proxy(request, {
get(target, prop) {
if (
prop === 'get' ||
prop === 'post' ||
prop === 'put' ||
prop === 'delete' ||
prop === 'patch'
) {
return (url: string, options?: Record<string, unknown>) => {
return target[prop as 'get'](url, {
...options,
headers: {
...headers,
...((options?.headers as Record<string, string>) || {}),
},
});
};
}
return target[prop as keyof APIRequestContext];
},
});
}
async function getOrganizationId(
request: APIRequestContext,
headers: Record<string, string>
): Promise<string> {
const response = await request.get(`${PLAYWRIGHT_BASE_URL}/api/v1/users/me/memberships`, {
headers,
});
expect(response.status()).toBe(200);
const body = await response.json();
return body.data[0].organization.id;
}
async function getCurrentMemberId(
request: APIRequestContext,
orgId: string,
headers: Record<string, string>
): Promise<string> {
const response = await request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${orgId}/members`,
{ headers }
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data[0].id;
}
// ──────────────────────────────────────────────────
// Duration parsing
// ──────────────────────────────────────────────────
function parseDurationToSeconds(duration: string): number {
let totalSeconds = 0;
// Match patterns like "1h", "30min", "2h 30min", "1h 7min"
const hourMatch = duration.match(/(\d+)\s*h/);
const minMatch = duration.match(/(\d+)\s*min/);
if (hourMatch) {
totalSeconds += parseInt(hourMatch[1], 10) * 3600;
}
if (minMatch) {
totalSeconds += parseInt(minMatch[1], 10) * 60;
}
// If no h/min pattern matched, try plain number as minutes
if (!hourMatch && !minMatch) {
const plainNumber = parseInt(duration, 10);
if (!isNaN(plainNumber)) {
totalSeconds = plainNumber * 60;
}
}
return totalSeconds;
}
function createTimestamps(duration: string): { start: string; end: string } {
const durationSeconds = parseDurationToSeconds(duration);
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0, 0);
const end = new Date(start.getTime() + durationSeconds * 1000);
return {
start: formatTimestamp(start),
end: formatTimestamp(end),
};
}
function formatTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
function randomColor(): string {
const colors = [
'#ef5350',
'#ab47bc',
'#5c6bc0',
'#29b6f6',
'#26a69a',
'#9ccc65',
'#ffa726',
'#8d6e63',
];
return colors[Math.floor(Math.random() * colors.length)];
}
// ──────────────────────────────────────────────────
// Entity creation
// ──────────────────────────────────────────────────
export async function createPublicProjectViaApi(
ctx: TestContext,
data: {
name: string;
is_billable?: boolean;
billable_rate?: number | null;
client_id?: string | null;
}
) {
return createProjectViaApi(ctx, {
...data,
is_public: true,
});
}
export async function createProjectViaApi(
ctx: TestContext,
data: {
name: string;
color?: string;
is_billable?: boolean;
billable_rate?: number | null;
client_id?: string | null;
estimated_time?: number | null;
is_public?: boolean;
}
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects`,
{
data: {
name: data.name,
color: data.color ?? randomColor(),
is_billable: data.is_billable ?? false,
billable_rate: data.billable_rate ?? null,
client_id: data.client_id ?? null,
estimated_time: data.estimated_time ?? null,
is_public: data.is_public ?? false,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; name: string; color: string; is_billable: boolean };
}
export async function archiveProjectViaApi(
ctx: TestContext,
project: {
id: string;
name: string;
color: string;
is_billable: boolean;
client_id?: string | null;
billable_rate?: number | null;
estimated_time?: number | null;
}
) {
const response = await ctx.request.put(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects/${project.id}`,
{
data: {
name: project.name,
color: project.color,
is_billable: project.is_billable,
is_archived: true,
client_id: project.client_id ?? null,
billable_rate: project.billable_rate ?? null,
estimated_time: project.estimated_time ?? null,
},
}
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data;
}
export async function createBillableProjectViaApi(
ctx: TestContext,
data: { name: string; billable_rate?: number | null }
) {
return createProjectViaApi(ctx, {
name: data.name,
is_billable: true,
billable_rate: data.billable_rate ?? null,
});
}
export async function createClientViaApi(ctx: TestContext, data: { name: string }) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/clients`,
{ data: { name: data.name } }
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; name: string };
}
export async function createProjectWithClientViaApi(
ctx: TestContext,
projectName: string,
clientName: string
) {
const client = await createClientViaApi(ctx, { name: clientName });
const project = await createProjectViaApi(ctx, {
name: projectName,
client_id: client.id,
});
return { project, client };
}
export async function createTaskViaApi(
ctx: TestContext,
data: { name: string; project_id: string }
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks`,
{
data: {
name: data.name,
project_id: data.project_id,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; name: string; project_id: string };
}
export async function createTagViaApi(ctx: TestContext, data: { name: string }) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tags`,
{ data: { name: data.name } }
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; name: string };
}
export async function createTimeEntryViaApi(
ctx: TestContext,
data: {
description?: string;
duration: string;
projectId?: string | null;
taskId?: string | null;
tags?: string[];
billable?: boolean;
}
) {
const { start, end } = createTimestamps(data.duration);
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
{
data: {
member_id: ctx.memberId,
start,
end,
description: data.description ?? '',
project_id: data.projectId ?? null,
task_id: data.taskId ?? null,
tags: data.tags ?? [],
billable: data.billable ?? false,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; start: string; end: string; description: string };
}
export async function createProjectMemberViaApi(
ctx: TestContext,
projectId: string,
data: { member_id: string; billable_rate?: number | null }
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects/${projectId}/project-members`,
{
data: {
member_id: data.member_id,
billable_rate: data.billable_rate ?? null,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; billable_rate: number | null };
}
export async function getMembersViaApi(ctx: TestContext) {
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as Array<{
id: string;
name: string;
email: string;
role: string;
billable_rate: number | null;
is_placeholder: boolean;
}>;
}
export async function updateMemberBillableRateViaApi(
ctx: TestContext,
memberId: string,
billableRate: number | null
) {
const response = await ctx.request.put(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members/${memberId}`,
{ data: { billable_rate: billableRate } }
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data;
}
// ──────────────────────────────────────────────────
// Composite helpers (matching existing UI helper signatures)
// ──────────────────────────────────────────────────
export async function createTimeEntryWithProjectViaApi(
ctx: TestContext,
projectName: string,
duration: string
) {
const project = await createProjectViaApi(ctx, { name: projectName });
const entry = await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName}`,
duration,
projectId: project.id,
});
return { project, entry };
}
export async function createTimeEntryWithProjectAndTaskViaApi(
ctx: TestContext,
projectId: string,
taskName: string,
projectName: string,
duration: string
) {
const task = await createTaskViaApi(ctx, { name: taskName, project_id: projectId });
const entry = await createTimeEntryViaApi(ctx, {
description: `Entry for ${projectName} - ${taskName}`,
duration,
projectId,
taskId: task.id,
});
return { task, entry };
}
export async function createTimeEntryWithTagViaApi(
ctx: TestContext,
tagName: string,
duration: string
) {
const tag = await createTagViaApi(ctx, { name: tagName });
const entry = await createTimeEntryViaApi(ctx, {
description: `Entry with tag ${tagName}`,
duration,
tags: [tag.id],
});
return { tag, entry };
}
export async function createRunningTimeEntryViaApi(ctx: TestContext, description: string) {
const start = new Date();
start.setMinutes(start.getMinutes() - 10);
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
{
data: {
member_id: ctx.memberId,
start: formatTimestamp(start),
description,
billable: false,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; start: string; end: null; description: string };
}
export async function createBareTimeEntryViaApi(
ctx: TestContext,
description: string,
duration: string
) {
return createTimeEntryViaApi(ctx, { description, duration });
}
export async function createTimeEntryWithBillableStatusViaApi(
ctx: TestContext,
isBillable: boolean,
duration: string
) {
return createTimeEntryViaApi(ctx, {
description: `Time entry ${isBillable ? 'billable' : 'non-billable'}`,
duration,
billable: isBillable,
});
}
// ──────────────────────────────────────────────────
// Import helper (for placeholder member creation)
// ──────────────────────────────────────────────────
export async function createPlaceholderMemberViaImportApi(
ctx: TestContext,
placeholderName: string
) {
const placeholderEmail = `placeholder+${Math.floor(Math.random() * 100000)}@solidtime-import.test`;
const csvContent = [
'User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Tags',
`${placeholderName},${placeholderEmail},,,,Imported entry,No,2024-01-01,09:00:00,2024-01-01,10:00:00,`,
].join('\n');
const base64Data = Buffer.from(csvContent).toString('base64');
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/import`,
{
data: {
type: 'toggl_time_entries',
data: base64Data,
},
}
);
expect(response.status()).toBe(200);
return await response.json();
}
// ──────────────────────────────────────────────────
// Organization settings helpers
// ──────────────────────────────────────────────────
export async function updateOrganizationSettingViaApi(
ctx: TestContext,
settings: Record<string, unknown>
) {
const response = await ctx.request.put(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}`,
{ data: settings }
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data;
}
export async function updateOrganizationCurrencyViaWeb(
page: Page,
ctx: TestContext,
currency: string,
name: string = 'Test Organization'
) {
const cookies = await page.context().cookies();
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
const response = await page.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, {
headers: { 'X-XSRF-TOKEN': xsrfToken },
data: { name, currency },
});
expect(response.status()).toBe(200);
}
// ──────────────────────────────────────────────────
// Bulk helpers
// ──────────────────────────────────────────────────
export async function createMultipleTimeEntriesViaApi(
ctx: TestContext,
count: number,
data: { description?: string; duration?: string } = {}
) {
const entries = [];
for (let i = 0; i < count; i++) {
const entry = await createTimeEntryViaApi(ctx, {
description: data.description ?? `Bulk entry ${i + 1}`,
duration: data.duration ?? '30min',
});
entries.push(entry);
}
return entries;
}
// ──────────────────────────────────────────────────
// Invitation helpers
// ──────────────────────────────────────────────────
export async function getInvitationsViaApi(ctx: TestContext) {
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invitations`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as Array<{ id: string; email: string; role: string }>;
}
// ──────────────────────────────────────────────────
// Timestamp-based time entry helpers
// ──────────────────────────────────────────────────
export async function createTimeEntryWithTimestampsViaApi(
ctx: TestContext,
data: {
description?: string;
start: string;
end: string;
projectId?: string | null;
taskId?: string | null;
tags?: string[];
billable?: boolean;
}
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
{
data: {
member_id: ctx.memberId,
start: data.start,
end: data.end,
description: data.description ?? '',
project_id: data.projectId ?? null,
task_id: data.taskId ?? null,
tags: data.tags ?? [],
billable: data.billable ?? false,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; start: string; end: string; description: string };
}
// ──────────────────────────────────────────────────
// User profile helpers
// ──────────────────────────────────────────────────
export async function updateUserProfileViaWeb(
page: Page,
settings: { timezone?: string; week_start?: string }
) {
// Read user info from Inertia's data-page attribute on the root element
const userInfo = await page.evaluate(() => {
// Try Inertia's data-page attribute (stores initial page props as JSON)
const appEl = document.getElementById('app');
if (appEl) {
const dataPage = appEl.getAttribute('data-page');
if (dataPage) {
try {
const parsed = JSON.parse(dataPage);
const user = parsed?.props?.auth?.user;
if (user) {
return {
name: user.name,
email: user.email,
timezone: user.timezone,
week_start: user.week_start,
};
}
} catch {
// JSON parse failed
}
}
}
return null;
});
if (!userInfo) throw new Error('Could not read user info from Inertia data-page attribute');
const cookies = await page.context().cookies();
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
const response = await page.request.put(`${PLAYWRIGHT_BASE_URL}/user/profile-information`, {
headers: {
'X-XSRF-TOKEN': xsrfToken,
'Content-Type': 'application/json',
Accept: 'application/json',
},
data: {
name: userInfo.name,
email: userInfo.email,
timezone: settings.timezone ?? userInfo.timezone,
week_start: settings.week_start ?? userInfo.week_start,
},
});
expect(response.status()).toBe(200);
}
// ──────────────────────────────────────────────────
// Running time entry with specific start
// ──────────────────────────────────────────────────
export async function createRunningTimeEntryWithStartViaApi(
ctx: TestContext,
description: string,
start: string
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
{
data: {
member_id: ctx.memberId,
start,
description,
billable: false,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; start: string; end: null; description: string };
}
// ──────────────────────────────────────────────────
// Reports
// ──────────────────────────────────────────────────
export async function createReportViaApi(
ctx: TestContext,
data: {
name: string;
is_public?: boolean;
public_until?: string | null;
}
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/reports`,
{
data: {
name: data.name,
description: '',
is_public: data.is_public ?? true,
public_until: data.public_until ?? null,
properties: {
start: '2024-01-01T00:00:00Z',
end: '2030-12-31T23:59:59Z',
group: 'project',
sub_group: 'project',
history_group: 'day',
},
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as {
id: string;
name: string;
is_public: boolean;
public_until: string | null;
};
}

View File

@@ -1,13 +1,21 @@
import { expect, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
export async function startOrStopTimerWithButton(page: Page) {
await page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]').click();
await page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
.click();
}
export async function assertThatTimerHasStarted(page: Page) {
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-red-400/80'
);
await expect(
page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
).toHaveClass(/bg-red-400\/80/);
}
export function newTimeEntryResponse(
@@ -33,7 +41,10 @@ export function newTimeEntryResponse(
export async function assertThatTimerIsStopped(page: Page) {
await expect(
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
page
.getByTestId('dashboard_timer')
.getByTestId('timer_button')
.and(page.locator(':visible'))
).toHaveClass(/bg-accent-300\/70/);
}

81
e2e/utils/mailpit.ts Normal file
View File

@@ -0,0 +1,81 @@
import { expect } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import { MAILPIT_BASE_URL } from '../../playwright/config';
/**
* Search for emails in Mailpit matching the given query.
*/
export async function searchEmails(
request: APIRequestContext,
query: string
): Promise<{ messages: Array<{ ID: string; Subject: string }> }> {
const response = await request.get(`${MAILPIT_BASE_URL}/api/v1/search?query=${query}`);
return response.json();
}
/**
* Get the full email message from Mailpit by ID.
*/
export async function getMessage(
request: APIRequestContext,
messageId: string
): Promise<{ HTML: string; Text: string }> {
const response = await request.get(`${MAILPIT_BASE_URL}/api/v1/message/${messageId}`);
return response.json();
}
/**
* Find the invitation acceptance URL from a Mailpit email sent to the given address.
* Retries a few times to allow for email delivery delay.
*/
export async function getInvitationAcceptUrl(
request: APIRequestContext,
recipientEmail: string
): Promise<string> {
let searchResult: { messages: Array<{ ID: string }> } = { messages: [] };
// Retry up to 5 times with 500ms delay to allow for email delivery
for (let attempt = 0; attempt < 5; attempt++) {
searchResult = await searchEmails(
request,
`to:${encodeURIComponent(recipientEmail)} subject:"Organization Invitation"`
);
if (searchResult.messages.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, 500));
}
expect(searchResult.messages.length).toBeGreaterThan(0);
const message = await getMessage(request, searchResult.messages[0].ID);
const acceptUrlMatch = message.HTML.match(/href="([^"]*team-invitations[^"]*)"/);
expect(acceptUrlMatch).toBeTruthy();
return acceptUrlMatch![1].replace(/&amp;/g, '&');
}
/**
* Find the password reset URL from a Mailpit email sent to the given address.
* Retries a few times to allow for email delivery delay.
*/
export async function getPasswordResetUrl(
request: APIRequestContext,
recipientEmail: string
): Promise<string> {
let searchResult: { messages: Array<{ ID: string }> } = { messages: [] };
// Retry up to 5 times with 500ms delay to allow for email delivery
for (let attempt = 0; attempt < 5; attempt++) {
searchResult = await searchEmails(
request,
`to:${encodeURIComponent(recipientEmail)} subject:"Reset Password"`
);
if (searchResult.messages.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, 500));
}
expect(searchResult.messages.length).toBeGreaterThan(0);
const message = await getMessage(request, searchResult.messages[0].ID);
const resetUrlMatch = message.HTML.match(/href="([^"]*reset-password[^"]*)"/);
expect(resetUrlMatch).toBeTruthy();
return resetUrlMatch![1].replace(/&amp;/g, '&');
}

243
e2e/utils/members.ts Normal file
View File

@@ -0,0 +1,243 @@
import { expect } from '@playwright/test';
import type { Browser, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
import { getInvitationAcceptUrl } from './mailpit';
import type { TestContext } from './api';
/**
* Register a new user in a fresh browser context and return the page + context.
*/
export async function registerUser(
browser: Browser,
name: string,
email: string
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
await page.getByLabel('Name').fill(name);
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill('amazingpassword123');
await page.getByLabel('Confirm Password').fill('amazingpassword123');
await page.getByLabel('I agree to the Terms of').click();
await page.getByRole('button', { name: 'Register' }).click();
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/dashboard');
return { page, close: () => context.close() };
}
/**
* Invite a user by email from the members page and accept the invitation
* through a second browser session, returning the accepted member to the
* members table as a real (non-placeholder) member.
*
* @param ownerPage The page of the organization owner who sends the invite
* @param browser Browser instance used to create a second context
* @param memberName Display name for the new user
* @param memberEmail Email address (must not be registered yet)
* @param role Role button label: 'Employee' | 'Manager' | 'Administrator'
*/
export async function inviteAndAcceptMember(
ownerPage: Page,
browser: Browser,
memberName: string,
memberEmail: string,
role: 'Employee' | 'Manager' | 'Administrator'
): Promise<void> {
// 1. Register the second user
const secondUser = await registerUser(browser, memberName, memberEmail);
// 2. Send invitation from the owner
await ownerPage.goto(PLAYWRIGHT_BASE_URL + '/members');
await ownerPage.getByRole('button', { name: 'Invite Member' }).click();
await expect(ownerPage.getByPlaceholder('Member Email')).toBeVisible();
await ownerPage.getByLabel('Email').fill(memberEmail);
await ownerPage.getByRole('button', { name: role }).click();
await Promise.all([
ownerPage.getByRole('button', { name: 'Invite Member', exact: true }).click(),
expect(ownerPage.getByRole('main')).toContainText(memberEmail),
]);
// 3. Retrieve the acceptance link from Mailpit and accept
const acceptUrl = await getInvitationAcceptUrl(secondUser.page.request, memberEmail);
await secondUser.page.goto(acceptUrl);
await secondUser.page.waitForURL(/dashboard/);
// 4. Clean up
await secondUser.close();
}
/**
* Set up an admin member in the owner's organization.
* Returns the admin's page, their member ID, and a cleanup function.
*/
export async function setupAdminUser(
ownerPage: Page,
ownerCtx: TestContext,
browser: Browser
): Promise<{
adminPage: Page;
adminMemberId: string;
closeAdmin: () => Promise<void>;
}> {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `admin+${memberId}@admin-perms.test`;
const memberName = 'Admin ' + memberId;
const admin = await registerUser(browser, memberName, memberEmail);
await ownerPage.goto(PLAYWRIGHT_BASE_URL + '/members');
await ownerPage.getByRole('button', { name: 'Invite Member' }).click();
await expect(ownerPage.getByPlaceholder('Member Email')).toBeVisible();
await ownerPage.getByPlaceholder('Member Email').fill(memberEmail);
await ownerPage.getByRole('button', { name: 'Administrator' }).click();
await Promise.all([
ownerPage.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
ownerPage.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
const acceptUrl = await getInvitationAcceptUrl(admin.page.request, memberEmail);
await admin.page.goto(acceptUrl);
await admin.page.waitForURL(/dashboard/);
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(admin.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
const orgSwitcherText = await admin.page
.getByTestId('organization_switcher')
.first()
.textContent();
if (!orgSwitcherText?.includes("John's Organization")) {
const cookies = await admin.page.context().cookies();
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
await admin.page.request.put(`${PLAYWRIGHT_BASE_URL}/current-team`, {
headers: {
'X-XSRF-TOKEN': xsrfToken,
Accept: 'text/html',
},
data: { team_id: ownerCtx.orgId },
});
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(admin.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
}
const membersResponse = await ownerCtx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ownerCtx.orgId}/members`
);
expect(membersResponse.status()).toBe(200);
const membersBody = await membersResponse.json();
const adminMember = membersBody.data.find(
(m: { role: string; name: string }) => m.role === 'admin' && m.name === memberName
);
expect(adminMember).toBeTruthy();
return {
adminPage: admin.page,
adminMemberId: adminMember.id,
closeAdmin: admin.close,
};
}
/**
* Set up an employee member in the owner's organization.
* Returns the employee's page, their member ID, and a cleanup function.
*
* The owner page (from the fixture) is used to invite the employee.
* Test data should be created via the owner's ctx.
*
* IMPORTANT: Projects must be created with is_public: true for the employee to see them,
* or the employee must be added as a project member via createProjectMemberViaApi.
* Clients are only visible to employees if they have at least one visible project.
* Tags are visible to all org members with tags:view permission.
*/
export async function setupEmployeeUser(
ownerPage: Page,
ownerCtx: TestContext,
browser: Browser
): Promise<{
employeePage: Page;
employeeMemberId: string;
closeEmployee: () => Promise<void>;
}> {
const memberId = Math.floor(Math.random() * 100000);
const memberEmail = `employee+${memberId}@emp-perms.test`;
const memberName = 'Emp ' + memberId;
// Register the employee user first
const employee = await registerUser(browser, memberName, memberEmail);
// Send invitation from the owner
await ownerPage.goto(PLAYWRIGHT_BASE_URL + '/members');
await ownerPage.getByRole('button', { name: 'Invite Member' }).click();
await expect(ownerPage.getByPlaceholder('Member Email')).toBeVisible();
await ownerPage.getByPlaceholder('Member Email').fill(memberEmail);
await ownerPage.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
ownerPage.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
ownerPage.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
// Accept the invitation
const acceptUrl = await getInvitationAcceptUrl(employee.page.request, memberEmail);
await employee.page.goto(acceptUrl);
await employee.page.waitForURL(/dashboard/);
// Navigate to dashboard explicitly and wait for it to load to ensure the correct org context.
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
// Verify we're on the correct organization (John's Organization).
const orgSwitcherText = await employee.page
.getByTestId('organization_switcher')
.first()
.textContent();
if (!orgSwitcherText?.includes("John's Organization")) {
// Switch to the owner's org using the PUT /current-team endpoint
const cookies = await employee.page.context().cookies();
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
await employee.page.request.put(`${PLAYWRIGHT_BASE_URL}/current-team`, {
headers: {
'X-XSRF-TOKEN': xsrfToken,
Accept: 'text/html',
},
data: { team_id: ownerCtx.orgId },
});
// Reload to pick up the new org
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
}
// Find the employee's member ID in the owner's organization
const membersResponse = await ownerCtx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ownerCtx.orgId}/members`
);
expect(membersResponse.status()).toBe(200);
const membersBody = await membersResponse.json();
const employeeMember = membersBody.data.find(
(m: { role: string; name: string }) => m.role === 'employee' && m.name === memberName
);
expect(employeeMember).toBeTruthy();
return {
employeePage: employee.page,
employeeMemberId: employeeMember.id,
closeEmployee: employee.close,
};
}

View File

@@ -1,6 +1,6 @@
import { formatCents } from '../../resources/js/packages/ui/src/utils/money';
import type { CurrencyFormat } from '../../resources/js/packages/ui/src/utils/money';
import { NumberFormat } from '../../resources/js/packages/ui/src/utils/number';
import type { NumberFormat } from '../../resources/js/packages/ui/src/utils/number';
export function formatCentsWithOrganizationDefaults(
cents: number,

320
e2e/utils/reporting.ts Normal file
View File

@@ -0,0 +1,320 @@
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
// ──────────────────────────────────────────────────
// Navigation
// ──────────────────────────────────────────────────
export async function goToReporting(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
}
export async function goToReportingDetailed(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed');
}
// ──────────────────────────────────────────────────
// Entity creation
// ──────────────────────────────────────────────────
export async function createProject(page: Page, projectName: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible();
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project name').fill(projectName);
await Promise.all([
page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201
),
]);
await expect(page.getByText(projectName)).toBeVisible();
}
export async function createBillableProject(page: Page, projectName: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible();
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project name').fill(projectName);
await page.getByText('Non-Billable').click();
await page.getByText('Default Rate').click();
await Promise.all([
page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201
),
]);
await expect(page.getByText(projectName)).toBeVisible();
}
export async function createClient(page: Page, clientName: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
await expect(page.getByRole('button', { name: 'Create Client' })).toBeVisible();
await page.getByRole('button', { name: 'Create Client' }).click();
await page.getByPlaceholder('Client Name').fill(clientName);
await Promise.all([
page.getByRole('button', { name: 'Create Client' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/clients') &&
response.request().method() === 'POST' &&
response.status() === 201
),
]);
await expect(page.getByText(clientName)).toBeVisible();
}
export async function createProjectWithClient(page: Page, projectName: string, clientName: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(page.getByRole('button', { name: 'Create Project' })).toBeVisible();
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project name').fill(projectName);
// Select client in the project create modal
await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click();
await page.getByRole('option', { name: clientName }).click();
await Promise.all([
page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201
),
]);
await expect(page.getByText(projectName)).toBeVisible();
}
export async function createTask(page: Page, projectName: string, taskName: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(page.getByText(projectName)).toBeVisible();
await page.getByText(projectName).click();
await page.getByRole('button', { name: 'Create Task' }).click();
await page.getByPlaceholder('Task Name').fill(taskName);
await Promise.all([
page.getByRole('button', { name: 'Create Task' }).click(),
page.waitForResponse(
(response) =>
response.url().includes('/tasks') &&
response.request().method() === 'POST' &&
response.status() === 201
),
]);
await expect(page.getByText(taskName)).toBeVisible();
}
// ──────────────────────────────────────────────────
// Time entry creation
// ──────────────────────────────────────────────────
export async function createTimeEntryWithProject(
page: Page,
projectName: string,
duration: string
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByRole('option').filter({ hasText: projectName }).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]);
}
export async function createTimeEntryWithProjectAndTask(
page: Page,
projectName: string,
taskName: string,
duration: string
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Entry for ${projectName} - ${taskName}`);
// Open the project/task dropdown
await page.getByRole('button', { name: 'No Project' }).click();
// Expand the project's tasks by clicking the "Tasks" button
const projectOption = page.getByRole('option').filter({ hasText: projectName });
await projectOption.getByText(/Tasks/).click();
// Select the task (this also selects the project and closes the dropdown)
await page.getByText(taskName, { exact: true }).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]);
}
export async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Entry with tag ${tagName}`);
// Add tag
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await Promise.all([
page.getByRole('button', { name: 'Create Tag' }).click(),
page.waitForResponse(
(response) => response.url().includes('/tags') && response.status() === 201
),
]);
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]);
}
export async function createBareTimeEntry(page: Page, description: string, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page.getByRole('dialog').getByRole('textbox', { name: 'Description' }).fill(description);
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]);
}
export async function createTimeEntryWithBillableStatus(
page: Page,
isBillable: boolean,
duration: string
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.getByRole('button', { name: 'Time entry actions' })).toBeVisible();
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
if (isBillable) {
await page
.getByRole('dialog')
.getByRole('combobox')
.filter({ hasText: 'Non-Billable' })
.click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
}
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]);
}
// ──────────────────────────────────────────────────
// Wait helpers
// ──────────────────────────────────────────────────
export async function waitForReportingUpdate(page: Page) {
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
export async function waitForDetailedReportingUpdate(page: Page) {
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
!response.url().includes('/aggregate') &&
response.request().method() === 'GET' &&
response.status() === 200
);
}
// ──────────────────────────────────────────────────
// Shared report helpers
// ──────────────────────────────────────────────────
export async function goToReportingShared(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
}
export async function saveAsSharedReport(
page: Page,
reportName: string
): Promise<{ shareableLink: string }> {
await page.getByRole('button', { name: 'Save Report' }).click();
await page.getByLabel('Name').fill(reportName);
// "Public" checkbox is checked by default
const [response] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/reports') &&
response.request().method() === 'POST' &&
response.status() === 201
),
page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(),
]);
const responseBody = await response.json();
// Wait for navigation to shared reports page
await page.waitForURL('**/reporting/shared');
return { shareableLink: responseBody.data.shareable_link };
}

16
e2e/utils/table.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { Locator } from '@playwright/test';
/**
* Extract the first cell's text content from each row in a table.
* Useful for reading the ordered names/labels from a sorted table.
*/
export async function getTableRowNames(table: Locator): Promise<string[]> {
const rows = table.getByRole('row');
const count = await rows.count();
const names: string[] = [];
for (let i = 0; i < count; i++) {
const text = await rows.nth(i).locator('div').first().textContent();
if (text) names.push(text.trim());
}
return names;
}

View File

@@ -1,4 +1,4 @@
import { Page } from '@playwright/test';
import type { Page } from '@playwright/test';
export function newTagResponse(page: Page, { name = '' } = {}) {
return page.waitForResponse(async (response) => {

4302
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,11 @@
{
"name": "solidtime",
"private": true,
"type": "module",
"workspaces": [
"resources/js/packages/ui",
"resources/js/packages/api"
],
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -15,36 +20,32 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@inertiajs/vue3": "^1.0.0",
"@inertiajs/vue3": "^2.0.0",
"@playwright/test": "^1.41.1",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@types/chroma-js": "2.4.5",
"@types/chroma-js": "^3.1.0",
"@types/node": "^22.10.10",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.5.1",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/tsconfig": "^0.8.0",
"autoprefixer": "^10.4.20",
"axios": "^1.6.4",
"eslint-plugin-unused-imports": "^4.1.4",
"laravel-vite-plugin": "^1.0.0",
"laravel-vite-plugin": "^2.1.0",
"openapi-zod-client": "^1.16.2",
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-nesting": "^12.1.5",
"tailwindcss": "^3.4.13",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vite-plugin-checker": "^0.8.0",
"vite": "^7.0.0",
"vite-plugin-checker": "^0.12.0",
"vue": "^3.5.0",
"vue-tsc": "^2.2.0"
"vue-tsc": "^3.0.0"
},
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/vue": "^1.0.6",
"@fullcalendar/core": "^6.1.18",
"@fullcalendar/daygrid": "^6.1.18",
"@fullcalendar/interaction": "^6.1.18",
"@fullcalendar/timegrid": "^6.1.18",
"@fullcalendar/vue3": "^6.1.18",
"@heroicons/vue": "^2.1.1",
"@rushstack/eslint-patch": "^1.10.5",
"@tailwindcss/container-queries": "^0.1.1",
@@ -54,22 +55,24 @@
"@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^12.8.2",
"@vueuse/integrations": "^12.5.0",
"@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.0.0",
"@zodios/core": "^10.9.6",
"chroma-js": "3.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"echarts": "^5.5.0",
"focus-trap": "^7.6.0",
"echarts": "^6.0.0",
"focus-trap": "^8.0.0",
"lucide-vue-next": "^0.487.0",
"parse-duration": "^2.0.1",
"pinia": "^2.1.7",
"pinia": "^3.0.0",
"radix-vue": "^1.9.6",
"reka-ui": "^2.2.0",
"reka-ui": "^2.8.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vue-echarts": "^7.0.3"
"vue-echarts": "^8.0.0",
"zod": "^3.23.8"
},
"overrides": {
"vite-plugin-checker": {

View File

@@ -17,10 +17,10 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 1 : 0,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Run tests in parallel */
workers: process.env.CI ? 2 : 4,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI ? 'line' : 'html',
reporter: process.env.CI ? 'blob' : 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
@@ -39,35 +39,15 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
// Firefox only in CI to keep local runs fast
...(process.env.CI
? [
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
]
: []),
],
/* Run your local dev server before starting the tests */

View File

@@ -1 +1,3 @@
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';
export const TEST_USER_PASSWORD = 'amazingpassword123';

View File

@@ -1,27 +1,114 @@
import { test as baseTest } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from './config';
import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config';
import { type TestContext, setupTestContext } from '../e2e/utils/api';
import { setupAdminUser, setupEmployeeUser } from '../e2e/utils/members';
export * from '@playwright/test';
export const test = baseTest.extend<object, { workerStorageState: string }>({
// Use the same storage state for all tests in this worker.
export type { TestContext };
export interface EmployeeFixture {
page: Page;
memberId: string;
}
export interface AdminFixture {
page: Page;
memberId: string;
}
/**
* API-based authentication fixture - creates a new user via HTTP requests instead of UI interactions.
* This is ~10-25x faster than UI-based authentication (~100-200ms vs ~3-5s).
*
* Uses page.context().request() to ensure cookies are shared between the API request and page.
*/
export const test = baseTest.extend<
{ ctx: TestContext; employee: EmployeeFixture; admin: AdminFixture },
{ workerStorageState: string }
>({
page: async ({ page }, use) => {
// Perform authentication steps. Replace these actions with your own.
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
await page.getByLabel('Name').fill('John Doe');
await page.getByLabel('Email').fill(`john+${Math.round(Math.random() * 1000000)}@doe.com`);
await page.getByLabel('Password', { exact: true }).fill('amazingpassword123');
await page.getByLabel('Confirm Password').fill('amazingpassword123');
await page.getByLabel('I agree to the Terms of').click();
await page.getByRole('button', { name: 'Register' }).click();
// Generate unique email for this test
const email = `john+${Date.now()}_${Math.floor(Math.random() * 10000)}@doe.com`;
const password = TEST_USER_PASSWORD;
const name = 'John Doe';
// Wait until the page receives the cookies.
//
// Sometimes login flow sets cookies in the process of several redirects.
// Wait for the final URL to ensure that the cookies are actually set.
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/dashboard');
// Use page.context().request() so cookies are automatically shared with the page
const request = page.context().request;
// End of authentication steps.
// Step 1: Visit the register page to get CSRF token and initial session
const csrfResponse = await request.get(`${PLAYWRIGHT_BASE_URL}/register`, {
maxRedirects: 0,
});
// Extract XSRF-TOKEN from cookies
const cookies = csrfResponse.headers()['set-cookie'];
let xsrfToken = '';
if (cookies) {
const xsrfMatch = cookies.match(/XSRF-TOKEN=([^;]+)/);
if (xsrfMatch) {
xsrfToken = decodeURIComponent(xsrfMatch[1]);
}
}
// Step 2: Register via API (Laravel Fortify web routes)
const registerResponse = await request.post(`${PLAYWRIGHT_BASE_URL}/register`, {
headers: {
'X-XSRF-TOKEN': xsrfToken,
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'text/html',
},
form: {
name,
email,
password,
password_confirmation: password,
terms: 'on',
},
maxRedirects: 0,
});
// Check if registration was successful (should redirect to dashboard)
if (registerResponse.status() !== 302) {
console.error('API registration failed, falling back to UI-based registration');
// Fall back to UI-based registration
await page.goto(`${PLAYWRIGHT_BASE_URL}/register`);
await page.getByLabel('Name').fill(name);
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
await page.getByLabel('Confirm Password').fill(password);
await page.getByLabel('I agree to the Terms of').click();
await page.getByRole('button', { name: 'Register' }).click();
await page.waitForURL(`${PLAYWRIGHT_BASE_URL}/dashboard`);
} else {
// Registration succeeded - cookies are already set in the context from the request
// Just navigate to dashboard to verify
await page.goto(`${PLAYWRIGHT_BASE_URL}/dashboard`);
await page.waitForLoadState('domcontentloaded');
}
await use(page);
},
ctx: async ({ page }, use) => {
const ctx = await setupTestContext(page);
await use(ctx);
},
employee: async ({ page, ctx, browser }, use) => {
const { employeePage, employeeMemberId, closeEmployee } = await setupEmployeeUser(
page,
ctx,
browser
);
await use({ page: employeePage, memberId: employeeMemberId });
await closeEmployee();
},
admin: async ({ page, ctx, browser }, use) => {
const { adminPage, adminMemberId, closeAdmin } = await setupAdminUser(page, ctx, browser);
await use({ page: adminPage, memberId: adminMemberId });
await closeAdmin();
},
});

View File

@@ -1,240 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root.dark {
--color-bg-primary: #101012;
--color-bg-secondary: #17181B;
--color-bg-tertiary: #2A2C32;
--color-bg-quaternary: #141518;
--color-bg-background: #090909;
--color-text-primary: #ffffff;
--color-text-secondary: #e3e4e6;
--color-text-tertiary: #969799;
--color-text-quaternary: #595a5c;
/* Import shared solidtime styles from UI package */
@import '../js/packages/ui/styles.css';
--color-border-primary: #191b1f;
--color-border-secondary: #23252a;
--color-border-tertiary: #2c2e33;
--color-border-quaternary: #393B42;
--color-input-border-active: rgba(255,255,255,0.3);
--theme-color-chart: var(--color-accent-200);
--theme-color-menu-active: var(--color-bg-secondary);
--theme-color-card-background: var(--color-bg-secondary);
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-row-background: var(--color-bg-primary);
--theme-color-row-heading-background: var(--theme-color-card-background);
--theme-color-row-heading-border: var(--theme-color-card-border);
--theme-color-icon-default: var(--color-text-tertiary);
--theme-color-ring: rgba(255,255,255,0.5);
--theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);
--theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-text: var(--color-text-primary);
--theme-color-input-background: var(--color-bg-secondary);
--theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
--color-accent-default: rgba(var(--color-accent-300), 0.2);
--color-accent-foreground: rgb(var(--color-accent-100));
--theme-color-default-background: var(--color-bg-primary);
}
:root.light {
--color-bg-primary: #FFFFFF;
--color-bg-secondary: #f7f7f8;
--color-bg-tertiary: #eeeeef;
--color-bg-quaternary: #e1e1e3;
--color-bg-background: #F5F5F5;
--color-text-primary: #18181b;
--color-text-secondary: #3f3f46;
--color-text-tertiary: #57575C;
--color-text-quaternary: #a1a1aa;
--color-border-primary: #e7e7e7;
--color-border-secondary: #e5e5e5;
--color-border-tertiary: #dfdfdf;
--color-border-quaternary: #d1d1d1;
--color-input-border-active: rgba(0,0,0,0.3);
--theme-color-menu-active: var(--color-bg-quaternary);
--theme-color-card-background: var(--color-bg-primary);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-chart: var(--color-accent-400);
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--theme-color-row-background: var(--theme-color-card-background);
--theme-color-row-heading-background: var(--color-bg-secondary);
--theme-color-row-heading-border: var(--color-border-tertiary);
--theme-color-icon-default: var(--color-text-quaternary);
--theme-color-ring: rgba(0,0,0, 0.7);
--theme-color-button-primary-background: rgba(var(--color-accent-600), 0.9);
--theme-color-button-primary-background-hover: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-text: #FFFFFF;
--theme-color-input-background: var(--color-bg-primary);
--theme-color-input-select-active: rgb(var(--color-accent-400));
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
--color-accent-default: rgb(var(--color-accent-100));
--color-accent-foreground: rgb(var(--color-accent-800));
--theme-color-default-background: #FCFCFC;
}
:root {
--theme-color-icon-active: rgb(var(--color-text-tertiary));
--theme-color-card-background-separator: var(--color-border-tertiary);
--theme-color-card-border: var(--color-border-secondary);
--theme-color-card-border-active: var(--color-border-tertiary);
--theme-color-default-background-separator: var(--color-border-primary);
--theme-color-primary-text: var(--color-text-primary);
--theme-color-input-border: var(--color-border-quaternary);
--theme-color-tab-background: var(--theme-color-card-background);
--theme-color-tab-background-active: var(--theme-color-card-background-active);
--theme-color-tab-border: var(--theme-color-card-border);
--theme-color-row-separator-background: var(--theme-color-default-background-separator);
--theme-color-row-border: var(--theme-color-card-border);
--color-accent-50: 240, 249, 255; /* sky-50 */
--color-accent-100: 224, 242, 254; /* sky-100 */
--color-accent-200: 186, 230, 253; /* sky-200 */
--color-accent-300: 125, 211, 252; /* sky-300 */
--color-accent-400: 56, 189, 248; /* sky-400 */
--color-accent-500: 14, 165, 233; /* sky-500 */
--color-accent-600: 2, 132, 199; /* sky-600 */
--color-accent-700: 3, 105, 161; /* sky-700 */
--color-accent-800: 7, 89, 133; /* sky-800 */
--color-accent-900: 12, 74, 110; /* sky-900 */
--color-accent-950: 8, 47, 73; /* sky-950 */
--theme-button-secondary-background: var(--theme-color-card-background);
--theme-button-secondary-background-active: var(--theme-color-card-background-active);
--popover-border: var(--color-border-secondary);
}
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* width */
::-webkit-scrollbar {
width: 5px;
}
/* Track */
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner {
background: transparent;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 2px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}
[x-cloak] {
display: none;
}
body {
background-color: var(--theme-color-default-background);
}
/* Inter Variable Font with browser compatibility considerations */
/* Main app specific styles - Inter font */
@font-face {
font-family: 'Inter';
src: url('/fonts/InterVariable.woff2') format('woff2'),
url('/fonts/InterVariable.ttf') format('truetype');
src:
url('/fonts/InterVariable.woff2') format('woff2'),
url('/fonts/InterVariable.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
@layer base {
:root {
--background: var(--color-bg-background);
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--theme-color-button-primary-background);
--primary-foreground: var(--theme-color-button-primary-text);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
--muted-foreground: var(--color-text-tertiary);
--accent: var(--theme-color-button-primary-background);
--accent-foreground: var(--theme-color-button-primary-text);
--destructive: 0 84.2% 60.2%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--color-border-tertiary);
--ring: var(--theme-color-ring);
--chart-1: var(--color-accent-400);
--chart-2: var(--color-accent-500);
--chart-3: var(--color-accent-600);
--chart-4: var(--color-accent-700);
--chart-5: var(--color-accent-800);
--radius: 0.5rem;
}
.dark {
--background: var(--color-bg-background);
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--theme-color-button-primary-background);
--primary-foreground: var(--theme-color-button-primary-text);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
--muted-foreground: var(--color-text-tertiary);
--accent: var(--theme-color-button-primary-background);
--accent-foreground: var(--theme-color-button-primary-text);
--destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--color-border-tertiary);
--ring: var(--theme-color-ring);
--chart-1: var(--color-accent-200);
--chart-2: var(--color-accent-300);
--chart-3: var(--color-accent-400);
--chart-4: var(--color-accent-500);
--chart-5: var(--color-accent-600);
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,276 @@
<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue';
import { router, usePage } from '@inertiajs/vue3';
import { CommandPalette } from '@/packages/ui/src/CommandPalette';
import { useCommandPalette } from '@/utils/useCommandPalette';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { useTagsStore } from '@/utils/useTags';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { canCreateProjects } from '@/utils/permissions';
import type {
CreateClientBody,
CreateProjectBody,
CreateTimeEntryBody,
Project,
Client,
Tag,
} from '@/packages/api/src';
import type { User } from '@/types/models';
import type { Role } from '@/types/jetstream';
// Import modals
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
// Import dropdowns for active timer selectors
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
// Dialog components for selectors
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const {
isOpen,
searchTerm,
groups,
entityResults,
togglePalette,
showCreateProjectModal,
showCreateClientModal,
showCreateTaskModal,
showCreateTagModal,
showInviteMemberModal,
showCreateTimeEntryModal,
showProjectSelector,
showTaskSelector,
showTagsSelector,
currentTimeEntry,
updateTimer,
projects,
clients,
tasks,
tags,
} = useCommandPalette();
// Stores for creating entities
const projectsStore = useProjectsStore();
const clientsStore = useClientsStore();
const tagsStore = useTagsStore();
// Time entry mutations
const { createTimeEntry: createTimeEntryMutation } = useTimeEntriesMutations();
// Get available roles from page props (for member invite modal)
const page = usePage<{
availableRoles?: Role[];
auth: {
user: User;
};
}>();
const availableRoles = computed(() => page.props.availableRoles ?? []);
// Active clients for dropdowns
const activeClients = computed(() => clients.value.filter((c) => !c.is_archived));
// Keyboard shortcut handler
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
togglePalette();
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown);
});
// Project creation
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
const openedFromCommandPalette = showCreateProjectModal.value;
const newProject = await projectsStore.createProject(project);
showCreateProjectModal.value = false;
if (newProject && openedFromCommandPalette) {
router.visit(route('projects.show', { project: newProject.id }));
}
return newProject;
}
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
const openedFromCommandPalette = showCreateClientModal.value;
const newClient = await clientsStore.createClient(client);
if (newClient && openedFromCommandPalette) {
showCreateClientModal.value = false;
router.visit(route('clients'));
}
return newClient;
}
async function createTag(name: string): Promise<Tag | undefined> {
const openedFromCommandPalette = showCreateTagModal.value;
const newTag = await tagsStore.createTag(name);
if (newTag && openedFromCommandPalette) {
showCreateTagModal.value = false;
router.visit(route('tags'));
}
return newTag;
}
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
await createTimeEntryMutation(timeEntry);
showCreateTimeEntryModal.value = false;
}
async function handleProjectTaskSelect() {
showProjectSelector.value = false;
showTaskSelector.value = false;
await updateTimer();
}
async function handleTagsSelect() {
showTagsSelector.value = false;
await updateTimer();
}
const firstProjectId = computed(() => projects.value[0]?.id ?? '');
</script>
<template>
<!-- Command Palette Dialog -->
<CommandPalette
v-model:open="isOpen"
v-model:search-term="searchTerm"
:groups="groups"
:entity-results="entityResults" />
<!-- Project Create Modal -->
<ProjectCreateModal
v-model:show="showCreateProjectModal"
:create-project="createProject"
:create-client="createClient"
:clients="activeClients"
:currency="getOrganizationCurrencyString()"
:organization-billable-rate="organization?.billable_rate ?? null"
:enable-estimated-time="isAllowedToPerformPremiumAction()" />
<!-- Client Create Modal -->
<ClientCreateModal v-model:show="showCreateClientModal" />
<!-- Task Create Modal -->
<TaskCreateModal
v-if="firstProjectId"
v-model:show="showCreateTaskModal"
:project-id="firstProjectId" />
<!-- Tag Create Modal -->
<TagCreateModal v-model:show="showCreateTagModal" :create-tag="createTag" />
<!-- Member Invite Modal -->
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />
<!-- Time Entry Create Modal -->
<TimeEntryCreateModal
v-model:show="showCreateTimeEntryModal"
:create-time-entry="createTimeEntry"
:create-project="createProject"
:create-client="createClient"
:create-tag="createTag"
:projects="projects"
:tasks="tasks"
:tags="tags"
:clients="activeClients"
:currency="getOrganizationCurrencyString()"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:can-create-project="canCreateProjects()"
:organization-billable-rate="organization?.billable_rate ?? null" />
<!-- Project Selector Dialog for Active Timer -->
<DialogModal :show="showProjectSelector" closeable @close="showProjectSelector = false">
<template #title>Set Project</template>
<template #content>
<TimeTrackerProjectTaskDropdown
v-model:project="currentTimeEntry.project_id"
v-model:task="currentTimeEntry.task_id"
variant="outline"
:projects="projects"
:tasks="tasks"
:clients="activeClients"
:create-project="createProject"
:create-client="createClient"
:can-create-project="canCreateProjects()"
:currency="getOrganizationCurrencyString()"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:organization-billable-rate="organization?.billable_rate ?? null"
class="w-full" />
</template>
<template #footer>
<SecondaryButton @click="showProjectSelector = false"> Cancel </SecondaryButton>
<SecondaryButton class="ms-3" @click="handleProjectTaskSelect"> Save </SecondaryButton>
</template>
</DialogModal>
<!-- Task Selector Dialog for Active Timer -->
<DialogModal :show="showTaskSelector" closeable @close="showTaskSelector = false">
<template #title>Set Task</template>
<template #content>
<TimeTrackerProjectTaskDropdown
v-model:project="currentTimeEntry.project_id"
v-model:task="currentTimeEntry.task_id"
variant="outline"
:projects="projects"
:tasks="tasks"
:clients="activeClients"
:create-project="createProject"
:create-client="createClient"
:can-create-project="canCreateProjects()"
:currency="getOrganizationCurrencyString()"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:organization-billable-rate="organization?.billable_rate ?? null"
class="w-full" />
</template>
<template #footer>
<SecondaryButton @click="showTaskSelector = false"> Cancel </SecondaryButton>
<SecondaryButton class="ms-3" @click="handleProjectTaskSelect"> Save </SecondaryButton>
</template>
</DialogModal>
<!-- Tags Selector Dialog for Active Timer -->
<DialogModal :show="showTagsSelector" closeable @close="showTagsSelector = false">
<template #title>Set Tags</template>
<template #content>
<TagDropdown v-model="currentTimeEntry.tags" :tags="tags" :create-tag="createTag">
<template #trigger>
<div
class="w-full p-3 border border-card-border rounded-lg cursor-pointer hover:bg-tertiary transition">
<span
v-if="currentTimeEntry.tags.length === 0"
class="text-muted-foreground">
Click to select tags...
</span>
<span v-else> {{ currentTimeEntry.tags.length }} tag(s) selected </span>
</div>
</template>
</TagDropdown>
</template>
<template #footer>
<SecondaryButton @click="showTagsSelector = false"> Cancel </SecondaryButton>
<SecondaryButton class="ms-3" @click="handleTagsSelect"> Save </SecondaryButton>
</template>
</DialogModal>
</template>

View File

@@ -0,0 +1 @@
export { default as CommandPaletteProvider } from './CommandPaletteProvider.vue';

View File

@@ -7,7 +7,7 @@ import type { CreateClientBody } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useClientsStore } from '@/utils/useClients';
import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue';
import { Field, FieldLabel } from '@/packages/ui/src/field';
const { createClient } = useClientsStore();
const show = defineModel('show', { default: false });
@@ -37,19 +37,19 @@ useFocus(clientNameInput, { initialValue: true });
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1">
<InputLabel for="clientName" value="Client Name" />
<Field class="col-span-6 sm:col-span-4 flex-1">
<FieldLabel for="clientName">Client Name</FieldLabel>
<TextInput
id="clientName"
ref="clientNameInput"
v-model="client.name"
type="text"
placeholder="Client Name"
class="mt-1 block w-full"
class="block w-full"
required
autocomplete="clientName"
@keydown.enter="submit" />
</div>
</Field>
</div>
</template>
<template #footer>

Some files were not shown because too many files have changed in this diff Show More