mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 13:12:16 +01:00
Compare commits
155 Commits
feature/up
...
c2a8eac65f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2a8eac65f | ||
|
|
28ecfc63a3 | ||
|
|
433a6f3770 | ||
|
|
0ba20fd24c | ||
|
|
3267acb161 | ||
|
|
7d9ecd9526 | ||
|
|
3a17f80f99 | ||
|
|
e29ea2ea42 | ||
|
|
fb6e4639ce | ||
|
|
69bc41988a | ||
|
|
f7663b1c8b | ||
|
|
793bd11dcf | ||
|
|
77a62afd69 | ||
|
|
b73aa543fd | ||
|
|
2d6f9e514f | ||
|
|
f8e668790b | ||
|
|
77a5e979c6 | ||
|
|
353a579850 | ||
|
|
bd44a2b376 | ||
|
|
277dbaf6eb | ||
|
|
1cf33ddb3f | ||
|
|
84cd0d572d | ||
|
|
f37b86f377 | ||
|
|
1e7364fc4b | ||
|
|
8cbc9838c9 | ||
|
|
71c8992e31 | ||
|
|
53d91b65d6 | ||
|
|
0c88a10eb5 | ||
|
|
dd7b23958a | ||
|
|
1eb066f5aa | ||
|
|
b1287c6a0a | ||
|
|
815abb5980 | ||
|
|
e2f859be27 | ||
|
|
3d26fcaefe | ||
|
|
1e73a90f9d | ||
|
|
0f8f906e5c | ||
|
|
797fddf638 | ||
|
|
d07294ae7c | ||
|
|
1f49940805 | ||
|
|
6be6a48e0d | ||
|
|
b94a04dca0 | ||
|
|
bd3b8f265f | ||
|
|
c19a0f9acc | ||
|
|
5c6d84dc38 | ||
|
|
5c67709746 | ||
|
|
a2b0828c54 | ||
|
|
b94872b07b | ||
|
|
12bbbf64e9 | ||
|
|
c07ac4b0e4 | ||
|
|
a58566d002 | ||
|
|
57ed6036e6 | ||
|
|
ef7569b63b | ||
|
|
19c789b78e | ||
|
|
49548037b3 | ||
|
|
97df779d1e | ||
|
|
a1d5563fc4 | ||
|
|
c94ca804f8 | ||
|
|
189682cfaf | ||
|
|
8d16503541 | ||
|
|
e43ce477b8 | ||
|
|
5646aedb25 | ||
|
|
2b46e568e0 | ||
|
|
89a4a1962a | ||
|
|
c581ad8854 | ||
|
|
bce6cb9395 | ||
|
|
1cdae98ed9 | ||
|
|
02f6436fd0 | ||
|
|
452acca942 | ||
|
|
192c8c3b88 | ||
|
|
6218ffceb5 | ||
|
|
ba32be0543 | ||
|
|
bd817db06f | ||
|
|
97f4bce676 | ||
|
|
6962b668fb | ||
|
|
be8091296c | ||
|
|
84c4750c9b | ||
|
|
f582adab0d | ||
|
|
c60cff04ce | ||
|
|
cae41e4b4f | ||
|
|
8973be9dab | ||
|
|
2a0b8d31e6 | ||
|
|
d2f3fe411a | ||
|
|
f880f9f730 | ||
|
|
556bbedeca | ||
|
|
eed638d0aa | ||
|
|
864f41bda6 | ||
|
|
26524c5f40 | ||
|
|
cf98fabe0a | ||
|
|
88c0c334e9 | ||
|
|
0fc325363d | ||
|
|
1afc16573a | ||
|
|
147514a606 | ||
|
|
435522b502 | ||
|
|
f1d001e03e | ||
|
|
7f145cf1c2 | ||
|
|
b579ed1075 | ||
|
|
ed2b7476ae | ||
|
|
8107c6a208 | ||
|
|
6dc517e07d | ||
|
|
2c60d04ba4 | ||
|
|
2c222f3f67 | ||
|
|
c5c1a7af13 | ||
|
|
22cf7cf74d | ||
|
|
cfbfbd4b6a | ||
|
|
6629482a0e | ||
|
|
38457cae4d | ||
|
|
0e63ecb520 | ||
|
|
6f207a4926 | ||
|
|
052424a581 | ||
|
|
b258717211 | ||
|
|
685cc29282 | ||
|
|
c78c681ec4 | ||
|
|
2d9f33387e | ||
|
|
b68d68a2a2 | ||
|
|
a9e03f3b29 | ||
|
|
474b294a18 | ||
|
|
334a98016f | ||
|
|
8be55359ce | ||
|
|
e45662c715 | ||
|
|
f3217baed1 | ||
|
|
562ee234a8 | ||
|
|
15e61e9789 | ||
|
|
125f6f062f | ||
|
|
f75a19bccd | ||
|
|
c17d87b710 | ||
|
|
a154293348 | ||
|
|
9832c688fe | ||
|
|
6804eb098d | ||
|
|
531443f0df | ||
|
|
bd2d57dfd1 | ||
|
|
73c92fad47 | ||
|
|
537a023ab9 | ||
|
|
28fc324c6a | ||
|
|
9379c191be | ||
|
|
ff06d4d2f3 | ||
|
|
7efb7e6071 | ||
|
|
b2af9c6bf1 | ||
|
|
73b4d66386 | ||
|
|
cb7baef0ba | ||
|
|
dd75a80df7 | ||
|
|
bc562bf76f | ||
|
|
756b423295 | ||
|
|
3707f2469c | ||
|
|
c6c1434430 | ||
|
|
70b78e41c3 | ||
|
|
8c16302f17 | ||
|
|
bfc369794e | ||
|
|
3c2ea0e645 | ||
|
|
b0d28f2f6d | ||
|
|
6555bca5f1 | ||
|
|
81d9561656 | ||
|
|
0a6bde8bc6 | ||
|
|
51af3db305 | ||
|
|
f242ce48b5 | ||
|
|
19064cdc3d |
2
.env.ci
2
.env.ci
@@ -60,7 +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
|
||||
|
||||
@@ -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
|
||||
|
||||
4
.github/workflows/build-onpremise.yml
vendored
4
.github/workflows/build-onpremise.yml
vendored
@@ -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-*
|
||||
|
||||
10
.github/workflows/build-private.yml
vendored
10
.github/workflows/build-private.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/build-public.yml
vendored
6
.github/workflows/build-public.yml
vendored
@@ -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-*
|
||||
|
||||
2
.github/workflows/generate-api-docs.yml
vendored
2
.github/workflows/generate-api-docs.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/npm-build.yml
vendored
4
.github/workflows/npm-build.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
4
.github/workflows/npm-format-check.yml
vendored
4
.github/workflows/npm-format-check.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
4
.github/workflows/npm-lint.yml
vendored
4
.github/workflows/npm-lint.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
4
.github/workflows/npm-publish-api.yml
vendored
4
.github/workflows/npm-publish-api.yml
vendored
@@ -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'
|
||||
|
||||
4
.github/workflows/npm-publish-ui.yml
vendored
4
.github/workflows/npm-publish-ui.yml
vendored
@@ -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'
|
||||
|
||||
4
.github/workflows/npm-typecheck.yml
vendored
4
.github/workflows/npm-typecheck.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
2
.github/workflows/phpstan.yml
vendored
2
.github/workflows/phpstan.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/phpunit.yml
vendored
6
.github/workflows/phpunit.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/pint.yml
vendored
4
.github/workflows/pint.yml
vendored
@@ -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"
|
||||
|
||||
8
.github/workflows/playwright.yml
vendored
8
.github/workflows/playwright.yml
vendored
@@ -9,8 +9,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4]
|
||||
shardTotal: [4]
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shardTotal: [8]
|
||||
|
||||
services:
|
||||
mailpit:
|
||||
@@ -35,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'
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,3 +42,4 @@ yarn-error.log
|
||||
/data
|
||||
/config/caddy
|
||||
/config/composer
|
||||
/AGENTS.md
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# solidtime - The modern Open-Source Time Tracker
|
||||
# solidtime - The modern Open-Source TimeTracker
|
||||
|
||||
[](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
||||
[](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.
|
||||
|
||||
15
SECURITY.md
15
SECURITY.md
@@ -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)
|
||||
|
||||
@@ -5,9 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Mail\VerifyUpdatedEmailMail;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
@@ -24,6 +27,10 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
if (isset($input['email']) && is_string($input['email'])) {
|
||||
$input['email'] = Str::lower($input['email']);
|
||||
}
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => [
|
||||
'required',
|
||||
@@ -58,16 +65,17 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
$user->updateProfilePhoto($input['photo']);
|
||||
}
|
||||
|
||||
if ($input['email'] !== $user->email) {
|
||||
$email = Str::lower((string) $input['email']);
|
||||
|
||||
if ($email !== Str::lower($user->email)) {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
'pending_email' => $email,
|
||||
'timezone' => $input['timezone'],
|
||||
'week_start' => $input['week_start'],
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
Mail::to($email)->send(new VerifyUpdatedEmailMail($user, $email));
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
|
||||
@@ -4,18 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\In;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||
|
||||
class AddOrganizationMember implements AddsTeamMembers
|
||||
@@ -25,70 +16,6 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
*/
|
||||
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
|
||||
|
||||
$this->validate($organization, $email, $role);
|
||||
|
||||
$newOrganizationMember = User::query()
|
||||
->where('email', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->firstOrFail();
|
||||
|
||||
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the add member operation.
|
||||
*/
|
||||
protected function validate(Organization $organization, string $email, ?string $role): void
|
||||
{
|
||||
Validator::make([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
], $this->rules())->after(
|
||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||
)->validateWithBag('addTeamMember');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules for adding a team member.
|
||||
*
|
||||
* @return array<string, array<ValidationRule|Rule|string|In>>
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->where('is_placeholder', '=', false);
|
||||
})->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
Role::Admin->value,
|
||||
Role::Manager->value,
|
||||
Role::Employee->value,
|
||||
]),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the user is not already on the team.
|
||||
*/
|
||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
||||
{
|
||||
return function ($validator) use ($team, $email): void {
|
||||
$validator->errors()->addIf(
|
||||
$team->hasRealUserWithEmail($email),
|
||||
'email',
|
||||
__('This user already belongs to the team.')
|
||||
);
|
||||
};
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ class CreateOrganization implements CreatesTeams
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
*
|
||||
* @deprecated Use REST endpoint instead
|
||||
*/
|
||||
public function create(User $user, array $input): Organization
|
||||
{
|
||||
|
||||
@@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams
|
||||
{
|
||||
/**
|
||||
* Delete the given team.
|
||||
*
|
||||
* @deprecated Use REST endpoint instead
|
||||
*/
|
||||
public function delete(Organization $organization): void
|
||||
{
|
||||
|
||||
@@ -16,6 +16,8 @@ class DeleteUser implements DeletesUsers
|
||||
* Delete the given user.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*
|
||||
* @deprecated Use REST endpoint instead
|
||||
*/
|
||||
public function delete(User $user): void
|
||||
{
|
||||
|
||||
@@ -18,6 +18,8 @@ class ValidateOrganizationDeletion
|
||||
* @param Organization $organization Organization to be deleted
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @deprecated Use REST endpoint instead
|
||||
*/
|
||||
public function validate(User $user, Organization $organization): void
|
||||
{
|
||||
|
||||
28
app/Events/MemberAdded.php
Normal file
28
app/Events/MemberAdded.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class MemberAdded
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Member $member;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public User $user;
|
||||
|
||||
public function __construct(Member $member, Organization $organization, User $user)
|
||||
{
|
||||
$this->member = $member;
|
||||
$this->organization = $organization;
|
||||
$this->user = $user;
|
||||
}
|
||||
}
|
||||
28
app/Events/MemberAdding.php
Normal file
28
app/Events/MemberAdding.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class MemberAdding
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public User $user;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public Role $role;
|
||||
|
||||
public function __construct(User $user, Organization $organization, Role $role)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->organization = $organization;
|
||||
$this->role = $role;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'user_resend_email_verification_no_pending_email';
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class ApiTokenController extends Controller
|
||||
/**
|
||||
* List all api token of the currently authenticated user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getApiTokens
|
||||
*
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,11 +5,17 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\DeletionService;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\OrganizationService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class OrganizationController extends Controller
|
||||
{
|
||||
@@ -80,4 +86,48 @@ class OrganizationController extends Controller
|
||||
|
||||
return new OrganizationResource($organization, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization
|
||||
*
|
||||
* @operationId createOrganization
|
||||
*/
|
||||
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
|
||||
{
|
||||
$user = $this->user();
|
||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());
|
||||
|
||||
$currency = $ipLookupResponse?->currency;
|
||||
|
||||
$organization = $organizationService->createOrganization(
|
||||
$request->getName(),
|
||||
$user,
|
||||
false,
|
||||
$currency
|
||||
);
|
||||
|
||||
$user->switchTeam($organization);
|
||||
|
||||
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
||||
$organization = $organization->refresh();
|
||||
AfterCreateOrganization::dispatch($organization);
|
||||
|
||||
return new OrganizationResource($organization, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete organization
|
||||
*
|
||||
* @operationId deleteOrganization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'organizations:delete');
|
||||
|
||||
$deletionService->deleteOrganization($organization);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -82,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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,15 +4,26 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
||||
use App\Http\Requests\V1\User\UserUpdateRequest;
|
||||
use App\Http\Resources\V1\User\UserResource;
|
||||
use App\Mail\VerifyUpdatedEmailMail;
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use App\Support\Base64File;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get the current user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getMe
|
||||
*
|
||||
@@ -24,4 +35,114 @@ class UserController extends Controller
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user
|
||||
*
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId updateUser
|
||||
*/
|
||||
public function update(User $user, UserUpdateRequest $request): UserResource
|
||||
{
|
||||
if ($user->getKey() !== $this->user()->getKey()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
if ($request->getPhoto() !== null) {
|
||||
$photo = Base64File::decode($request->getPhoto());
|
||||
assert($photo !== null);
|
||||
$extension = Base64File::extension($photo['mime_type']);
|
||||
assert($extension !== null);
|
||||
|
||||
$previousPhotoPath = $user->profile_photo_path;
|
||||
$photoPath = 'profile-photos/'.Str::uuid().'.'.$extension;
|
||||
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
|
||||
|
||||
Storage::disk($photoDisk)->put($photoPath, $photo['data'], 'public');
|
||||
$user->profile_photo_path = $photoPath;
|
||||
|
||||
if ($previousPhotoPath !== null) {
|
||||
Storage::disk($photoDisk)->delete($previousPhotoPath);
|
||||
}
|
||||
}
|
||||
|
||||
$emailToVerify = null;
|
||||
$email = $request->getEmail();
|
||||
if ($email !== null && $email !== Str::lower($user->email)) {
|
||||
$emailToVerify = $email;
|
||||
$user->pending_email = $email;
|
||||
}
|
||||
|
||||
if ($request->getName() !== null) {
|
||||
$user->name = $request->getName();
|
||||
}
|
||||
|
||||
if ($request->getTimezone() !== null) {
|
||||
$user->timezone = $request->getTimezone();
|
||||
}
|
||||
|
||||
if ($request->getWeekStart() !== null) {
|
||||
$user->week_start = $request->getWeekStart();
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
if ($emailToVerify !== null) {
|
||||
Mail::to($emailToVerify)->send(new VerifyUpdatedEmailMail($user, $emailToVerify));
|
||||
}
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend the pending email update verification email.
|
||||
*
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId resendUserEmailVerification
|
||||
*
|
||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
|
||||
* @throws UserResendEmailVerificationNoPendingEmailApiException Thrown when the user does not have a pending email to verify.
|
||||
*/
|
||||
public function resendEmailVerification(User $user): JsonResponse
|
||||
{
|
||||
if ($user->getKey() !== $this->user()->getKey()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
if ($user->pending_email === null) {
|
||||
throw new UserResendEmailVerificationNoPendingEmailApiException;
|
||||
}
|
||||
|
||||
Mail::to($user->pending_email)
|
||||
->queue(new VerifyUpdatedEmailMail($user, $user->pending_email));
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the deletion of a user.
|
||||
*
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId deleteUser
|
||||
*
|
||||
* @param User $user The user instance to be deleted.
|
||||
* @param DeletionService $deletionService The service responsible for performing the user deletion.
|
||||
* @return JsonResponse A JSON response with a 204 No Content status upon successful deletion.
|
||||
*
|
||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
|
||||
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
|
||||
*/
|
||||
public function destroy(User $user, DeletionService $deletionService): JsonResponse
|
||||
{
|
||||
if ($user->getKey() !== $this->user()->getKey()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$deletionService->deleteUser($user);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class UserMembershipController extends Controller
|
||||
/**
|
||||
* Get the memberships of the current user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getMyMemberships
|
||||
*
|
||||
|
||||
@@ -17,7 +17,7 @@ class UserTimeEntryController extends Controller
|
||||
/**
|
||||
* Get the active time entry of the current user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getMyActiveTimeEntry
|
||||
*/
|
||||
|
||||
@@ -4,30 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Service\DashboardService;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
|
||||
public function dashboard(): Response
|
||||
{
|
||||
$user = $this->user();
|
||||
$organization = $this->currentOrganization();
|
||||
|
||||
$latestTeamActivity = null;
|
||||
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
||||
}
|
||||
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
return Inertia::render('Dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use RuntimeException;
|
||||
|
||||
class OrganizationInvitationController extends Controller
|
||||
{
|
||||
public function accept(OrganizationInvitation $invitation, MemberService $memberService): RedirectResponse
|
||||
{
|
||||
$email = strtolower($invitation->email);
|
||||
$role = Role::tryFrom($invitation->role);
|
||||
if ($role === null || $role === Role::Owner || $role === Role::Placeholder) {
|
||||
throw new RuntimeException('Invalid role');
|
||||
}
|
||||
|
||||
$organization = $invitation->organization;
|
||||
$invitee = User::query()
|
||||
->where('email', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->first();
|
||||
|
||||
// No account yet — finish on registration.
|
||||
if ($invitee === null) {
|
||||
if ($invitation->accepted_at === null) {
|
||||
$invitation->accepted_at = now();
|
||||
$invitation->save();
|
||||
}
|
||||
|
||||
return redirect(route('register'))
|
||||
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
|
||||
'organization' => $organization->name,
|
||||
]))
|
||||
->with('bannerStyle', 'info');
|
||||
}
|
||||
|
||||
$alreadyMember = $memberService->isEmailAlreadyMember($organization, $email);
|
||||
if (! $alreadyMember) {
|
||||
$memberService->addMember($invitee, $organization, $role);
|
||||
$invitation->delete();
|
||||
}
|
||||
|
||||
// Logged out — banner on /login.
|
||||
if (! Auth::check()) {
|
||||
return redirect(route('login'))
|
||||
->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization. Please log in to access it.', [
|
||||
'organization' => $organization->name,
|
||||
]))
|
||||
->with('bannerStyle', 'success');
|
||||
}
|
||||
|
||||
// Logged in — banner on /dashboard.
|
||||
if ($alreadyMember) {
|
||||
return redirect(route('dashboard'))
|
||||
->with('bannerText', __('You are already a member of the :organization organization.', [
|
||||
'organization' => $organization->name,
|
||||
]))
|
||||
->with('bannerStyle', 'danger');
|
||||
}
|
||||
|
||||
return redirect(route('dashboard'))
|
||||
->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization.', [
|
||||
'organization' => $organization->name,
|
||||
]))
|
||||
->with('bannerStyle', 'success');
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/Web/UserController.php
Normal file
55
app/Http/Controllers/Web/UserController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function verifyEmailChange(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
if ($request->user()?->getAuthIdentifier() !== $user->getKey()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$email = $request->query('email');
|
||||
if (! is_string($email)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$email = Str::lower($email);
|
||||
|
||||
if ($user->pending_email !== $email) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$emailAlreadyInUse = User::query()
|
||||
->where('email', '=', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->whereKeyNot($user->getKey())
|
||||
->exists();
|
||||
|
||||
if ($emailAlreadyInUse) {
|
||||
return redirect(route('dashboard', [
|
||||
'bannerStyle' => 'danger',
|
||||
'bannerText' => __('The email address is already in use.'),
|
||||
]));
|
||||
}
|
||||
|
||||
$user->email = $email;
|
||||
$user->pending_email = null;
|
||||
$user->email_verified_at = Carbon::now();
|
||||
$user->save();
|
||||
|
||||
return redirect(route('dashboard', [
|
||||
'bannerStyle' => 'success',
|
||||
'bannerText' => __('Your email address has been updated successfully.'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ class HandleInertiaRequests extends Middleware
|
||||
] : null,
|
||||
'flash' => [
|
||||
'message' => fn () => $request->session()->get('message'),
|
||||
'bannerText' => fn () => $request->session()->get('bannerText'),
|
||||
'bannerStyle' => fn () => $request->session()->get('bannerStyle'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ class ShareInertiaData
|
||||
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
|
||||
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
|
||||
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
|
||||
'flash' => $request->session()->get('flash', []),
|
||||
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
|
||||
'hasApiFeatures' => Jetstream::hasApiFeatures(),
|
||||
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
|
||||
|
||||
@@ -21,6 +21,11 @@ class InvitationIndexRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ class MemberIndexRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Organization;
|
||||
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Organization;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class OrganizationStoreRequest extends BaseFormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return (string) $this->input('name');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/V1/Report/ReportIndexRequest.php
Normal file
27
app/Http/Requests/V1/Report/ReportIndexRequest.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/V1/Tag/TagIndexRequest.php
Normal file
27
app/Http/Requests/V1/Tag/TagIndexRequest.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
88
app/Http/Requests/V1/User/UserUpdateRequest.php
Normal file
88
app/Http/Requests/V1/User/UserUpdateRequest.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\User;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\User;
|
||||
use App\Rules\Base64ImageRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property User $user User from model binding
|
||||
*/
|
||||
class UserUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('email') && is_string($this->input('email'))) {
|
||||
$this->merge([
|
||||
'email' => Str::lower((string) $this->input('email')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'email' => [
|
||||
'email',
|
||||
'max:255',
|
||||
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
}),
|
||||
],
|
||||
'photo' => [
|
||||
'nullable',
|
||||
new Base64ImageRule,
|
||||
],
|
||||
'timezone' => [
|
||||
'timezone:all',
|
||||
],
|
||||
'week_start' => [
|
||||
Rule::enum(Weekday::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->has('name') ? (string) $this->input('name') : null;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->has('email') ? Str::lower((string) $this->input('email')) : null;
|
||||
}
|
||||
|
||||
public function getTimezone(): ?string
|
||||
{
|
||||
return $this->has('timezone') ? (string) $this->input('timezone') : null;
|
||||
}
|
||||
|
||||
public function getWeekStart(): ?Weekday
|
||||
{
|
||||
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
||||
}
|
||||
|
||||
public function getPhoto(): ?string
|
||||
{
|
||||
return $this->has('photo') ? (string) $this->input('photo') : null;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class RemovePlaceholder
|
||||
{
|
||||
/**
|
||||
* Handle the event.
|
||||
*/
|
||||
public function handle(TeamMemberAdded $event): void
|
||||
{
|
||||
$memberService = app(MemberService::class);
|
||||
$member = Member::query()
|
||||
->whereBelongsTo($event->team, 'organization')
|
||||
->whereBelongsTo($event->user, 'user')
|
||||
->firstOrFail();
|
||||
$placeholders = Member::query()
|
||||
->whereHas('user', function (Builder $query) use ($event): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', true)
|
||||
->where('email', '=', $event->user->email);
|
||||
})
|
||||
->whereBelongsTo($event->team, 'organization')
|
||||
->with(['user'])
|
||||
->get();
|
||||
|
||||
foreach ($placeholders as $placeholder) {
|
||||
/** @var Member $placeholder */
|
||||
$placeholderUser = $placeholder->user;
|
||||
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
|
||||
$placeholder->delete();
|
||||
$placeholderUser->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class OrganizationInvitationMail extends Mailable
|
||||
@@ -32,9 +33,12 @@ class OrganizationInvitationMail extends Mailable
|
||||
public function build(): self
|
||||
{
|
||||
return $this->markdown('emails.organization-invitation', [
|
||||
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
||||
'invitation' => $this->invitation,
|
||||
]),
|
||||
'acceptUrl' => URL::to(URL::signedRoute(
|
||||
'organization-invitations.accept',
|
||||
['invitation' => $this->invitation->getKey()],
|
||||
Carbon::now()->addDays(90),
|
||||
false
|
||||
)),
|
||||
])->subject(__('Organization Invitation'));
|
||||
}
|
||||
}
|
||||
|
||||
48
app/Mail/VerifyUpdatedEmailMail.php
Normal file
48
app/Mail/VerifyUpdatedEmailMail.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class VerifyUpdatedEmailMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public User $user;
|
||||
|
||||
public string $email;
|
||||
|
||||
public function __construct(User $user, string $email)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->email = Str::lower($email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*/
|
||||
public function build(): self
|
||||
{
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'users.verify-email-change',
|
||||
Carbon::now()->addMinutes((int) config('auth.verification.expire', 60)),
|
||||
[
|
||||
'user' => $this->user->getKey(),
|
||||
'email' => $this->email,
|
||||
],
|
||||
false
|
||||
);
|
||||
|
||||
return $this->markdown('emails.verify-updated-email', [
|
||||
'verificationUrl' => URL::to($verificationUrl),
|
||||
])->subject(__('Verify Email Address'));
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $user_id
|
||||
* @property bool $employees_can_see_billable_rates
|
||||
* @property bool $employees_can_manage_tasks
|
||||
* @property bool $prevent_overlapping_time_entries
|
||||
* @property User $owner
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
|
||||
@@ -18,6 +18,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $email
|
||||
* @property string $role
|
||||
* @property string $organization_id
|
||||
* @property Carbon|null $accepted_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property-read Organization $organization
|
||||
@@ -41,14 +42,16 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
||||
protected $table = 'organization_invitations';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @var array<int, string>
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'email',
|
||||
'role',
|
||||
];
|
||||
public function casts(): array
|
||||
{
|
||||
return [
|
||||
'accepted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the organization that the invitation belongs to.
|
||||
|
||||
@@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property string|null $pending_email
|
||||
* @property Carbon|null $email_verified_at
|
||||
* @property string|null $password
|
||||
* @property string|null $two_factor_secret
|
||||
@@ -105,6 +106,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
||||
protected $casts = [
|
||||
'name' => 'string',
|
||||
'email' => 'string',
|
||||
'pending_email' => 'string',
|
||||
'email_verified_at' => 'datetime',
|
||||
'is_admin' => 'boolean',
|
||||
'is_placeholder' => 'boolean',
|
||||
|
||||
@@ -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,19 +59,7 @@ class OrganizationPolicy
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->ownsTeam($organization);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can add team members.
|
||||
*/
|
||||
public function addTeamMember(User $user, Organization $organization): bool
|
||||
{
|
||||
if (Filament::isServing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\RemovePlaceholder;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -21,9 +19,6 @@ class EventServiceProvider extends ServiceProvider
|
||||
Registered::class => [
|
||||
SendEmailVerificationNotification::class,
|
||||
],
|
||||
TeamMemberAdded::class => [
|
||||
RemovePlaceholder::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Laravel\Fortify\Http\Responses\LoginResponse;
|
||||
@@ -41,6 +42,14 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
|
||||
Fortify::registerView(function () {
|
||||
return Inertia::render('Auth/Register', [
|
||||
'terms_url' => config('auth.terms_url'),
|
||||
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
||||
'newsletter_consent' => config('auth.newsletter_consent'),
|
||||
]);
|
||||
});
|
||||
|
||||
Fortify::authenticateUsing(function (Request $request): ?User {
|
||||
/** @var User|null $user */
|
||||
$user = User::query()
|
||||
|
||||
@@ -13,20 +13,18 @@ use App\Actions\Jetstream\RemoveOrganizationMember;
|
||||
use App\Actions\Jetstream\UpdateMemberRole;
|
||||
use App\Actions\Jetstream\UpdateOrganization;
|
||||
use App\Actions\Jetstream\ValidateOrganizationDeletion;
|
||||
use App\Enums\Role;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use App\Service\TimezoneService;
|
||||
use Brick\Money\Currency;
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
|
||||
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
@@ -60,13 +58,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
||||
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
||||
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
|
||||
Fortify::registerView(function () {
|
||||
return Inertia::render('Auth/Register', [
|
||||
'terms_url' => config('auth.terms_url'),
|
||||
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
||||
'newsletter_consent' => config('auth.newsletter_consent'),
|
||||
]);
|
||||
});
|
||||
Gate::define('removeTeamMember', function (User $user, Organization $team) {
|
||||
return false;
|
||||
});
|
||||
@@ -79,205 +70,10 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
{
|
||||
Jetstream::defaultApiTokenPermissions([]);
|
||||
|
||||
Jetstream::role(Role::Owner->value, 'Owner', [
|
||||
'charts:view:own',
|
||||
'charts:view:all',
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'project-members:view',
|
||||
'project-members:create',
|
||||
'project-members:update',
|
||||
'project-members:delete',
|
||||
'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',
|
||||
'time-entries:delete:all',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'tags:view',
|
||||
'tags:create',
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
'organizations:view',
|
||||
'organizations:update',
|
||||
'organizations:delete',
|
||||
'import',
|
||||
'export',
|
||||
'invitations:view',
|
||||
'invitations:create',
|
||||
'invitations:resend',
|
||||
'invitations:remove',
|
||||
'members:view',
|
||||
'members:invite-placeholder',
|
||||
'members:change-ownership',
|
||||
'members:make-placeholder',
|
||||
'members:merge-into',
|
||||
'members:update',
|
||||
'members:delete',
|
||||
'billing',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
'invoices:download',
|
||||
'invoices:delete',
|
||||
'invoice-settings:view',
|
||||
'invoice-settings:update',
|
||||
])->description('Owner users can perform any action. There is only one owner per organization.');
|
||||
|
||||
Jetstream::role(Role::Admin->value, 'Administrator', [
|
||||
'charts:view:own',
|
||||
'charts:view:all',
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'project-members:view',
|
||||
'project-members:create',
|
||||
'project-members:update',
|
||||
'project-members:delete',
|
||||
'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',
|
||||
'time-entries:delete:all',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'tags:view',
|
||||
'tags:create',
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
'organizations:view',
|
||||
'organizations:update',
|
||||
'import',
|
||||
'export',
|
||||
'invitations:view',
|
||||
'invitations:create',
|
||||
'invitations:resend',
|
||||
'invitations:remove',
|
||||
'members:view',
|
||||
'members:invite-placeholder',
|
||||
'members:make-placeholder',
|
||||
'members:merge-into',
|
||||
'members:delete',
|
||||
'members:update',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
'invoices:download',
|
||||
'invoices:delete',
|
||||
'invoice-settings:view',
|
||||
'invoice-settings:update',
|
||||
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
||||
|
||||
Jetstream::role(Role::Manager->value, 'Manager', [
|
||||
'charts:view:own',
|
||||
'charts:view:all',
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'project-members:view',
|
||||
'project-members:create',
|
||||
'project-members:update',
|
||||
'project-members:delete',
|
||||
'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',
|
||||
'time-entries:delete:all',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'tags:view',
|
||||
'tags:create',
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
'organizations:view',
|
||||
'invitations:view',
|
||||
'members:view',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
'invoices:download',
|
||||
'invoices:delete',
|
||||
'invoice-settings:view',
|
||||
'invoice-settings:update',
|
||||
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
|
||||
|
||||
Jetstream::role(Role::Employee->value, 'Employee', [
|
||||
'charts:view:own',
|
||||
'projects:view',
|
||||
'tags:view',
|
||||
'tasks:view',
|
||||
'clients:view',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'organizations:view',
|
||||
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
|
||||
|
||||
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
||||
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
||||
foreach (PermissionStore::roleDefinitions() as $role => $definition) {
|
||||
Jetstream::role($role, $definition['name'], $definition['permissions'])
|
||||
->description($definition['description']);
|
||||
}
|
||||
|
||||
Jetstream::inertia()
|
||||
->whenRendering(
|
||||
@@ -304,28 +100,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();
|
||||
|
||||
37
app/Rules/Base64ImageRule.php
Normal file
37
app/Rules/Base64ImageRule.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\Base64File;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class Base64ImageRule implements ValidationRule
|
||||
{
|
||||
private const array ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
];
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param Closure(string): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.string'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$file = Base64File::decode($value);
|
||||
if ($file === null || ! in_array($file['mime_type'], self::ALLOWED_MIME_TYPES, true)) {
|
||||
$fail(__('validation.mimes', ['values' => 'jpg, png']));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,11 @@ use App\Enums\Role;
|
||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||
|
||||
@@ -21,11 +23,7 @@ class InvitationService
|
||||
*/
|
||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||
{
|
||||
if (Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereRelation('user', 'email', '=', $email)
|
||||
->where('role', '!=', Role::Placeholder->value)
|
||||
->exists()) {
|
||||
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||
}
|
||||
|
||||
@@ -48,4 +46,37 @@ class InvitationService
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Organization>
|
||||
*/
|
||||
public function processAcceptedInvitations(User $user): Collection
|
||||
{
|
||||
$organizations = new Collection;
|
||||
|
||||
$invitations = OrganizationInvitation::query()
|
||||
->where('email', $user->email)
|
||||
->whereNotNull('accepted_at')
|
||||
->get();
|
||||
|
||||
foreach ($invitations as $invitation) {
|
||||
$organization = $invitation->organization;
|
||||
$role = Role::tryFrom($invitation->role);
|
||||
if ($role === null) {
|
||||
Log::error('Invalid role in invitation', [
|
||||
'invitation' => $invitation->getKey(),
|
||||
'role' => $invitation->role,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
app(MemberService::class)->addMember($user, $organization, $role);
|
||||
|
||||
$invitation->delete();
|
||||
|
||||
$organizations->push($organization);
|
||||
}
|
||||
|
||||
return $organizations;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\MemberAdded;
|
||||
use App\Events\MemberAdding;
|
||||
use App\Events\MemberRemoved;
|
||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||
@@ -36,7 +38,8 @@ class MemberService
|
||||
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
||||
{
|
||||
if (! $asSuperAdmin) {
|
||||
AddingTeamMember::dispatch($organization, $user);
|
||||
MemberAdding::dispatch($user, $organization, $role);
|
||||
AddingTeamMember::dispatch($organization, $user); // Legacy event
|
||||
}
|
||||
|
||||
$member = new Member;
|
||||
@@ -49,14 +52,37 @@ class MemberService
|
||||
$user->currentOrganization()->associate($organization);
|
||||
$user->save();
|
||||
});
|
||||
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
|
||||
|
||||
if (! $asSuperAdmin) {
|
||||
TeamMemberAdded::dispatch($organization, $user);
|
||||
MemberAdded::dispatch($member, $organization, $user);
|
||||
TeamMemberAdded::dispatch($organization, $user); // Legacy event
|
||||
}
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
private function mergePlaceholderMembersIntoExistingMember(Member $member, Organization $organization, User $user): void
|
||||
{
|
||||
$placeholders = Member::query()
|
||||
->whereHas('user', function (Builder $query) use ($user): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', true)
|
||||
->where('email', '=', $user->email);
|
||||
})
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->with(['user'])
|
||||
->get();
|
||||
|
||||
foreach ($placeholders as $placeholder) {
|
||||
/** @var Member $placeholder */
|
||||
$placeholderUser = $placeholder->user;
|
||||
$this->assignOrganizationEntitiesToDifferentMember($organization, $placeholder, $member);
|
||||
$placeholder->delete();
|
||||
$placeholderUser->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CanNotRemoveOwnerFromOrganization
|
||||
* @throws EntityStillInUseApiException
|
||||
@@ -196,6 +222,7 @@ class MemberService
|
||||
|
||||
$placeholderUser = $user->replicate();
|
||||
$placeholderUser->is_placeholder = true;
|
||||
$placeholderUser->current_team_id = $member->organization_id;
|
||||
$placeholderUser->save();
|
||||
|
||||
$member->user()->associate($placeholderUser);
|
||||
@@ -208,4 +235,13 @@ class MemberService
|
||||
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||
}
|
||||
}
|
||||
|
||||
public function isEmailAlreadyMember(Organization $organization, string $email): bool
|
||||
{
|
||||
return Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereRelation('user', 'email', '=', $email)
|
||||
->where('role', '!=', Role::Placeholder->value)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,238 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Laravel\Jetstream\Role;
|
||||
|
||||
class PermissionStore
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{name: string, permissions: array<string>, description: string}>
|
||||
*/
|
||||
private const array ROLE_DEFINITIONS = [
|
||||
'owner' => [
|
||||
'name' => 'Owner',
|
||||
'permissions' => [
|
||||
'charts:view:own',
|
||||
'charts:view:all',
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'project-members:view',
|
||||
'project-members:create',
|
||||
'project-members:update',
|
||||
'project-members:delete',
|
||||
'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',
|
||||
'time-entries:delete:all',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'tags:view',
|
||||
'tags:create',
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
'organizations:view',
|
||||
'organizations:update',
|
||||
'organizations:delete',
|
||||
'import',
|
||||
'export',
|
||||
'invitations:view',
|
||||
'invitations:create',
|
||||
'invitations:resend',
|
||||
'invitations:remove',
|
||||
'members:view',
|
||||
'members:invite-placeholder',
|
||||
'members:change-ownership',
|
||||
'members:make-placeholder',
|
||||
'members:merge-into',
|
||||
'members:update',
|
||||
'members:delete',
|
||||
'billing',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
'invoices:download',
|
||||
'invoices:delete',
|
||||
'invoice-settings:view',
|
||||
'invoice-settings:update',
|
||||
],
|
||||
'description' => 'Owner users can perform any action. There is only one owner per organization.',
|
||||
],
|
||||
'admin' => [
|
||||
'name' => 'Administrator',
|
||||
'permissions' => [
|
||||
'charts:view:own',
|
||||
'charts:view:all',
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'project-members:view',
|
||||
'project-members:create',
|
||||
'project-members:update',
|
||||
'project-members:delete',
|
||||
'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',
|
||||
'time-entries:delete:all',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'tags:view',
|
||||
'tags:create',
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
'organizations:view',
|
||||
'organizations:update',
|
||||
'import',
|
||||
'export',
|
||||
'invitations:view',
|
||||
'invitations:create',
|
||||
'invitations:resend',
|
||||
'invitations:remove',
|
||||
'members:view',
|
||||
'members:invite-placeholder',
|
||||
'members:make-placeholder',
|
||||
'members:merge-into',
|
||||
'members:delete',
|
||||
'members:update',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
'invoices:download',
|
||||
'invoices:delete',
|
||||
'invoice-settings:view',
|
||||
'invoice-settings:update',
|
||||
],
|
||||
'description' => 'Administrator users can perform any action, except accessing the billing dashboard.',
|
||||
],
|
||||
'manager' => [
|
||||
'name' => 'Manager',
|
||||
'permissions' => [
|
||||
'charts:view:own',
|
||||
'charts:view:all',
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'project-members:view',
|
||||
'project-members:create',
|
||||
'project-members:update',
|
||||
'project-members:delete',
|
||||
'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',
|
||||
'time-entries:delete:all',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'tags:view',
|
||||
'tags:create',
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
'organizations:view',
|
||||
'invitations:view',
|
||||
'members:view',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
'invoices:download',
|
||||
'invoices:delete',
|
||||
'invoice-settings:view',
|
||||
'invoice-settings:update',
|
||||
],
|
||||
'description' => 'Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).',
|
||||
],
|
||||
'employee' => [
|
||||
'name' => 'Employee',
|
||||
'permissions' => [
|
||||
'charts:view:own',
|
||||
'projects:view',
|
||||
'tags:view',
|
||||
'tasks:view',
|
||||
'clients:view',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'organizations:view',
|
||||
],
|
||||
'description' => 'Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.',
|
||||
],
|
||||
'placeholder' => [
|
||||
'name' => 'Placeholder',
|
||||
'permissions' => [],
|
||||
'description' => 'Placeholders are used for importing data. They cannot log in and have no permissions.',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array<string>>
|
||||
*/
|
||||
private static array $customRolePermissions = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array<string>>
|
||||
*/
|
||||
@@ -22,6 +246,37 @@ class PermissionStore
|
||||
$this->permissionCache = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{name: string, permissions: array<string>, description: string}>
|
||||
*/
|
||||
public static function roleDefinitions(): array
|
||||
{
|
||||
return self::ROLE_DEFINITIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $permissions
|
||||
*/
|
||||
public static function registerCustomRole(string $role, array $permissions): void
|
||||
{
|
||||
self::$customRolePermissions[$role] = $permissions;
|
||||
}
|
||||
|
||||
public static function resetCustomRoles(): void
|
||||
{
|
||||
self::$customRolePermissions = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function permissionsForRole(string $role): array
|
||||
{
|
||||
return self::$customRolePermissions[$role]
|
||||
?? self::ROLE_DEFINITIONS[$role]['permissions']
|
||||
?? [];
|
||||
}
|
||||
|
||||
public function has(Organization $organization, string $permission): bool
|
||||
{
|
||||
/** @var User|null $user */
|
||||
@@ -68,14 +323,11 @@ class PermissionStore
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var Role|null $roleObj */
|
||||
$roleObj = Jetstream::findRole($role);
|
||||
|
||||
$permissions = $roleObj->permissions ?? [];
|
||||
$permissions = self::permissionsForRole($role);
|
||||
|
||||
// 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) {
|
||||
if ($role === Role::Employee->value && $organization->employees_can_manage_tasks) {
|
||||
$permissions = array_merge($permissions, [
|
||||
'tasks:create',
|
||||
'tasks:update',
|
||||
|
||||
@@ -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.')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class UserService
|
||||
): User {
|
||||
$user = new User;
|
||||
$user->name = $name;
|
||||
$user->email = $email;
|
||||
$user->email = strtolower($email);
|
||||
$user->password = Hash::make($password);
|
||||
$user->timezone = $timezone;
|
||||
$user->week_start = $weekStart;
|
||||
@@ -47,19 +47,22 @@ class UserService
|
||||
}
|
||||
$user->save();
|
||||
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$this->getOrganizationNameForUserName($user->name),
|
||||
$user,
|
||||
true,
|
||||
$currency,
|
||||
$numberFormat,
|
||||
$currencyFormat,
|
||||
$dateFormat,
|
||||
$intervalFormat,
|
||||
$timeFormat,
|
||||
);
|
||||
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
if ($organizations->isEmpty()) {
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$this->getOrganizationNameForUserName($user->name),
|
||||
$user,
|
||||
true,
|
||||
$currency,
|
||||
$numberFormat,
|
||||
$currencyFormat,
|
||||
$dateFormat,
|
||||
$intervalFormat,
|
||||
$timeFormat,
|
||||
);
|
||||
$user->ownedTeams()->save($organization);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
45
app/Support/Base64File.php
Normal file
45
app/Support/Base64File.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Symfony\Component\Mime\MimeTypes;
|
||||
|
||||
class Base64File
|
||||
{
|
||||
/**
|
||||
* @return array{data: string, mime_type: string}|null
|
||||
*/
|
||||
public static function decode(string $value): ?array
|
||||
{
|
||||
if (str_contains($value, ',')) {
|
||||
[, $value] = explode(',', $value, 2);
|
||||
}
|
||||
|
||||
$value = preg_replace('/\s+/', '', $value);
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = base64_decode($value, true);
|
||||
if ($decoded === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
|
||||
if ($mimeType === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $decoded,
|
||||
'mime_type' => $mimeType,
|
||||
];
|
||||
}
|
||||
|
||||
public static function extension(string $mimeType): ?string
|
||||
{
|
||||
return MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? null;
|
||||
}
|
||||
}
|
||||
2066
composer.lock
generated
2066
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -25,9 +25,24 @@ class OrganizationInvitationFactory extends Factory
|
||||
'email' => $this->faker->unique()->safeEmail(),
|
||||
'role' => Role::Employee->value,
|
||||
'organization_id' => Organization::factory(),
|
||||
'accepted_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function role(Role $role): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'role' => $role->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function accepted(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'accepted_at' => $this->faker->dateTime(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function forOrganization(Organization $organization): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
|
||||
@@ -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('organization_invitations', function (Blueprint $table): void {
|
||||
$table->timestamp('accepted_at')->nullable()->after('email');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('organization_invitations', function (Blueprint $table): void {
|
||||
$table->dropColumn('accepted_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('users', function (Blueprint $table): void {
|
||||
$table->string('pending_email')->nullable()->after('email');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropColumn('pending_email');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$duplicateEmails = DB::table('users')
|
||||
->selectRaw('LOWER(email) as normalized_email')
|
||||
->selectRaw('COUNT(*) as user_count')
|
||||
->selectRaw("STRING_AGG(id::text || ' <' || email || '>', ', ' ORDER BY email) as users")
|
||||
->where('is_placeholder', false)
|
||||
->groupByRaw('LOWER(email)')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->orderBy('normalized_email')
|
||||
->get();
|
||||
|
||||
if ($duplicateEmails->isNotEmpty()) {
|
||||
$duplicateEmailMessage = $duplicateEmails
|
||||
->take(20)
|
||||
->map(fn (\stdClass $duplicateEmail): string => sprintf(
|
||||
'%s (%d users: %s)',
|
||||
$duplicateEmail->normalized_email,
|
||||
$duplicateEmail->user_count,
|
||||
$duplicateEmail->users,
|
||||
))
|
||||
->implode('; ');
|
||||
|
||||
$remainingDuplicateCount = $duplicateEmails->count() - 20;
|
||||
$remainingDuplicateMessage = $remainingDuplicateCount > 0
|
||||
? sprintf('; and %d more duplicate normalized emails', $remainingDuplicateCount)
|
||||
: '';
|
||||
|
||||
throw new RuntimeException(
|
||||
'Cannot lowercase users.email because doing so would create duplicate non-placeholder user emails and violate the unique index on users.email for non-placeholder users. Resolve these case-insensitive duplicates first: '.
|
||||
$duplicateEmailMessage.
|
||||
$remainingDuplicateMessage
|
||||
);
|
||||
}
|
||||
|
||||
DB::table('users')
|
||||
->whereRaw('email <> LOWER(email)')
|
||||
->update([
|
||||
'email' => DB::raw('LOWER(email)'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
689
e2e/calendar-settings.spec.ts
Normal file
689
e2e/calendar-settings.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
2566
e2e/calendar.spec.ts
2566
e2e/calendar.spec.ts
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import {
|
||||
createProjectViaApi,
|
||||
createPublicProjectViaApi,
|
||||
} from './utils/api';
|
||||
import { getTableRowNames } from './utils/table';
|
||||
|
||||
async function goToClientsOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||
@@ -131,6 +132,166 @@ test('test that deleting a client via actions menu works', async ({ page, ctx })
|
||||
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
|
||||
// =============================================
|
||||
|
||||
@@ -31,15 +31,21 @@ async function selectCommand(page: Page, name: string) {
|
||||
}
|
||||
|
||||
async function assertTimerIsRunning(page: Page) {
|
||||
await expect(page.locator(TIMER_BUTTON_SELECTOR)).toHaveClass(/bg-red-400\/80/, {
|
||||
timeout: 10000,
|
||||
});
|
||||
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)).toHaveClass(/bg-accent-300\/70/, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
|
||||
/bg-accent-300\/70/,
|
||||
{
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('Command Palette', () => {
|
||||
|
||||
@@ -25,7 +25,12 @@ test('test that dashboard loads with all expected sections', async ({ page }) =>
|
||||
|
||||
// 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')).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();
|
||||
@@ -104,7 +109,10 @@ test.describe('Employee Dashboard Restrictions', () => {
|
||||
|
||||
// Timer should be available
|
||||
await expect(
|
||||
employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
|
||||
employee.page
|
||||
.getByTestId('dashboard_timer')
|
||||
.getByTestId('timer_button')
|
||||
.and(employee.page.locator(':visible'))
|
||||
).toBeVisible();
|
||||
await expect(employee.page.getByTestId('time_entry_description')).toBeEditable();
|
||||
});
|
||||
|
||||
158
e2e/invitation-accept.spec.ts
Normal file
158
e2e/invitation-accept.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { expect, test } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
|
||||
import { getInvitationAcceptUrl } from './utils/mailpit';
|
||||
import { registerUser } from './utils/members';
|
||||
|
||||
// Invitation acceptance flows touch mail delivery + redirects.
|
||||
test.describe.configure({ timeout: 45000 });
|
||||
|
||||
test.describe('invitation accept banners', () => {
|
||||
test('shows success banner on dashboard when a logged-in registered user accepts an invitation', async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const memberId = Math.floor(Math.random() * 100000);
|
||||
const memberEmail = `success+${memberId}@invite-banner.test`;
|
||||
|
||||
// Invitee already has an account and is logged in.
|
||||
const invitee = await registerUser(browser, 'Banner Success', memberEmail);
|
||||
|
||||
// Owner sends the invitation.
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||
await page.getByRole('button', { name: 'Invite Member' }).click();
|
||||
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
|
||||
await page.getByLabel('Email').fill(memberEmail);
|
||||
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(),
|
||||
]);
|
||||
|
||||
// Invitee clicks the email link.
|
||||
const acceptUrl = await getInvitationAcceptUrl(invitee.page.request, memberEmail);
|
||||
await invitee.page.goto(acceptUrl);
|
||||
await invitee.page.waitForURL(/\/dashboard$/);
|
||||
|
||||
const banner = invitee.page.getByTestId('banner');
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toContainText(
|
||||
/Great! You have accepted the invitation to join the .* organization\./
|
||||
);
|
||||
|
||||
await invitee.close();
|
||||
});
|
||||
|
||||
test('shows info banner on login screen when a registered-but-logged-out invitee clicks the accept link', async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const memberId = Math.floor(Math.random() * 100000);
|
||||
const memberEmail = `loggedout+${memberId}@invite-banner.test`;
|
||||
|
||||
// Invitee has an account, but the context that clicks the link has no session.
|
||||
const invitee = await registerUser(browser, 'Banner Loggedout', memberEmail);
|
||||
await invitee.close();
|
||||
|
||||
// Owner sends the invitation.
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||
await page.getByRole('button', { name: 'Invite Member' }).click();
|
||||
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
|
||||
await page.getByLabel('Email').fill(memberEmail);
|
||||
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(),
|
||||
]);
|
||||
|
||||
// Open the accept link in a fresh browser context (no session).
|
||||
const context = await browser.newContext();
|
||||
const inviteePage = await context.newPage();
|
||||
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
|
||||
await inviteePage.goto(acceptUrl);
|
||||
await inviteePage.waitForURL(/\/login$/);
|
||||
|
||||
const banner = inviteePage.getByTestId('banner');
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toContainText(
|
||||
/Great! You have accepted the invitation to join the .* organization\. Please log in to access it\./
|
||||
);
|
||||
|
||||
// Logging in lands the invitee on the dashboard — they were already added silently
|
||||
// by the accept controller, so the inviter's members list shows them.
|
||||
await inviteePage.getByLabel('Email').fill(memberEmail);
|
||||
await inviteePage.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD);
|
||||
await inviteePage.getByRole('button', { name: 'Log in' }).click();
|
||||
await inviteePage.waitForURL(/\/dashboard/);
|
||||
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||
const memberRow = page.getByRole('row').filter({ hasText: 'Banner Loggedout' });
|
||||
await expect(memberRow).toBeVisible();
|
||||
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('shows info banner on register screen when an unregistered email accepts an invitation, then auto-joins on registration', async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const memberId = Math.floor(Math.random() * 100000);
|
||||
const memberEmail = `info+${memberId}@invite-banner.test`;
|
||||
|
||||
// Owner invites an email that has no account yet.
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||
await page.getByRole('button', { name: 'Invite Member' }).click();
|
||||
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
|
||||
await page.getByLabel('Email').fill(memberEmail);
|
||||
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(),
|
||||
]);
|
||||
|
||||
// Open the accept link in a fresh browser context (no session).
|
||||
const context = await browser.newContext();
|
||||
const inviteePage = await context.newPage();
|
||||
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
|
||||
await inviteePage.goto(acceptUrl);
|
||||
await inviteePage.waitForURL(/\/register$/);
|
||||
|
||||
const banner = inviteePage.getByTestId('banner');
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toContainText(
|
||||
/Please create an account to finish joining the .* organization\./
|
||||
);
|
||||
|
||||
// Complete registration — the invitee should auto-join the inviter's org
|
||||
// (no fresh personal organization is created on top).
|
||||
await inviteePage.getByLabel('Name').fill('Banner Info');
|
||||
await inviteePage.getByLabel('Email').fill(memberEmail);
|
||||
await inviteePage.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD);
|
||||
await inviteePage.getByLabel('Confirm Password').fill(TEST_USER_PASSWORD);
|
||||
await inviteePage.getByLabel('I agree to the Terms of').click();
|
||||
await inviteePage.getByRole('button', { name: 'Register' }).click();
|
||||
await inviteePage.waitForURL(/\/dashboard/);
|
||||
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||
const memberRow = page.getByRole('row').filter({ hasText: 'Banner Info' });
|
||||
await expect(memberRow).toBeVisible();
|
||||
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,13 @@ 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 } from './utils/api';
|
||||
import {
|
||||
createPlaceholderMemberViaImportApi,
|
||||
getMembersViaApi,
|
||||
updateMemberBillableRateViaApi,
|
||||
updateOrganizationSettingViaApi,
|
||||
} from './utils/api';
|
||||
import { getTableRowNames } from './utils/table';
|
||||
|
||||
// Tests that invite + accept members need more time
|
||||
test.describe.configure({ timeout: 45000 });
|
||||
@@ -79,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();
|
||||
|
||||
@@ -102,6 +108,136 @@ 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);
|
||||
|
||||
@@ -360,6 +496,158 @@ test('test that organization owner cannot be deleted', async ({ 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
|
||||
// =============================================
|
||||
@@ -487,6 +775,125 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
|
||||
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
|
||||
// =============================================
|
||||
@@ -522,7 +929,7 @@ test.describe('Employee Sidebar Navigation', () => {
|
||||
});
|
||||
|
||||
// Member table is empty — no rows rendered (only headers)
|
||||
await expect(employee.page.getByTestId('client_table').locator('[role="row"]')).toHaveCount(
|
||||
await expect(employee.page.getByTestId('member_table').locator('[role="row"]')).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
|
||||
@@ -369,6 +369,40 @@ test('test that format settings persist after page reload', async ({ page }) =>
|
||||
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
|
||||
// =============================================
|
||||
|
||||
@@ -230,6 +230,37 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
|
||||
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
|
||||
// =============================================
|
||||
|
||||
@@ -3,11 +3,13 @@ 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 {
|
||||
createProjectViaApi,
|
||||
createPublicProjectViaApi,
|
||||
createTaskViaApi,
|
||||
createClientViaApi,
|
||||
createTimeEntryViaApi,
|
||||
archiveProjectViaApi,
|
||||
updateOrganizationSettingViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
@@ -335,61 +337,179 @@ test('test that editing an existing billable project with default rate loads cor
|
||||
});
|
||||
|
||||
// Sorting tests
|
||||
test('test that sorting projects by name works', async ({ page }) => {
|
||||
test('test that sorting projects by all columns works', async ({ page, ctx }) => {
|
||||
// Seed projects with distinct values for each sortable column
|
||||
const clientAlpha = await createClientViaApi(ctx, { name: 'Alpha Client' });
|
||||
const clientBeta = await createClientViaApi(ctx, { name: 'Beta Client' });
|
||||
|
||||
// Project A: client Alpha, low billable rate, has estimated time, active
|
||||
const projectA = await createProjectViaApi(ctx, {
|
||||
name: 'AAA Project',
|
||||
client_id: clientAlpha.id,
|
||||
is_billable: true,
|
||||
billable_rate: 5000,
|
||||
estimated_time: 36000, // 10h
|
||||
});
|
||||
// Add 1h of time entries (10% progress)
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
duration: '1h',
|
||||
projectId: projectA.id,
|
||||
});
|
||||
|
||||
// Project B: client Beta, high billable rate, has estimated time, archived
|
||||
const projectB = await createProjectViaApi(ctx, {
|
||||
name: 'BBB Project',
|
||||
client_id: clientBeta.id,
|
||||
is_billable: true,
|
||||
billable_rate: 15000,
|
||||
estimated_time: 7200, // 2h
|
||||
});
|
||||
// Add 1h of time entries (50% progress)
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
duration: '1h',
|
||||
projectId: projectB.id,
|
||||
});
|
||||
await archiveProjectViaApi(ctx, {
|
||||
...projectB,
|
||||
client_id: clientBeta.id,
|
||||
billable_rate: 15000,
|
||||
estimated_time: 7200,
|
||||
});
|
||||
|
||||
// Project C: no client, medium billable rate, no estimated time, active
|
||||
const projectC = await createProjectViaApi(ctx, {
|
||||
name: 'CCC Project',
|
||||
is_billable: true,
|
||||
billable_rate: 10000,
|
||||
});
|
||||
// Add 3h of time entries
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
duration: '3h',
|
||||
projectId: projectC.id,
|
||||
});
|
||||
|
||||
await goToProjectsOverview(page);
|
||||
await clearProjectTableState(page);
|
||||
await page.reload();
|
||||
|
||||
// Wait for the table to load
|
||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||
await expect(page.getByText('AAA Project')).toBeVisible();
|
||||
await expect(page.getByText('BBB Project')).toBeVisible();
|
||||
await expect(page.getByText('CCC Project')).toBeVisible();
|
||||
|
||||
// Get initial project names
|
||||
const getProjectNames = async () => {
|
||||
const rows = page
|
||||
.getByTestId('project_table')
|
||||
.locator('[data-testid="project_table"] > div')
|
||||
.filter({ hasNot: page.locator('.border-t') });
|
||||
const names: string[] = [];
|
||||
const count = await page.getByTestId('project_table').getByRole('row').count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const row = page.getByTestId('project_table').getByRole('row').nth(i);
|
||||
const nameCell = row.locator('div').first();
|
||||
const text = await nameCell.textContent();
|
||||
if (text) {
|
||||
names.push(text.trim());
|
||||
}
|
||||
// Helper to get the visual order of our seeded projects by reading
|
||||
// all row text in a single evaluate call (avoids locator timing issues)
|
||||
const seededNames = ['AAA Project', 'BBB Project', 'CCC Project'];
|
||||
const getOrder = async (): Promise<string[]> => {
|
||||
const allRowTexts = await page.evaluate(() => {
|
||||
const table = document.querySelector('[data-testid="project_table"]');
|
||||
if (!table) return [];
|
||||
const rows = table.querySelectorAll('[role="row"]');
|
||||
return Array.from(rows).map((row) => row.textContent ?? '');
|
||||
});
|
||||
const order: string[] = [];
|
||||
for (const text of allRowTexts) {
|
||||
const match = seededNames.find((name) => text.includes(name));
|
||||
if (match) order.push(match);
|
||||
}
|
||||
return names;
|
||||
return order;
|
||||
};
|
||||
|
||||
// Click on Name header to sort ascending (default should already be ascending)
|
||||
const nameHeader = page.getByText('Name').first();
|
||||
await nameHeader.click();
|
||||
// Helper: click a column header and wait for sort to apply.
|
||||
// expectedFirstAmongSeeded = which of our 3 seeded projects should appear first
|
||||
const clickSortHeader = async (headerText: string, expectedFirstAmongSeeded: string) => {
|
||||
const header = page
|
||||
.locator('[data-testid="project_table"] .select-none', {
|
||||
hasText: headerText,
|
||||
})
|
||||
.first();
|
||||
await header.click();
|
||||
// Wait until the expected project appears before the others among our seeded set
|
||||
await page.waitForFunction(
|
||||
({ expected, names }) => {
|
||||
const table = document.querySelector('[data-testid="project_table"]');
|
||||
if (!table) return false;
|
||||
const rows = table.querySelectorAll('[role="row"]');
|
||||
let firstSeededIdx = -1;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const text = rows[i].textContent ?? '';
|
||||
if (names.some((n: string) => text.includes(n))) {
|
||||
firstSeededIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (firstSeededIdx === -1) return false;
|
||||
return (rows[firstSeededIdx].textContent ?? '').includes(expected);
|
||||
},
|
||||
{ expected: expectedFirstAmongSeeded, names: seededNames },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
};
|
||||
|
||||
// Wait for sort indicator to appear
|
||||
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||
// --- Sort by Name ---
|
||||
// Default is name asc (A-Z)
|
||||
let order = await getOrder();
|
||||
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']);
|
||||
|
||||
// Click again to sort descending
|
||||
await nameHeader.click();
|
||||
// Click to toggle to Z-A
|
||||
await clickSortHeader('Name', 'CCC Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['CCC Project', 'BBB Project', 'AAA Project']);
|
||||
|
||||
// Verify the sort indicator is still visible (showing descending)
|
||||
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||
});
|
||||
// --- Sort by Client (text: first click = A-Z, no-client last) ---
|
||||
await clickSortHeader('Client', 'AAA Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // Alpha, Beta, No client
|
||||
|
||||
test('test that sorting projects by status works', async ({ page }) => {
|
||||
await goToProjectsOverview(page);
|
||||
await clearProjectTableState(page);
|
||||
await page.reload();
|
||||
// Reverse: Z-A, no-client still last
|
||||
await clickSortHeader('Client', 'BBB Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // Beta, Alpha, No client
|
||||
|
||||
// Default is "all" so no filter needed - Wait for the table to load
|
||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||
// --- Sort by Total Time (numeric: first click = highest first) ---
|
||||
await clickSortHeader('Total Time', 'CCC Project');
|
||||
order = await getOrder();
|
||||
expect(order[0]).toBe('CCC Project'); // C=3h first, A and B tied at 1h
|
||||
|
||||
// Click on Status header to sort
|
||||
const statusHeader = page.getByText('Status').first();
|
||||
await statusHeader.click();
|
||||
// Reverse: lowest first
|
||||
await clickSortHeader('Total Time', 'AAA Project');
|
||||
order = await getOrder();
|
||||
expect(order[2]).toBe('CCC Project'); // C=3h last
|
||||
|
||||
// Sort indicator should be visible
|
||||
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||
// --- Sort by Billable Rate (numeric: first click = highest first) ---
|
||||
await clickSortHeader('Billable Rate', 'BBB Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['BBB Project', 'CCC Project', 'AAA Project']); // 15000, 10000, 5000
|
||||
|
||||
// Reverse: lowest first
|
||||
await clickSortHeader('Billable Rate', 'AAA Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['AAA Project', 'CCC Project', 'BBB Project']); // 5000, 10000, 15000
|
||||
|
||||
// --- Sort by Progress (numeric: first click = highest first, no-estimate last) ---
|
||||
await clickSortHeader('Progress', 'BBB Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // 50%, 10%, no estimate
|
||||
|
||||
// Reverse: lowest first, no-estimate still last
|
||||
await clickSortHeader('Progress', 'AAA Project');
|
||||
order = await getOrder();
|
||||
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // 10%, 50%, no estimate
|
||||
|
||||
// --- Sort by Status (first click = active first, archived last) ---
|
||||
await expect(async () => {
|
||||
await clickSortHeader('Status', 'AAA Project');
|
||||
order = await getOrder();
|
||||
expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('AAA Project'));
|
||||
expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('CCC Project'));
|
||||
}).toPass({ timeout: 5000 });
|
||||
|
||||
// Reverse: archived first
|
||||
await expect(async () => {
|
||||
await clickSortHeader('Status', 'BBB Project');
|
||||
order = await getOrder();
|
||||
expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('AAA Project'));
|
||||
expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('CCC Project'));
|
||||
}).toPass({ timeout: 5000 });
|
||||
});
|
||||
|
||||
// Filter tests
|
||||
@@ -642,22 +762,6 @@ test('test that estimated time input displays formatted value after blur', async
|
||||
await expect(estimatedTimeInput).toHaveValue(/1h.*30/);
|
||||
});
|
||||
|
||||
// Create new project with new Client
|
||||
|
||||
// Create new project with existing Client
|
||||
|
||||
// Delete project via More Options
|
||||
|
||||
// Test that project task count is displayed correctly
|
||||
|
||||
// Edit Project Modal Test
|
||||
|
||||
// Add Project with billable rate
|
||||
|
||||
// Edit Project with billable rate
|
||||
|
||||
// Edit Project Member Billable Rate
|
||||
|
||||
test('test that editing a task name on the project detail page works', async ({ page, ctx }) => {
|
||||
const projectName = 'Task Edit Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000);
|
||||
@@ -696,6 +800,81 @@ test('test that editing a task name on the project detail page works', async ({
|
||||
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Context Menu Tests
|
||||
// =============================================
|
||||
|
||||
test('test that project context menu edit updates the project', async ({ page, ctx }) => {
|
||||
const projectName = 'CtxEditProject ' + Math.floor(1 + Math.random() * 10000);
|
||||
const updatedName = 'CtxUpdatedProject ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createProjectViaApi(ctx, { name: projectName });
|
||||
await goToProjectsOverview(page);
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: projectName }).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('Project Name').fill(updatedName);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Update Project' }).click(),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/projects/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('project_table')).toContainText(updatedName);
|
||||
await expect(page.getByTestId('project_table')).not.toContainText(projectName);
|
||||
});
|
||||
|
||||
test('test that project context menu archive archives the project', async ({ page, ctx }) => {
|
||||
const projectName = 'CtxArchiveProject ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createProjectViaApi(ctx, { name: projectName });
|
||||
await goToProjectsOverview(page);
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: projectName }).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('/projects') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Archive' }).click(),
|
||||
]);
|
||||
// After archiving, the project stays visible (default filter is 'all') but status changes to 'Archived'
|
||||
await expect(row).toContainText('Archived');
|
||||
});
|
||||
|
||||
test('test that project context menu delete deletes the project', async ({ page, ctx }) => {
|
||||
const projectName = 'CtxDeleteProject ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createProjectViaApi(ctx, { name: projectName });
|
||||
await goToProjectsOverview(page);
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: projectName }).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('/projects') &&
|
||||
response.request().method() === 'DELETE' &&
|
||||
response.status() === 204
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||||
]);
|
||||
await expect(page.getByTestId('project_table')).not.toContainText(projectName);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Employee Permission Tests
|
||||
// =============================================
|
||||
|
||||
@@ -32,7 +32,7 @@ test('test that detailed view shows time entries correctly', async ({ page, ctx
|
||||
|
||||
// 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('1h 00min');
|
||||
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1:00:00');
|
||||
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -62,8 +62,8 @@ test('test that updating duration in detailed view works correctly', async ({ pa
|
||||
),
|
||||
]);
|
||||
|
||||
// Verify the new duration is displayed
|
||||
await expect(durationInput).toHaveValue(updatedDuration);
|
||||
// Verify the new duration is displayed (reporting views promote to HH:MM:SS format)
|
||||
await expect(durationInput).toHaveValue('2:30:00');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@@ -333,7 +333,7 @@ test('test that task filtering works in reporting', async ({ page, ctx }) => {
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify the report only shows 1h (task1's duration)
|
||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that task multiselect search filters the option list', async ({ page, ctx }) => {
|
||||
@@ -474,7 +474,7 @@ test('test that tag filtering works in reporting', async ({ page, ctx }) => {
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify only time entries with tag1 are shown
|
||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that tag dropdown search filters the option list', async ({ page, ctx }) => {
|
||||
@@ -594,7 +594,7 @@ test('test that billable status filtering works in reporting', async ({ page, ct
|
||||
waitForReportingUpdate(page),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
|
||||
await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that billable filter can switch between all three states', async ({ page }) => {
|
||||
@@ -885,7 +885,7 @@ test.describe('Employee Reporting Restrictions', () => {
|
||||
|
||||
// Employee's data should be visible (1h)
|
||||
await expect(
|
||||
employee.page.getByTestId('reporting_view').getByText('1h 00min').first()
|
||||
employee.page.getByTestId('reporting_view').getByText('1:00:00').first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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,
|
||||
@@ -8,6 +12,10 @@ import {
|
||||
createTimeEntryViaApi,
|
||||
createTimeEntryWithTagViaApi,
|
||||
createBareTimeEntryViaApi,
|
||||
createBillableProjectViaApi,
|
||||
createTimeEntryWithBillableStatusViaApi,
|
||||
createTagViaApi,
|
||||
createReportViaApi,
|
||||
} from './utils/api';
|
||||
import {
|
||||
goToReporting,
|
||||
@@ -246,6 +254,191 @@ test('test that shared report with No Task filter shows entries without a task',
|
||||
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
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -577,3 +770,149 @@ test('test that updating expiration date on already-public report works', async
|
||||
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);
|
||||
});
|
||||
|
||||
105
e2e/tags.spec.ts
105
e2e/tags.spec.ts
@@ -3,6 +3,7 @@ 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');
|
||||
@@ -89,6 +90,110 @@ test('test that multiple tags can be created via API and displayed in the table'
|
||||
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
|
||||
// =============================================
|
||||
|
||||
783
e2e/time.spec.ts
783
e2e/time.spec.ts
@@ -15,6 +15,7 @@ import {
|
||||
createBareTimeEntryViaApi,
|
||||
createTimeEntryViaApi,
|
||||
updateOrganizationCurrencyViaWeb,
|
||||
updateOrganizationSettingViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
// Date picker button name patterns for different date formats
|
||||
@@ -38,6 +39,10 @@ function getMonthFromTimestamp(timestamp: string): number {
|
||||
return new Date(timestamp).getUTCMonth() + 1;
|
||||
}
|
||||
|
||||
async function goToProfilePage(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
}
|
||||
|
||||
async function goToTimeOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
}
|
||||
@@ -66,6 +71,14 @@ async function createEmptyTimeEntry(page: Page) {
|
||||
]);
|
||||
}
|
||||
|
||||
async function setTimeEntriesGrouping(page: Page, enabled: boolean) {
|
||||
await goToProfilePage(page);
|
||||
const checkbox = page.getByLabel('Group similar time entries');
|
||||
const isChecked = await checkbox.isChecked();
|
||||
if (isChecked !== enabled) await checkbox.click();
|
||||
await goToTimeOverview(page);
|
||||
}
|
||||
|
||||
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -332,6 +345,30 @@ test.skip('test that load more works when the end of page is reached', async ({
|
||||
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
|
||||
});
|
||||
|
||||
test('test that Group similar time entries option is affected', async ({ page }) => {
|
||||
// Enable grouping
|
||||
await setTimeEntriesGrouping(page, true);
|
||||
|
||||
// Create 2 similar time entries
|
||||
await createEmptyTimeEntry(page);
|
||||
await page.waitForSelector('[data-testid="time_entry_row"]', { timeout: 1000 });
|
||||
await createEmptyTimeEntry(page);
|
||||
|
||||
// Verify similar time entries are grouped
|
||||
await expect(page.getByTestId('grouped_items_count_button').first()).toBeVisible({
|
||||
timeout: 1000,
|
||||
});
|
||||
|
||||
// Disable grouping
|
||||
await setTimeEntriesGrouping(page, false);
|
||||
|
||||
// Verify similar time entries are not grouped
|
||||
await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2, { timeout: 1000 });
|
||||
await expect(page.locator('[data-testid="grouped_items_count_button"]')).toHaveCount(0, {
|
||||
timeout: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Test that updating the time entry start / end times works while it is running
|
||||
|
||||
// TODO: Test for project update
|
||||
@@ -608,7 +645,7 @@ test('test that billable icon shows dollar sign for USD currency on time entry r
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await updateOrganizationCurrencyViaWeb(ctx, 'USD');
|
||||
await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
|
||||
await goToTimeOverview(page);
|
||||
await createEmptyTimeEntry(page);
|
||||
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
||||
@@ -621,7 +658,7 @@ test('test that billable icon shows euro sign for EUR currency on time entry row
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await updateOrganizationCurrencyViaWeb(ctx, 'EUR');
|
||||
await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
|
||||
await goToTimeOverview(page);
|
||||
await createEmptyTimeEntry(page);
|
||||
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
||||
@@ -963,7 +1000,12 @@ test('test that natural language duration input works in create modal', async ({
|
||||
expect(createBody.data.duration).toBe(9000);
|
||||
});
|
||||
|
||||
test('test that decimal duration input works in create modal', async ({ page }) => {
|
||||
test('test that decimal duration input works in create modal', async ({ page, ctx }) => {
|
||||
// Ensure comma-point format so "1.5h" uses period as decimal
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'hours-minutes',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the create modal
|
||||
@@ -978,7 +1020,6 @@ test('test that decimal duration input works in create modal', async ({ page })
|
||||
.fill('Decimal duration test');
|
||||
|
||||
// Test decimal duration input "1.5h" (should be interpreted as 1.5 hours = 90 minutes)
|
||||
// Note: parse-duration library requires a unit suffix for decimal values
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await durationInput.fill('1.5h');
|
||||
await durationInput.press('Tab');
|
||||
@@ -997,6 +1038,508 @@ test('test that decimal duration input works in create modal', async ({ page })
|
||||
expect(createBody.data.duration).toBe(5400);
|
||||
});
|
||||
|
||||
test('test that decimal duration with comma number format does not corrupt on blur in edit modal', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Set organization to decimal interval format with European number format (comma as decimal separator)
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'decimal',
|
||||
number_format: 'point-comma',
|
||||
});
|
||||
|
||||
// Create a 1-hour time entry
|
||||
await createBareTimeEntryViaApi(ctx, 'Decimal blur test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
|
||||
// Open edit modal via the actions dropdown
|
||||
const actionsDropdown = newTimeEntry
|
||||
.getByRole('button', { name: 'Actions for the time entry' })
|
||||
.first();
|
||||
await actionsDropdown.click();
|
||||
await page.getByTestId('time_entry_edit').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// The duration input should show "1,00 h" (decimal format with comma)
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await expect(durationInput).toHaveValue('1,00 h');
|
||||
|
||||
// Click on the duration input and blur it without changing the value
|
||||
await durationInput.click();
|
||||
await durationInput.press('Tab');
|
||||
|
||||
// After blur, the value should remain "1,00 h" and NOT become "100,00 h"
|
||||
await expect(durationInput).toHaveValue('1,00 h');
|
||||
|
||||
// Submit and verify the duration is still 3600 seconds (1 hour)
|
||||
const [updateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||
]);
|
||||
const updateBody = await updateResponse.json();
|
||||
expect(updateBody.data.duration).toBe(3600);
|
||||
|
||||
// Reset organization settings
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'hours-minutes',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
});
|
||||
|
||||
test('test that typing bare decimal 1,5 in edit modal is interpreted as 1.5 hours', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Set organization to decimal interval format with European number format
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'decimal',
|
||||
number_format: 'point-comma',
|
||||
});
|
||||
|
||||
// Create a 1-hour time entry
|
||||
await createBareTimeEntryViaApi(ctx, 'Bare decimal test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
|
||||
// Open edit modal
|
||||
const actionsDropdown = newTimeEntry
|
||||
.getByRole('button', { name: 'Actions for the time entry' })
|
||||
.first();
|
||||
await actionsDropdown.click();
|
||||
await page.getByTestId('time_entry_edit').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Type "1,5" (bare decimal without "h" suffix) — should be interpreted as 1.5 hours
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await durationInput.fill('1,5');
|
||||
await durationInput.press('Tab');
|
||||
|
||||
// Should display as "1,50 h" (1.5 hours formatted in point-comma locale)
|
||||
await expect(durationInput).toHaveValue('1,50 h');
|
||||
|
||||
// Submit and verify the duration is 5400 seconds (1.5 hours)
|
||||
const [updateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||
]);
|
||||
const updateBody = await updateResponse.json();
|
||||
expect(updateBody.data.duration).toBe(5400);
|
||||
|
||||
// Reset organization settings
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'hours-minutes',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
});
|
||||
|
||||
test('test that typing bare decimal 1.5 in edit modal is interpreted as 1.5 hours', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Set organization to decimal interval format with default number format
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'decimal',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
|
||||
// Create a 1-hour time entry
|
||||
await createBareTimeEntryViaApi(ctx, 'Bare decimal dot test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
|
||||
// Open edit modal
|
||||
const actionsDropdown = newTimeEntry
|
||||
.getByRole('button', { name: 'Actions for the time entry' })
|
||||
.first();
|
||||
await actionsDropdown.click();
|
||||
await page.getByTestId('time_entry_edit').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Type "1.5" (bare decimal with period) — should be interpreted as 1.5 hours
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await durationInput.fill('1.5');
|
||||
await durationInput.press('Tab');
|
||||
|
||||
// Should display as "1.50 h" (1.5 hours formatted in comma-point locale)
|
||||
await expect(durationInput).toHaveValue('1.50 h');
|
||||
|
||||
// Submit and verify the duration is 5400 seconds (1.5 hours)
|
||||
const [updateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||
]);
|
||||
const updateBody = await updateResponse.json();
|
||||
expect(updateBody.data.duration).toBe(5400);
|
||||
|
||||
// Reset organization settings
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'hours-minutes',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
});
|
||||
|
||||
test('test that decimal duration with space-comma number format does not corrupt on blur in edit modal', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Set organization to decimal interval format with space-comma number format
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'decimal',
|
||||
number_format: 'space-comma',
|
||||
});
|
||||
|
||||
// Create a 1-hour time entry
|
||||
await createBareTimeEntryViaApi(ctx, 'Space-comma blur test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
|
||||
// Open edit modal
|
||||
const actionsDropdown = newTimeEntry
|
||||
.getByRole('button', { name: 'Actions for the time entry' })
|
||||
.first();
|
||||
await actionsDropdown.click();
|
||||
await page.getByTestId('time_entry_edit').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// The duration input should show "1,00 h" (space-comma uses comma as decimal)
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await expect(durationInput).toHaveValue('1,00 h');
|
||||
|
||||
// Blur without changing the value
|
||||
await durationInput.click();
|
||||
await durationInput.press('Tab');
|
||||
|
||||
// Should remain "1,00 h"
|
||||
await expect(durationInput).toHaveValue('1,00 h');
|
||||
|
||||
// Submit and verify the duration is still 3600 seconds
|
||||
const [updateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||
]);
|
||||
const updateBody = await updateResponse.json();
|
||||
expect(updateBody.data.duration).toBe(3600);
|
||||
|
||||
// Reset organization settings
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'hours-minutes',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
});
|
||||
|
||||
test('test that bare integer in edit modal is interpreted as minutes', async ({ page, ctx }) => {
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'hours-minutes',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
|
||||
await createBareTimeEntryViaApi(ctx, 'Bare integer test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
|
||||
// Open edit modal
|
||||
const actionsDropdown = newTimeEntry
|
||||
.getByRole('button', { name: 'Actions for the time entry' })
|
||||
.first();
|
||||
await actionsDropdown.click();
|
||||
await page.getByTestId('time_entry_edit').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Type "30" — should be interpreted as 30 minutes
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await durationInput.fill('30');
|
||||
await durationInput.press('Tab');
|
||||
|
||||
// Should display as "0h 30min"
|
||||
await expect(durationInput).toHaveValue('0h 30min');
|
||||
|
||||
// Submit and verify the duration is 1800 seconds (30 minutes)
|
||||
const [updateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||
]);
|
||||
const updateBody = await updateResponse.json();
|
||||
expect(updateBody.data.duration).toBe(1800);
|
||||
});
|
||||
|
||||
test('test that bare integer in edit modal with decimal format is interpreted as hours', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'decimal',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
|
||||
await createBareTimeEntryViaApi(ctx, 'Bare integer decimal test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
|
||||
// Open edit modal
|
||||
const actionsDropdown = newTimeEntry
|
||||
.getByRole('button', { name: 'Actions for the time entry' })
|
||||
.first();
|
||||
await actionsDropdown.click();
|
||||
await page.getByTestId('time_entry_edit').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Type "2" — with decimal format, should be interpreted as 2 hours
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await durationInput.fill('2');
|
||||
await durationInput.press('Tab');
|
||||
|
||||
// Should display as "2.00 h"
|
||||
await expect(durationInput).toHaveValue('2.00 h');
|
||||
|
||||
// Submit and verify the duration is 7200 seconds (2 hours)
|
||||
const [updateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||
]);
|
||||
const updateBody = await updateResponse.json();
|
||||
expect(updateBody.data.duration).toBe(7200);
|
||||
|
||||
// Reset organization settings
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'hours-minutes',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
});
|
||||
|
||||
test('test that HH:MM input in edit modal works', async ({ page, ctx }) => {
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'hours-minutes',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
|
||||
await createBareTimeEntryViaApi(ctx, 'HH:MM test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
|
||||
// Open edit modal
|
||||
const actionsDropdown = newTimeEntry
|
||||
.getByRole('button', { name: 'Actions for the time entry' })
|
||||
.first();
|
||||
await actionsDropdown.click();
|
||||
await page.getByTestId('time_entry_edit').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Type "1:30" — should be interpreted as 1 hour 30 minutes
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await durationInput.fill('1:30');
|
||||
await durationInput.press('Tab');
|
||||
|
||||
// Should display as "1h 30min"
|
||||
await expect(durationInput).toHaveValue('1h 30min');
|
||||
|
||||
// Submit and verify the duration is 5400 seconds (1.5 hours)
|
||||
const [updateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
||||
]);
|
||||
const updateBody = await updateResponse.json();
|
||||
expect(updateBody.data.duration).toBe(5400);
|
||||
});
|
||||
|
||||
test('test that bare integer in inline duration input is interpreted as minutes', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'hours-minutes',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
|
||||
await createBareTimeEntryViaApi(ctx, 'Inline bare integer test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
|
||||
// Type "45" in the inline duration input — should be 45 minutes
|
||||
const durationInput = newTimeEntry.getByTestId('time_entry_duration_input').first();
|
||||
await durationInput.click();
|
||||
await durationInput.fill('45');
|
||||
|
||||
const [updateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
durationInput.press('Tab'),
|
||||
]);
|
||||
const updateBody = await updateResponse.json();
|
||||
expect(updateBody.data.duration).toBe(2700);
|
||||
});
|
||||
|
||||
test('test that bare integer in inline duration input with decimal format is interpreted as hours', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'decimal',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
|
||||
await createBareTimeEntryViaApi(ctx, 'Inline bare integer decimal test', '1h');
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
|
||||
const newTimeEntry = timeEntryRows.first();
|
||||
|
||||
// Type "3" in the inline duration input — with decimal format, should be 3 hours
|
||||
const durationInput = newTimeEntry.getByTestId('time_entry_duration_input').first();
|
||||
await durationInput.click();
|
||||
await durationInput.fill('3');
|
||||
|
||||
const [updateResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
durationInput.press('Tab'),
|
||||
]);
|
||||
const updateBody = await updateResponse.json();
|
||||
expect(updateBody.data.duration).toBe(10800);
|
||||
|
||||
// Reset organization settings
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'hours-minutes',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
});
|
||||
|
||||
test('test that bare integer in create modal is interpreted as minutes', async ({ page, ctx }) => {
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'hours-minutes',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the create modal
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill('Bare integer create test');
|
||||
|
||||
// Type "30" — should be interpreted as 30 minutes
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await durationInput.fill('30');
|
||||
await durationInput.press('Tab');
|
||||
|
||||
await expect(durationInput).toHaveValue('0h 30min');
|
||||
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
]);
|
||||
const createBody = await createResponse.json();
|
||||
expect(createBody.data.duration).toBe(1800);
|
||||
});
|
||||
|
||||
test('test that bare integer in create modal with decimal format is interpreted as hours', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'decimal',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// Open the create modal
|
||||
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('textbox', { name: 'Description' })
|
||||
.fill('Bare integer decimal create test');
|
||||
|
||||
// Type "2" — with decimal format, should be interpreted as 2 hours
|
||||
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||
await durationInput.fill('2');
|
||||
await durationInput.press('Tab');
|
||||
|
||||
await expect(durationInput).toHaveValue('2.00 h');
|
||||
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||
),
|
||||
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||
]);
|
||||
const createBody = await createResponse.json();
|
||||
expect(createBody.data.duration).toBe(7200);
|
||||
|
||||
// Reset organization settings
|
||||
await updateOrganizationSettingViaApi(ctx, {
|
||||
interval_format: 'hours-minutes',
|
||||
number_format: 'comma-point',
|
||||
});
|
||||
});
|
||||
|
||||
test('test that project selection works in create modal', async ({ page, ctx }) => {
|
||||
const projectName = 'Create Modal Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createProjectViaApi(ctx, { name: projectName });
|
||||
@@ -1159,6 +1702,8 @@ test('test that end time picker works in create modal', async ({ page }) => {
|
||||
await endTimeInput.press('Tab');
|
||||
|
||||
// Set duration (this will adjust based on the times)
|
||||
// clear() before fill() needed because fill() appends on Firefox instead of replacing
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').clear();
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h');
|
||||
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
|
||||
|
||||
@@ -1450,7 +1995,10 @@ test.describe('Employee Time Entry Isolation', () => {
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await expect(
|
||||
employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
|
||||
employee.page
|
||||
.getByTestId('dashboard_timer')
|
||||
.getByTestId('timer_button')
|
||||
.and(employee.page.locator(':visible'))
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Employee's time entry IS visible
|
||||
@@ -1530,3 +2078,228 @@ test.describe('Employee Time Entry Isolation', () => {
|
||||
await expect(timeEntryRow).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Context Menu Tests
|
||||
// =============================================
|
||||
|
||||
async function openTimeEntryContextMenu(page: Page, description: string) {
|
||||
const row = page
|
||||
.locator('[data-testid="time_entry_row"]')
|
||||
.filter({ hasText: description })
|
||||
.first();
|
||||
await row.click({ button: 'right' });
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
}
|
||||
|
||||
test('test that context menu appears with correct items on time entry row', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const description = 'Context menu items test ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||
|
||||
await goToTimeOverview(page);
|
||||
await openTimeEntryContextMenu(page, description);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Continue' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that context menu edit opens the edit modal', async ({ page, ctx }) => {
|
||||
const description = 'Context edit test ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||
|
||||
await goToTimeOverview(page);
|
||||
await openTimeEntryContextMenu(page, description);
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByRole('dialog').getByPlaceholder('What did you work on?')).toHaveValue(
|
||||
description
|
||||
);
|
||||
});
|
||||
|
||||
test('test that context menu duplicate creates a copy', async ({ page, ctx }) => {
|
||||
const description = 'Context dup test ' + Math.floor(1 + Math.random() * 10000);
|
||||
const project = await createProjectViaApi(ctx, {
|
||||
name: 'Dup Project ' + Math.floor(1 + Math.random() * 10000),
|
||||
is_billable: true,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description,
|
||||
duration: '1h',
|
||||
projectId: project.id,
|
||||
billable: true,
|
||||
});
|
||||
|
||||
await goToTimeOverview(page);
|
||||
await openTimeEntryContextMenu(page, description);
|
||||
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Duplicate' }).click(),
|
||||
]);
|
||||
|
||||
const body = await createResponse.json();
|
||||
expect(body.data.description).toBe(description);
|
||||
expect(body.data.project_id).toBe(project.id);
|
||||
expect(body.data.billable).toBe(true);
|
||||
});
|
||||
|
||||
test('test that context menu continue starts a new time entry', async ({ page, ctx }) => {
|
||||
const description = 'Context continue test ' + Math.floor(1 + Math.random() * 10000);
|
||||
const project = await createProjectViaApi(ctx, {
|
||||
name: 'Continue Project ' + Math.floor(1 + Math.random() * 10000),
|
||||
is_billable: false,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description,
|
||||
duration: '1h',
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
await goToTimeOverview(page);
|
||||
await openTimeEntryContextMenu(page, description);
|
||||
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Continue' }).click(),
|
||||
]);
|
||||
|
||||
const body = await createResponse.json();
|
||||
expect(body.data.description).toBe(description);
|
||||
expect(body.data.project_id).toBe(project.id);
|
||||
expect(body.data.end).toBeNull();
|
||||
});
|
||||
|
||||
test('test that context menu delete removes the time entry', async ({ page, ctx }) => {
|
||||
const description = 'Context delete test ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||
|
||||
await goToTimeOverview(page);
|
||||
await openTimeEntryContextMenu(page, description);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') && response.request().method() === 'DELETE'
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="time_entry_row"]').filter({ hasText: description })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that aggregate row context menu shows only Continue and Delete', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const description = 'Context agg items ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||
await createBareTimeEntryViaApi(ctx, description, '30min');
|
||||
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const aggregateRow = page
|
||||
.locator('[data-testid="time_entry_row"]')
|
||||
.filter({ hasText: description })
|
||||
.first();
|
||||
await aggregateRow.click({ button: 'right' });
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Continue' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Edit' })).not.toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('test that aggregate row context menu continue starts a new time entry', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const description = 'Context agg continue ' + Math.floor(1 + Math.random() * 10000);
|
||||
const project = await createProjectViaApi(ctx, {
|
||||
name: 'Agg Continue Project ' + Math.floor(1 + Math.random() * 10000),
|
||||
is_billable: false,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description,
|
||||
duration: '1h',
|
||||
projectId: project.id,
|
||||
});
|
||||
await createTimeEntryViaApi(ctx, {
|
||||
description,
|
||||
duration: '30min',
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
await goToTimeOverview(page);
|
||||
|
||||
const aggregateRow = page
|
||||
.locator('[data-testid="time_entry_row"]')
|
||||
.filter({ hasText: description })
|
||||
.first();
|
||||
await aggregateRow.click({ button: 'right' });
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Continue' }).click(),
|
||||
]);
|
||||
|
||||
const body = await createResponse.json();
|
||||
expect(body.data.description).toBe(description);
|
||||
expect(body.data.project_id).toBe(project.id);
|
||||
expect(body.data.end).toBeNull();
|
||||
});
|
||||
|
||||
test('test that aggregate row context menu delete removes all grouped entries', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
const description = 'Context agg delete ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createBareTimeEntryViaApi(ctx, description, '1h');
|
||||
await createBareTimeEntryViaApi(ctx, description, '30min');
|
||||
|
||||
await goToTimeOverview(page);
|
||||
|
||||
// The aggregate row groups entries with same description
|
||||
const aggregateRow = page
|
||||
.locator('[data-testid="time_entry_row"]')
|
||||
.filter({ hasText: description })
|
||||
.first();
|
||||
await aggregateRow.click({ button: 'right' });
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') && response.request().method() === 'DELETE'
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="time_entry_row"]').filter({ hasText: description })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from './utils/currentTimeEntry';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { newTagResponse } from './utils/tags';
|
||||
import { updateOrganizationCurrencyViaWeb } from './utils/api';
|
||||
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}$/;
|
||||
@@ -30,7 +30,7 @@ test('test that starting and stopping a timer without description and project wo
|
||||
});
|
||||
|
||||
test('test that billable icon shows dollar sign for USD currency', async ({ page, ctx }) => {
|
||||
await updateOrganizationCurrencyViaWeb(ctx, 'USD');
|
||||
await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
|
||||
await goToDashboard(page);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
|
||||
@@ -39,7 +39,7 @@ test('test that billable icon shows dollar sign for USD currency', async ({ page
|
||||
});
|
||||
|
||||
test('test that billable icon shows euro sign for EUR currency', async ({ page, ctx }) => {
|
||||
await updateOrganizationCurrencyViaWeb(ctx, 'EUR');
|
||||
await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
|
||||
await goToDashboard(page);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
|
||||
@@ -354,7 +354,10 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
|
||||
|
||||
// Timer should still be running (the timer button should be red/active)
|
||||
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-red-400\/80/);
|
||||
|
||||
// Stop the timer
|
||||
@@ -365,6 +368,45 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
|
||||
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);
|
||||
|
||||
304
e2e/utils/api.ts
304
e2e/utils/api.ts
@@ -16,12 +16,59 @@ export interface TestContext {
|
||||
// Auth helpers
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
async function getApiHeaders(page: Page): Promise<Record<string, string>> {
|
||||
const cookies = await page.context().cookies();
|
||||
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
|
||||
/**
|
||||
* 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',
|
||||
...(xsrfCookie ? { 'X-XSRF-TOKEN': decodeURIComponent(xsrfCookie.value) } : {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,8 +77,10 @@ async function getApiHeaders(page: Page): Promise<Record<string, string>> {
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function setupTestContext(page: Page): Promise<TestContext> {
|
||||
const token = await createApiToken(page);
|
||||
const request = page.request;
|
||||
const headers = await getApiHeaders(page);
|
||||
const headers = bearerHeaders(token);
|
||||
|
||||
const orgId = await getOrganizationId(request, headers);
|
||||
const memberId = await getCurrentMemberId(request, orgId, headers);
|
||||
return { request: createAuthenticatedRequest(request, headers), orgId, memberId };
|
||||
@@ -201,6 +250,37 @@ export async function createProjectViaApi(
|
||||
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 }
|
||||
@@ -314,6 +394,36 @@ export async function createProjectMemberViaApi(
|
||||
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)
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -363,6 +473,25 @@ export async function createTimeEntryWithTagViaApi(
|
||||
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,
|
||||
@@ -430,11 +559,17 @@ export async function updateOrganizationSettingViaApi(
|
||||
}
|
||||
|
||||
export async function updateOrganizationCurrencyViaWeb(
|
||||
page: Page,
|
||||
ctx: TestContext,
|
||||
currency: string,
|
||||
name: string = 'Test Organization'
|
||||
) {
|
||||
const response = await ctx.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, {
|
||||
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);
|
||||
@@ -468,7 +603,164 @@ 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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,12 +2,19 @@ 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 expect(
|
||||
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
|
||||
page
|
||||
.getByTestId('dashboard_timer')
|
||||
.getByTestId('timer_button')
|
||||
.and(page.locator(':visible'))
|
||||
).toHaveClass(/bg-red-400\/80/);
|
||||
}
|
||||
|
||||
@@ -34,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/);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@ export async function getInvitationAcceptUrl(
|
||||
expect(searchResult.messages.length).toBeGreaterThan(0);
|
||||
|
||||
const message = await getMessage(request, searchResult.messages[0].ID);
|
||||
const acceptUrlMatch = message.HTML.match(/href="([^"]*team-invitations[^"]*)"/);
|
||||
const acceptUrlMatch = message.HTML.match(
|
||||
/href="([^"]*(?:organization-invitations|team-invitations)[^"]*)"/
|
||||
);
|
||||
expect(acceptUrlMatch).toBeTruthy();
|
||||
|
||||
return acceptUrlMatch![1].replace(/&/g, '&');
|
||||
|
||||
@@ -68,6 +68,85 @@ export async function inviteAndAcceptMember(
|
||||
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.
|
||||
|
||||
16
e2e/utils/table.ts
Normal file
16
e2e/utils/table.ts
Normal 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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user