mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 05:02:14 +01:00
Compare commits
166 Commits
feature/em
...
433a6f3770
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
5a05ee35e0 | ||
|
|
00d9d1488e | ||
|
|
9bbbfdfafe | ||
|
|
d27f023e16 | ||
|
|
db57055941 | ||
|
|
743c64909a | ||
|
|
de97d15925 | ||
|
|
0691fe10ef | ||
|
|
513b2048ee | ||
|
|
3acf9b8b07 | ||
|
|
814d539fb0 | ||
|
|
7a51fca2f9 | ||
|
|
280032ee02 |
12
.env.ci
12
.env.ci
@@ -34,7 +34,12 @@ SESSION_DRIVER=database
|
|||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
# Mail
|
# Mail
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=smtp
|
||||||
|
MAIL_HOST=localhost
|
||||||
|
MAIL_PORT=1025
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_ENCRYPTION=null
|
||||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||||
MAIL_FROM_NAME="solidtime"
|
MAIL_FROM_NAME="solidtime"
|
||||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||||
@@ -55,4 +60,7 @@ AUDITING_ENABLED=true
|
|||||||
TELESCOPE_ENABLED=false
|
TELESCOPE_ENABLED=false
|
||||||
|
|
||||||
# Services
|
# 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
|
# Services
|
||||||
GOTENBERG_URL=http://gotenberg:3000
|
GOTENBERG_URL=http://gotenberg:3000
|
||||||
|
|
||||||
|
# Octane
|
||||||
|
OCTANE_SERVER=frankenphp
|
||||||
|
|
||||||
# Local setup
|
# Local setup
|
||||||
NGINX_HOST_NAME=solidtime.test
|
NGINX_HOST_NAME=solidtime.test
|
||||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
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
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|||||||
10
.github/workflows/build-private.yml
vendored
10
.github/workflows/build-private.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||||
|
|
||||||
@@ -68,12 +68,12 @@ jobs:
|
|||||||
run: cat .env
|
run: cat .env
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
- name: "Checkout billing extension"
|
- name: "Checkout billing extension"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: solidtime-io/extension-billing
|
repository: solidtime-io/extension-billing
|
||||||
path: extensions/Billing
|
path: extensions/Billing
|
||||||
@@ -93,7 +93,7 @@ jobs:
|
|||||||
run: cd extensions/Billing && npm ci
|
run: cd extensions/Billing && npm ci
|
||||||
|
|
||||||
- name: "Checkout services extension"
|
- name: "Checkout services extension"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: solidtime-io/extension-services
|
repository: solidtime-io/extension-services
|
||||||
path: extensions/Services
|
path: extensions/Services
|
||||||
@@ -111,7 +111,7 @@ jobs:
|
|||||||
run: cd extensions/Services && npm ci
|
run: cd extensions/Services && npm ci
|
||||||
|
|
||||||
- name: "Checkout invoicing extension"
|
- name: "Checkout invoicing extension"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: solidtime-io/extension-invoicing
|
repository: solidtime-io/extension-invoicing
|
||||||
path: extensions/Invoicing
|
path: extensions/Invoicing
|
||||||
|
|||||||
6
.github/workflows/build-public.yml
vendored
6
.github/workflows/build-public.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out code"
|
- name: "Check out code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
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
|
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ jobs:
|
|||||||
- build
|
- build
|
||||||
steps:
|
steps:
|
||||||
- name: "Download digests"
|
- name: "Download digests"
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: 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:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
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:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP (for Ziggy)"
|
- name: "Setup PHP (for Ziggy)"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
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:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/npm-lint.yml
vendored
4
.github/workflows/npm-lint.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
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
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- name: Install root project dependencies
|
- name: Install root project dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
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
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
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:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP (for Ziggy)"
|
- name: "Setup PHP (for Ziggy)"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- name: "Use Node.js"
|
- name: "Use Node.js"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/phpstan.yml
vendored
2
.github/workflows/phpstan.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
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
|
--health-retries 5
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup PHP"
|
- name: "Setup PHP"
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
- name: "Run composer install"
|
- name: "Run composer install"
|
||||||
run: composer install -n --prefer-dist
|
run: composer install -n --prefer-dist
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||||
|
|
||||||
- name: "Upload coverage reports to Codecov"
|
- name: "Upload coverage reports to Codecov"
|
||||||
uses: codecov/codecov-action@v5.4.3
|
uses: codecov/codecov-action@v5.5.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: solidtime-io/solidtime
|
slug: solidtime-io/solidtime
|
||||||
|
|||||||
4
.github/workflows/pint.yml
vendored
4
.github/workflows/pint.yml
vendored
@@ -9,9 +9,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Check code style"
|
- name: "Check code style"
|
||||||
uses: aglipanci/laravel-pint-action@2.5
|
uses: aglipanci/laravel-pint-action@2.6
|
||||||
with:
|
with:
|
||||||
configPath: "pint.json"
|
configPath: "pint.json"
|
||||||
|
|||||||
67
.github/workflows/playwright.yml
vendored
67
.github/workflows/playwright.yml
vendored
@@ -6,10 +6,18 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||||
|
shardTotal: [8]
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mailpit:
|
mailpit:
|
||||||
image: 'axllent/mailpit:latest'
|
image: 'axllent/mailpit:latest'
|
||||||
|
ports:
|
||||||
|
- 1025:1025
|
||||||
|
- 8025:8025
|
||||||
pgsql_test:
|
pgsql_test:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
env:
|
env:
|
||||||
@@ -27,10 +35,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: "Setup node"
|
- name: "Setup node"
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20.x'
|
node-version: '20.x'
|
||||||
|
|
||||||
@@ -57,22 +65,63 @@ jobs:
|
|||||||
- name: "Build Frontend"
|
- name: "Build Frontend"
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: "Run Laravel Server"
|
- name: "Install FrankenPHP"
|
||||||
run: php artisan serve > /dev/null 2>&1 &
|
run: |
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
curl -fsSL "https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}" -o /usr/local/bin/frankenphp
|
||||||
|
chmod +x /usr/local/bin/frankenphp
|
||||||
|
|
||||||
|
- name: "Run Laravel Octane Server"
|
||||||
|
run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 &
|
||||||
|
env:
|
||||||
|
OCTANE_SERVER: frankenphp
|
||||||
|
|
||||||
- name: "Install Playwright Browsers"
|
- name: "Install Playwright Browsers"
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: "Run Playwright tests"
|
- name: "Run Playwright tests"
|
||||||
run: npx playwright test
|
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
||||||
|
MAILPIT_BASE_URL: 'http://localhost:8025'
|
||||||
|
|
||||||
- name: "Upload test results"
|
- name: "Upload blob report"
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: blob-report-${{ matrix.shardIndex }}
|
||||||
path: test-results/
|
path: blob-report/
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
|
|
||||||
|
merge-reports:
|
||||||
|
if: always()
|
||||||
|
needs: [test]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: "Setup node"
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.x'
|
||||||
|
|
||||||
|
- name: "Install dependencies"
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: "Download blob reports"
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: all-blob-reports
|
||||||
|
pattern: blob-report-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: "Merge reports"
|
||||||
|
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||||
|
|
||||||
|
- name: "Upload merged HTML report"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 30
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,3 +42,4 @@ yarn-error.log
|
|||||||
/data
|
/data
|
||||||
/config/caddy
|
/config/caddy
|
||||||
/config/composer
|
/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://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
||||||
[](https://codecov.io/gh/solidtime-io/solidtime)
|
[](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.
|
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.
|
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.
|
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
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
|
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)
|
||||||
|
|||||||
@@ -4,18 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Exceptions\MovedToApiException;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
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;
|
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||||
|
|
||||||
class AddOrganizationMember implements 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
|
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||||
{
|
{
|
||||||
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
|
throw new MovedToApiException;
|
||||||
|
|
||||||
$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.')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class CreateOrganization implements CreatesTeams
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function create(User $user, array $input): Organization
|
public function create(User $user, array $input): Organization
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Delete the given team.
|
* Delete the given team.
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function delete(Organization $organization): void
|
public function delete(Organization $organization): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class DeleteUser implements DeletesUsers
|
|||||||
* Delete the given user.
|
* Delete the given user.
|
||||||
*
|
*
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function delete(User $user): void
|
public function delete(User $user): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ class ValidateOrganizationDeletion
|
|||||||
* @param Organization $organization Organization to be deleted
|
* @param Organization $organization Organization to be deleted
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function validate(User $user, Organization $organization): void
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\TimeEntryResource\Pages;
|
use App\Filament\Resources\TimeEntryResource\Pages;
|
||||||
|
use App\Models\Member;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@@ -16,6 +17,7 @@ use Filament\Tables;
|
|||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class TimeEntryResource extends Resource
|
class TimeEntryResource extends Resource
|
||||||
{
|
{
|
||||||
@@ -51,15 +53,23 @@ class TimeEntryResource extends Resource
|
|||||||
->rules([
|
->rules([
|
||||||
'after_or_equal:start',
|
'after_or_equal:start',
|
||||||
]),
|
]),
|
||||||
Select::make('user_id')
|
Select::make('member_id')
|
||||||
->relationship(name: 'user', titleAttribute: 'email')
|
->relationship(
|
||||||
->searchable(['name', 'email'])
|
name: 'member',
|
||||||
|
titleAttribute: 'id',
|
||||||
|
modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])
|
||||||
|
)
|
||||||
|
->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')
|
||||||
|
->searchable()
|
||||||
->required(),
|
->required(),
|
||||||
Select::make('project_id')
|
Select::make('project_id')
|
||||||
->relationship(name: 'project', titleAttribute: 'name')
|
->relationship(name: 'project', titleAttribute: 'name')
|
||||||
->searchable(['name'])
|
->searchable(['name'])
|
||||||
->nullable(),
|
->nullable(),
|
||||||
// TODO
|
Select::make('task_id')
|
||||||
|
->relationship(name: 'task', titleAttribute: 'name')
|
||||||
|
->searchable(['name'])
|
||||||
|
->nullable(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,28 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TimeEntryResource;
|
use App\Filament\Resources\TimeEntryResource;
|
||||||
|
use App\Models\Member;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateTimeEntry extends CreateRecord
|
class CreateTimeEntry extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = TimeEntryResource::class;
|
protected static string $resource = TimeEntryResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
if (isset($data['member_id'])) {
|
||||||
|
/** @var Member|null $member */
|
||||||
|
$member = Member::query()->find($data['member_id']);
|
||||||
|
if ($member !== null) {
|
||||||
|
$data['user_id'] = $member->user_id;
|
||||||
|
$data['organization_id'] = $member->organization_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TimeEntryResource;
|
use App\Filament\Resources\TimeEntryResource;
|
||||||
|
use App\Models\Member;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
@@ -19,4 +20,22 @@ class EditTimeEntry extends EditRecord
|
|||||||
->icon('heroicon-m-trash'),
|
->icon('heroicon-m-trash'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
if (isset($data['member_id'])) {
|
||||||
|
/** @var Member|null $member */
|
||||||
|
$member = Member::query()->find($data['member_id']);
|
||||||
|
if ($member !== null) {
|
||||||
|
$data['user_id'] = $member->user_id;
|
||||||
|
$data['organization_id'] = $member->organization_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class ApiTokenController extends Controller
|
|||||||
/** @var Builder<Client> $query */
|
/** @var Builder<Client> $query */
|
||||||
$query->whereJsonContains('grant_types', 'personal_access');
|
$query->whereJsonContains('grant_types', 'personal_access');
|
||||||
})
|
})
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return new ApiTokenCollection($tokens);
|
return new ApiTokenCollection($tokens);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class ChartController extends Controller
|
|||||||
$this->checkPermission($organization, 'charts:view:own');
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
|
|
||||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 100);
|
||||||
|
|
||||||
return response()->json($dailyTrackedHours);
|
return response()->json($dailyTrackedHours);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class InvitationController extends Controller
|
|||||||
$this->checkPermission($organization, 'invitations:view');
|
$this->checkPermission($organization, 'invitations:view');
|
||||||
|
|
||||||
$invitations = $organization->teamInvitations()
|
$invitations = $organization->teamInvitations()
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return InvitationCollection::make($invitations);
|
return InvitationCollection::make($invitations);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class MemberController extends Controller
|
|||||||
$members = Member::query()
|
$members = Member::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->with(['user'])
|
->with(['user'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return MemberCollection::make($members);
|
return MemberCollection::make($members);
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
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\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
|
use App\Service\OrganizationService;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class OrganizationController extends Controller
|
class OrganizationController extends Controller
|
||||||
{
|
{
|
||||||
@@ -46,6 +52,9 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
||||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||||
}
|
}
|
||||||
|
if ($request->getEmployeesCanManageTasks() !== null) {
|
||||||
|
$organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks();
|
||||||
|
}
|
||||||
if ($request->getNumberFormat() !== null) {
|
if ($request->getNumberFormat() !== null) {
|
||||||
$organization->number_format = $request->getNumberFormat();
|
$organization->number_format = $request->getNumberFormat();
|
||||||
}
|
}
|
||||||
@@ -77,4 +86,48 @@ class OrganizationController extends Controller
|
|||||||
|
|
||||||
return new OrganizationResource($organization, true);
|
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');
|
$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;
|
$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
|
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,
|
// 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.
|
// 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\InactiveUserCanNotBeUsedApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
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\ProjectMemberStoreRequest;
|
||||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
|
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
|
||||||
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
|
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
|
||||||
@@ -41,12 +42,13 @@ class ProjectMemberController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId getProjectMembers
|
* @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);
|
$this->checkPermission($organization, 'project-members:view', $project);
|
||||||
|
|
||||||
$projectMembers = ProjectMember::query()
|
$projectMembers = ProjectMember::query()
|
||||||
->whereBelongsTo($project, 'project')
|
->whereBelongsTo($project, 'project')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new ProjectMemberCollection($projectMembers);
|
return new ProjectMemberCollection($projectMembers);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
|
use App\Http\Requests\V1\Report\ReportIndexRequest;
|
||||||
use App\Http\Requests\V1\Report\ReportStoreRequest;
|
use App\Http\Requests\V1\Report\ReportStoreRequest;
|
||||||
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
||||||
use App\Http\Resources\V1\Report\DetailedReportResource;
|
use App\Http\Resources\V1\Report\DetailedReportResource;
|
||||||
@@ -40,7 +41,7 @@ class ReportController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId getReports
|
* @operationId getReports
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization): ReportCollection
|
public function index(Organization $organization, ReportIndexRequest $request): ReportCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'reports:view');
|
$this->checkPermission($organization, 'reports:view');
|
||||||
|
|
||||||
@@ -150,6 +151,9 @@ class ReportController extends Controller
|
|||||||
$report->share_secret = null;
|
$report->share_secret = null;
|
||||||
$report->public_until = null;
|
$report->public_until = null;
|
||||||
}
|
}
|
||||||
|
} elseif ($report->is_public && $request->has('public_until')) {
|
||||||
|
// Allow updating expiration date on already-public reports
|
||||||
|
$report->public_until = $request->getPublicUntil();
|
||||||
}
|
}
|
||||||
$report->save();
|
$report->save();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
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\TagStoreRequest;
|
||||||
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
||||||
use App\Http\Resources\V1\Tag\TagCollection;
|
use App\Http\Resources\V1\Tag\TagCollection;
|
||||||
@@ -34,7 +35,7 @@ class TagController extends Controller
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization): TagCollection
|
public function index(Organization $organization, TagIndexRequest $request): TagCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'tags:view');
|
$this->checkPermission($organization, 'tags:view');
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
|
|||||||
use App\Http\Resources\V1\Task\TaskCollection;
|
use App\Http\Resources\V1\Task\TaskCollection;
|
||||||
use App\Http\Resources\V1\Task\TaskResource;
|
use App\Http\Resources\V1\Task\TaskResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Models\Project;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -27,6 +28,26 @@ class TaskController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check scoped permission and verify user has access to the project
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, $permission);
|
||||||
|
|
||||||
|
$user = $this->user();
|
||||||
|
$hasAccess = Project::query()
|
||||||
|
->where('id', $project->id)
|
||||||
|
->visibleByEmployee($user)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $hasAccess) {
|
||||||
|
throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tasks
|
* Get tasks
|
||||||
*
|
*
|
||||||
@@ -61,7 +82,9 @@ class TaskController extends Controller
|
|||||||
$query->whereNull('done_at');
|
$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);
|
return new TaskCollection($tasks);
|
||||||
}
|
}
|
||||||
@@ -75,7 +98,15 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'tasks:create');
|
/** @var Project $project */
|
||||||
|
$project = Project::query()->findOrFail($request->input('project_id'));
|
||||||
|
|
||||||
|
if ($this->hasPermission($organization, 'tasks:create:all')) {
|
||||||
|
$this->checkPermission($organization, 'tasks:create:all');
|
||||||
|
} else {
|
||||||
|
$this->checkScopedPermissionForProject($organization, $project, 'tasks:create');
|
||||||
|
}
|
||||||
|
|
||||||
$task = new Task;
|
$task = new Task;
|
||||||
$task->name = $request->input('name');
|
$task->name = $request->input('name');
|
||||||
$task->project_id = $request->input('project_id');
|
$task->project_id = $request->input('project_id');
|
||||||
@@ -97,7 +128,17 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
|
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'tasks:update', $task);
|
// Check task belongs to organization
|
||||||
|
if ($task->organization_id !== $organization->id) {
|
||||||
|
throw new AuthorizationException('Task does not belong to organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasPermission($organization, 'tasks:update:all')) {
|
||||||
|
$this->checkPermission($organization, 'tasks:update:all');
|
||||||
|
} else {
|
||||||
|
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');
|
||||||
|
}
|
||||||
|
|
||||||
$task->name = $request->input('name');
|
$task->name = $request->input('name');
|
||||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||||
$task->estimated_time = $request->getEstimatedTime();
|
$task->estimated_time = $request->getEstimatedTime();
|
||||||
@@ -119,7 +160,16 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Organization $organization, Task $task): JsonResponse
|
public function destroy(Organization $organization, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'tasks:delete', $task);
|
// Check task belongs to organization
|
||||||
|
if ($task->organization_id !== $organization->id) {
|
||||||
|
throw new AuthorizationException('Task does not belong to organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasPermission($organization, 'tasks:delete:all')) {
|
||||||
|
$this->checkPermission($organization, 'tasks:delete:all');
|
||||||
|
} else {
|
||||||
|
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');
|
||||||
|
}
|
||||||
|
|
||||||
if ($task->timeEntries()->exists()) {
|
if ($task->timeEntries()->exists()) {
|
||||||
throw new EntityStillInUseApiException('task', 'time_entry');
|
throw new EntityStillInUseApiException('task', 'time_entry');
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ use Illuminate\Support\Facades\Blade;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
|
|
||||||
@@ -246,7 +247,7 @@ class TimeEntryController extends Controller
|
|||||||
'user',
|
'user',
|
||||||
'tagsRelation',
|
'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';
|
$folderPath = 'exports';
|
||||||
$path = $folderPath.'/'.$filename;
|
$path = $folderPath.'/'.$filename;
|
||||||
$localizationService = LocalizationService::forOrganization($organization);
|
$localizationService = LocalizationService::forOrganization($organization);
|
||||||
@@ -469,7 +470,7 @@ class TimeEntryController extends Controller
|
|||||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||||
$localizationService = LocalizationService::forOrganization($organization);
|
$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';
|
$folderPath = 'exports';
|
||||||
$path = $folderPath.'/'.$filename;
|
$path = $folderPath.'/'.$filename;
|
||||||
|
|
||||||
@@ -628,9 +629,9 @@ class TimeEntryController extends Controller
|
|||||||
/** @var Member|null $member */
|
/** @var Member|null $member */
|
||||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
$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())) {
|
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 {
|
} 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) {
|
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
use App\Http\Resources\V1\User\UserResource;
|
use App\Http\Resources\V1\User\UserResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\DeletionService;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
@@ -24,4 +28,29 @@ class UserController extends Controller
|
|||||||
|
|
||||||
return new UserResource($user);
|
return new UserResource($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the deletion of a user.
|
||||||
|
*
|
||||||
|
* This endpoint is independent of 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
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\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
public function dashboard(): Response
|
||||||
* @throws AuthorizationException
|
|
||||||
*/
|
|
||||||
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): 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');
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,8 @@ class HandleInertiaRequests extends Middleware
|
|||||||
] : null,
|
] : null,
|
||||||
'flash' => [
|
'flash' => [
|
||||||
'message' => fn () => $request->session()->get('message'),
|
'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()),
|
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
|
||||||
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
|
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
|
||||||
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
|
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
|
||||||
'flash' => $request->session()->get('flash', []),
|
|
||||||
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
|
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
|
||||||
'hasApiFeatures' => Jetstream::hasApiFeatures(),
|
'hasApiFeatures' => Jetstream::hasApiFeatures(),
|
||||||
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
|
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ class InvitationIndexRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ class MemberIndexRequest extends BaseFormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,9 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
'employees_can_see_billable_rates' => [
|
'employees_can_see_billable_rates' => [
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
|
'employees_can_manage_tasks' => [
|
||||||
|
'boolean',
|
||||||
|
],
|
||||||
'prevent_overlapping_time_entries' => [
|
'prevent_overlapping_time_entries' => [
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
@@ -102,6 +105,11 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
|||||||
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getEmployeesCanManageTasks(): ?bool
|
||||||
|
{
|
||||||
|
return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null;
|
||||||
|
}
|
||||||
|
|
||||||
public function getPreventOverlappingTimeEntries(): ?bool
|
public function getPreventOverlappingTimeEntries(): ?bool
|
||||||
{
|
{
|
||||||
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
|
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,11 @@ use App\Enums\TimeEntryRoundingType;
|
|||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
|
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +25,7 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule|LegacyValidationRule>>
|
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -81,7 +83,14 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'properties.client_ids.*' => [
|
'properties.client_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! Str::isUuid($value)) {
|
||||||
|
$fail('The '.$attribute.' must be a valid UUID.');
|
||||||
|
}
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by project IDs, project IDs are OR combined
|
// Filter by project IDs, project IDs are OR combined
|
||||||
'properties.project_ids' => [
|
'properties.project_ids' => [
|
||||||
@@ -90,7 +99,14 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'properties.project_ids.*' => [
|
'properties.project_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! Str::isUuid($value)) {
|
||||||
|
$fail('The '.$attribute.' must be a valid UUID.');
|
||||||
|
}
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by tag IDs, tag IDs are OR combined
|
// Filter by tag IDs, tag IDs are OR combined
|
||||||
'properties.tag_ids' => [
|
'properties.tag_ids' => [
|
||||||
@@ -99,7 +115,14 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'properties.tag_ids.*' => [
|
'properties.tag_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! Str::isUuid($value)) {
|
||||||
|
$fail('The '.$attribute.' must be a valid UUID.');
|
||||||
|
}
|
||||||
|
},
|
||||||
],
|
],
|
||||||
'properties.task_ids' => [
|
'properties.task_ids' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
@@ -107,7 +130,14 @@ class ReportStoreRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'properties.task_ids.*' => [
|
'properties.task_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! Str::isUuid($value)) {
|
||||||
|
$fail('The '.$attribute.' must be a valid UUID.');
|
||||||
|
}
|
||||||
|
},
|
||||||
],
|
],
|
||||||
'properties.group' => [
|
'properties.group' => [
|
||||||
'required',
|
'required',
|
||||||
|
|||||||
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
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'page' => [
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
'max:2147483647',
|
||||||
|
],
|
||||||
'project_id' => [
|
'project_id' => [
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use App\Models\Project;
|
|||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -30,7 +31,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -94,10 +95,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'project_ids.*' => [
|
'project_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Project> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Project> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by client IDs, client IDs are OR combined
|
// Filter by client IDs, client IDs are OR combined
|
||||||
'client_ids' => [
|
'client_ids' => [
|
||||||
@@ -106,10 +112,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'client_ids.*' => [
|
'client_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Client> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Client> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by tag IDs, tag IDs are OR combined
|
// Filter by tag IDs, tag IDs are OR combined
|
||||||
'tag_ids' => [
|
'tag_ids' => [
|
||||||
@@ -118,10 +129,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'tag_ids.*' => [
|
'tag_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Tag> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Tag> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
@@ -130,9 +146,14 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'task_ids.*' => [
|
'task_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
})->uuid(),
|
return;
|
||||||
|
}
|
||||||
|
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||||
'start' => [
|
'start' => [
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Models\Project;
|
|||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -28,7 +29,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -80,10 +81,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'project_ids.*' => [
|
'project_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Project> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Project> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by client IDs, client IDs are OR combined
|
// Filter by client IDs, client IDs are OR combined
|
||||||
'client_ids' => [
|
'client_ids' => [
|
||||||
@@ -92,10 +98,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'client_ids.*' => [
|
'client_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Client> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Client> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by tag IDs, tag IDs are OR combined
|
// Filter by tag IDs, tag IDs are OR combined
|
||||||
'tag_ids' => [
|
'tag_ids' => [
|
||||||
@@ -104,10 +115,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'tag_ids.*' => [
|
'tag_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Tag> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Tag> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
@@ -116,9 +132,14 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'task_ids.*' => [
|
'task_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
})->uuid(),
|
return;
|
||||||
|
}
|
||||||
|
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||||
'start' => [
|
'start' => [
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ namespace App\Http\Requests\V1\TimeEntry;
|
|||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -25,7 +27,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -57,6 +59,23 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
// Filter by client IDs, client IDs are OR combined
|
||||||
|
'client_ids' => [
|
||||||
|
'array',
|
||||||
|
'min:1',
|
||||||
|
],
|
||||||
|
'client_ids.*' => [
|
||||||
|
'string',
|
||||||
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Client> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
|
],
|
||||||
// Filter by project IDs, project IDs are OR combined
|
// Filter by project IDs, project IDs are OR combined
|
||||||
'project_ids' => [
|
'project_ids' => [
|
||||||
'array',
|
'array',
|
||||||
@@ -64,11 +83,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
],
|
],
|
||||||
'project_ids.*' => [
|
'project_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
/** @var Builder<Project> $builder */
|
return;
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
}
|
||||||
}),
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Project> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by tag IDs, tag IDs are OR combined
|
// Filter by tag IDs, tag IDs are OR combined
|
||||||
'tag_ids' => [
|
'tag_ids' => [
|
||||||
@@ -77,11 +100,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
],
|
],
|
||||||
'tag_ids.*' => [
|
'tag_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
/** @var Builder<Tag> $builder */
|
return;
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
}
|
||||||
}),
|
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Tag> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
@@ -90,11 +117,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
],
|
],
|
||||||
'task_ids.*' => [
|
'task_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
'uuid',
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
/** @var Builder<Task> $builder */
|
return;
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
}
|
||||||
}),
|
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Task> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||||
'start' => [
|
'start' => [
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Contracts\Validation\Rule as RuleContract;
|
use Illuminate\Contracts\Validation\Rule as RuleContract;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -26,7 +27,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule|RuleContract>>
|
* @return array<string, array<string|ValidationRule|RuleContract|\Closure>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -58,10 +59,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'client_ids.*' => [
|
'client_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Client> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Client> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by project IDs, project IDs are OR combined
|
// Filter by project IDs, project IDs are OR combined
|
||||||
'project_ids' => [
|
'project_ids' => [
|
||||||
@@ -70,10 +76,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'project_ids.*' => [
|
'project_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Project> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Project> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by tag IDs, tag IDs are OR combined
|
// Filter by tag IDs, tag IDs are OR combined
|
||||||
'tag_ids' => [
|
'tag_ids' => [
|
||||||
@@ -82,10 +93,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'tag_ids.*' => [
|
'tag_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Tag> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Tag> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter by task IDs, task IDs are OR combined
|
// Filter by task IDs, task IDs are OR combined
|
||||||
'task_ids' => [
|
'task_ids' => [
|
||||||
@@ -94,10 +110,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
],
|
],
|
||||||
'task_ids.*' => [
|
'task_ids.*' => [
|
||||||
'string',
|
'string',
|
||||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
/** @var Builder<Task> $builder */
|
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return;
|
||||||
})->uuid(),
|
}
|
||||||
|
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||||
|
/** @var Builder<Task> $builder */
|
||||||
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
})->uuid()->validate($attribute, $value, $fail);
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||||
'start' => [
|
'start' => [
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,7 +44,16 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
|||||||
'required_with:task_id',
|
'required_with:task_id',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
|
||||||
|
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||||
|
$permissionStore = app(PermissionStore::class);
|
||||||
|
if (! $permissionStore->has($this->organization, 'time-entries:create:all')
|
||||||
|
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||||
|
$builder = $builder->visibleByEmployee(Auth::user());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder;
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +56,16 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
|||||||
'required_with:task_id',
|
'required_with:task_id',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
|
||||||
|
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||||
|
$permissionStore = app(PermissionStore::class);
|
||||||
|
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
|
||||||
|
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||||
|
$builder = $builder->visibleByEmployee(Auth::user());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder;
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,7 +44,16 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
|||||||
'required_with:task_id',
|
'required_with:task_id',
|
||||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
|
|
||||||
|
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||||
|
$permissionStore = app(PermissionStore::class);
|
||||||
|
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
|
||||||
|
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||||
|
$builder = $builder->visibleByEmployee(Auth::user());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder;
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// ID of the task that the time entry should belong to
|
// ID of the task that the time entry should belong to
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Client;
|
namespace App\Http\Resources\V1\Client;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class ClientCollection extends ResourceCollection
|
class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ class OrganizationResource extends BaseResource
|
|||||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||||
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
|
||||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||||
|
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
||||||
|
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
|
||||||
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
|
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
|
||||||
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
|
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
|
||||||
/** @var string $currency Currency code (ISO 4217) */
|
/** @var string $currency Currency code (ISO 4217) */
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\Tag;
|
namespace App\Http\Resources\V1\Tag;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class TagCollection extends ResourceCollection
|
class TagCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* 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\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
class OrganizationInvitationMail extends Mailable
|
class OrganizationInvitationMail extends Mailable
|
||||||
@@ -32,9 +33,12 @@ class OrganizationInvitationMail extends Mailable
|
|||||||
public function build(): self
|
public function build(): self
|
||||||
{
|
{
|
||||||
return $this->markdown('emails.organization-invitation', [
|
return $this->markdown('emails.organization-invitation', [
|
||||||
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
'acceptUrl' => URL::to(URL::signedRoute(
|
||||||
'invitation' => $this->invitation,
|
'organization-invitations.accept',
|
||||||
]),
|
['invitation' => $this->invitation->getKey()],
|
||||||
|
Carbon::now()->addDays(90),
|
||||||
|
false
|
||||||
|
)),
|
||||||
])->subject(__('Organization Invitation'));
|
])->subject(__('Organization Invitation'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property int|null $billable_rate
|
* @property int|null $billable_rate
|
||||||
* @property string $user_id
|
* @property string $user_id
|
||||||
* @property bool $employees_can_see_billable_rates
|
* @property bool $employees_can_see_billable_rates
|
||||||
|
* @property bool $employees_can_manage_tasks
|
||||||
|
* @property bool $prevent_overlapping_time_entries
|
||||||
* @property User $owner
|
* @property User $owner
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
@@ -70,6 +72,7 @@ class Organization extends JetstreamTeam implements AuditableContract
|
|||||||
'personal_team' => 'boolean',
|
'personal_team' => 'boolean',
|
||||||
'currency' => 'string',
|
'currency' => 'string',
|
||||||
'employees_can_see_billable_rates' => 'boolean',
|
'employees_can_see_billable_rates' => 'boolean',
|
||||||
|
'employees_can_manage_tasks' => 'boolean',
|
||||||
'prevent_overlapping_time_entries' => 'boolean',
|
'prevent_overlapping_time_entries' => 'boolean',
|
||||||
'number_format' => NumberFormat::class,
|
'number_format' => NumberFormat::class,
|
||||||
'currency_format' => CurrencyFormat::class,
|
'currency_format' => CurrencyFormat::class,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $email
|
* @property string $email
|
||||||
* @property string $role
|
* @property string $role
|
||||||
* @property string $organization_id
|
* @property string $organization_id
|
||||||
|
* @property Carbon|null $accepted_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property-read Organization $organization
|
* @property-read Organization $organization
|
||||||
@@ -41,14 +42,16 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
|||||||
protected $table = 'organization_invitations';
|
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 = [
|
public function casts(): array
|
||||||
'email',
|
{
|
||||||
'role',
|
return [
|
||||||
];
|
'accepted_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the organization that the invitation belongs to.
|
* Get the organization that the invitation belongs to.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Policies;
|
|||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
@@ -58,19 +59,7 @@ class OrganizationPolicy
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->ownsTeam($organization);
|
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the user can add team members.
|
|
||||||
*/
|
|
||||||
public function addTeamMember(User $user, Organization $organization): bool
|
|
||||||
{
|
|
||||||
if (Filament::isServing()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Listeners\RemovePlaceholder;
|
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
|
||||||
|
|
||||||
class EventServiceProvider extends ServiceProvider
|
class EventServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -21,9 +19,6 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
Registered::class => [
|
Registered::class => [
|
||||||
SendEmailVerificationNotification::class,
|
SendEmailVerificationNotification::class,
|
||||||
],
|
],
|
||||||
TeamMemberAdded::class => [
|
|
||||||
RemovePlaceholder::class,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -94,8 +94,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tasks:view',
|
'tasks:view',
|
||||||
'tasks:view:all',
|
'tasks:view:all',
|
||||||
'tasks:create',
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
'tasks:update',
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
'tasks:delete',
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
'time-entries:view:all',
|
'time-entries:view:all',
|
||||||
'time-entries:create:all',
|
'time-entries:create:all',
|
||||||
'time-entries:update:all',
|
'time-entries:update:all',
|
||||||
@@ -158,8 +161,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tasks:view',
|
'tasks:view',
|
||||||
'tasks:view:all',
|
'tasks:view:all',
|
||||||
'tasks:create',
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
'tasks:update',
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
'tasks:delete',
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
'time-entries:view:all',
|
'time-entries:view:all',
|
||||||
'time-entries:create:all',
|
'time-entries:create:all',
|
||||||
'time-entries:update:all',
|
'time-entries:update:all',
|
||||||
@@ -219,8 +225,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'tasks:view',
|
'tasks:view',
|
||||||
'tasks:view:all',
|
'tasks:view:all',
|
||||||
'tasks:create',
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
'tasks:update',
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
'tasks:delete',
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
'time-entries:view:all',
|
'time-entries:view:all',
|
||||||
'time-entries:create:all',
|
'time-entries:create:all',
|
||||||
'time-entries:update:all',
|
'time-entries:update:all',
|
||||||
@@ -295,28 +304,8 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
'owner' => [
|
'owner' => [
|
||||||
'id' => $owner->getKey(),
|
'id' => $owner->getKey(),
|
||||||
'name' => $owner->name,
|
'name' => $owner->name,
|
||||||
'email' => $owner->email,
|
|
||||||
'profile_photo_url' => $owner->profile_photo_url,
|
'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 {
|
'currencies' => array_map(function (Currency $currency): string {
|
||||||
return $currency->getName();
|
return $currency->getName();
|
||||||
|
|||||||
@@ -266,7 +266,8 @@ class DashboardService
|
|||||||
) as aggregate'))
|
) as aggregate'))
|
||||||
->where('billable', '=', true)
|
->where('billable', '=', true)
|
||||||
->whereNotNull('billable_rate')
|
->whereNotNull('billable_rate')
|
||||||
->where('user_id', '=', $user->id);
|
->where('user_id', '=', $user->getKey())
|
||||||
|
->where('organization_id', '=', $organization->getKey());
|
||||||
|
|
||||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||||
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Enums\TimeEntryAggregationType;
|
|||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
use App\Enums\TimeEntryRoundingType;
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Contracts\Database\Eloquent\Castable;
|
use Illuminate\Contracts\Database\Eloquent\Castable;
|
||||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -174,7 +175,7 @@ class ReportPropertiesDto implements Castable
|
|||||||
if (! is_string($id)) {
|
if (! is_string($id)) {
|
||||||
throw new \InvalidArgumentException('The given ID is not a string');
|
throw new \InvalidArgumentException('The given ID is not a string');
|
||||||
}
|
}
|
||||||
if (! Str::isUuid($id)) {
|
if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) {
|
||||||
throw new \InvalidArgumentException('The given ID is not a valid UUID');
|
throw new \InvalidArgumentException('The given ID is not a valid UUID');
|
||||||
}
|
}
|
||||||
$collection->push($id);
|
$collection->push($id);
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ use App\Enums\Role;
|
|||||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Mail\OrganizationInvitationMail;
|
use App\Mail\OrganizationInvitationMail;
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||||
|
|
||||||
@@ -21,11 +23,7 @@ class InvitationService
|
|||||||
*/
|
*/
|
||||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||||
{
|
{
|
||||||
if (Member::query()
|
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
||||||
->whereBelongsTo($organization, 'organization')
|
|
||||||
->whereRelation('user', 'email', '=', $email)
|
|
||||||
->where('role', '!=', Role::Placeholder->value)
|
|
||||||
->exists()) {
|
|
||||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,4 +46,37 @@ class InvitationService
|
|||||||
|
|
||||||
return $invitation;
|
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
|
public function formatCurrency(Money $money): string
|
||||||
{
|
{
|
||||||
$currencyService = app(CurrencyService::class);
|
$currencyService = app(CurrencyService::class);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Events\MemberAdded;
|
||||||
|
use App\Events\MemberAdding;
|
||||||
use App\Events\MemberRemoved;
|
use App\Events\MemberRemoved;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||||
@@ -36,7 +38,8 @@ class MemberService
|
|||||||
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
||||||
{
|
{
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
AddingTeamMember::dispatch($organization, $user);
|
MemberAdding::dispatch($user, $organization, $role);
|
||||||
|
AddingTeamMember::dispatch($organization, $user); // Legacy event
|
||||||
}
|
}
|
||||||
|
|
||||||
$member = new Member;
|
$member = new Member;
|
||||||
@@ -49,14 +52,37 @@ class MemberService
|
|||||||
$user->currentOrganization()->associate($organization);
|
$user->currentOrganization()->associate($organization);
|
||||||
$user->save();
|
$user->save();
|
||||||
});
|
});
|
||||||
|
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
|
||||||
|
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
TeamMemberAdded::dispatch($organization, $user);
|
MemberAdded::dispatch($member, $organization, $user);
|
||||||
|
TeamMemberAdded::dispatch($organization, $user); // Legacy event
|
||||||
}
|
}
|
||||||
|
|
||||||
return $member;
|
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 CanNotRemoveOwnerFromOrganization
|
||||||
* @throws EntityStillInUseApiException
|
* @throws EntityStillInUseApiException
|
||||||
@@ -196,6 +222,7 @@ class MemberService
|
|||||||
|
|
||||||
$placeholderUser = $user->replicate();
|
$placeholderUser = $user->replicate();
|
||||||
$placeholderUser->is_placeholder = true;
|
$placeholderUser->is_placeholder = true;
|
||||||
|
$placeholderUser->current_team_id = $member->organization_id;
|
||||||
$placeholderUser->save();
|
$placeholderUser->save();
|
||||||
|
|
||||||
$member->user()->associate($placeholderUser);
|
$member->user()->associate($placeholderUser);
|
||||||
@@ -208,4 +235,13 @@ class MemberService
|
|||||||
$this->userService->makeSureUserHasCurrentOrganization($user);
|
$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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,19 @@ class PermissionStore
|
|||||||
/** @var Role|null $roleObj */
|
/** @var Role|null $roleObj */
|
||||||
$roleObj = Jetstream::findRole($role);
|
$roleObj = Jetstream::findRole($role);
|
||||||
|
|
||||||
return $roleObj->permissions ?? [];
|
$permissions = $roleObj->permissions ?? [];
|
||||||
|
|
||||||
|
// If the organization allows employees to manage tasks and the user is an employee,
|
||||||
|
// add the task management permissions for accessible projects
|
||||||
|
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
|
||||||
|
$permissions = array_merge($permissions, [
|
||||||
|
'tasks:create',
|
||||||
|
'tasks:update',
|
||||||
|
'tasks:delete',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $permissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Log;
|
|||||||
|
|
||||||
class TimeEntryFilter
|
class TimeEntryFilter
|
||||||
{
|
{
|
||||||
|
public const string NONE_VALUE = 'none';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Builder<TimeEntry>
|
* @var Builder<TimeEntry>
|
||||||
*/
|
*/
|
||||||
@@ -149,7 +151,17 @@ class TimeEntryFilter
|
|||||||
if ($clientIds === null) {
|
if ($clientIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$this->builder->whereIn('client_id', $clientIds);
|
$includeNone = in_array(self::NONE_VALUE, $clientIds, true);
|
||||||
|
$clientIds = array_values(array_filter($clientIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
|
|
||||||
|
$this->builder->where(function (Builder $builder) use ($clientIds, $includeNone): void {
|
||||||
|
if (count($clientIds) > 0) {
|
||||||
|
$builder->whereIn('client_id', $clientIds);
|
||||||
|
}
|
||||||
|
if ($includeNone) {
|
||||||
|
$builder->orWhereNull('client_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -162,7 +174,17 @@ class TimeEntryFilter
|
|||||||
if ($projectIds === null) {
|
if ($projectIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$this->builder->whereIn('project_id', $projectIds);
|
$includeNone = in_array(self::NONE_VALUE, $projectIds, true);
|
||||||
|
$projectIds = array_values(array_filter($projectIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
|
|
||||||
|
$this->builder->where(function (Builder $builder) use ($projectIds, $includeNone): void {
|
||||||
|
if (count($projectIds) > 0) {
|
||||||
|
$builder->whereIn('project_id', $projectIds);
|
||||||
|
}
|
||||||
|
if ($includeNone) {
|
||||||
|
$builder->orWhereNull('project_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -175,10 +197,18 @@ class TimeEntryFilter
|
|||||||
if ($tagIds === null) {
|
if ($tagIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$this->builder->where(function (Builder $builder) use ($tagIds): void {
|
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
|
||||||
|
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
|
|
||||||
|
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
|
||||||
foreach ($tagIds as $tagId) {
|
foreach ($tagIds as $tagId) {
|
||||||
$builder->orWhereJsonContains('tags', $tagId);
|
$builder->orWhereJsonContains('tags', $tagId);
|
||||||
}
|
}
|
||||||
|
if ($includeNone) {
|
||||||
|
$builder->orWhere(function (Builder $query): void {
|
||||||
|
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -192,7 +222,17 @@ class TimeEntryFilter
|
|||||||
if ($taskIds === null) {
|
if ($taskIds === null) {
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
$this->builder->whereIn('task_id', $taskIds);
|
$includeNone = in_array(self::NONE_VALUE, $taskIds, true);
|
||||||
|
$taskIds = array_values(array_filter($taskIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||||
|
|
||||||
|
$this->builder->where(function (Builder $builder) use ($taskIds, $includeNone): void {
|
||||||
|
if (count($taskIds) > 0) {
|
||||||
|
$builder->whereIn('task_id', $taskIds);
|
||||||
|
}
|
||||||
|
if ($includeNone) {
|
||||||
|
$builder->orWhereNull('task_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,17 @@ class TimeEntryService
|
|||||||
throw new LogicException('Rounding minutes must be greater than 0');
|
throw new LogicException('Rounding minutes must be greater than 0');
|
||||||
}
|
}
|
||||||
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||||
|
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
|
||||||
if ($roundingType === TimeEntryRoundingType::Down) {
|
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) {
|
} 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) {
|
} 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 {
|
||||||
$user = new User;
|
$user = new User;
|
||||||
$user->name = $name;
|
$user->name = $name;
|
||||||
$user->email = $email;
|
$user->email = strtolower($email);
|
||||||
$user->password = Hash::make($password);
|
$user->password = Hash::make($password);
|
||||||
$user->timezone = $timezone;
|
$user->timezone = $timezone;
|
||||||
$user->week_start = $weekStart;
|
$user->week_start = $weekStart;
|
||||||
@@ -47,19 +47,22 @@ class UserService
|
|||||||
}
|
}
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
$organization = app(OrganizationService::class)->createOrganization(
|
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||||
$this->getOrganizationNameForUserName($user->name),
|
|
||||||
$user,
|
|
||||||
true,
|
|
||||||
$currency,
|
|
||||||
$numberFormat,
|
|
||||||
$currencyFormat,
|
|
||||||
$dateFormat,
|
|
||||||
$intervalFormat,
|
|
||||||
$timeFormat,
|
|
||||||
);
|
|
||||||
|
|
||||||
$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;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|||||||
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(),
|
'email' => $this->faker->unique()->safeEmail(),
|
||||||
'role' => Role::Employee->value,
|
'role' => Role::Employee->value,
|
||||||
'organization_id' => Organization::factory(),
|
'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
|
public function forOrganization(Organization $organization): self
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
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('organizations', function (Blueprint $table): void {
|
||||||
|
$table->boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('organizations', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('employees_can_manage_tasks');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -107,7 +107,7 @@ services:
|
|||||||
- sail
|
- sail
|
||||||
- reverse-proxy
|
- reverse-proxy
|
||||||
playwright:
|
playwright:
|
||||||
image: mcr.microsoft.com/playwright:v1.51.1-jammy
|
image: mcr.microsoft.com/playwright:v1.58.1-jammy
|
||||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||||
working_dir: /src
|
working_dir: /src
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
189
e2e/auth.spec.ts
189
e2e/auth.spec.ts
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
import { getPasswordResetUrl } from './utils/mailpit';
|
||||||
|
|
||||||
async function registerNewUser(page, email, password) {
|
async function registerNewUser(page, email, password) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||||
@@ -35,14 +36,198 @@ test('can register and delete account', async ({ page }) => {
|
|||||||
await registerNewUser(page, email, password);
|
await registerNewUser(page, email, password);
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
await page.getByPlaceholder('Password').fill(password);
|
await page.getByPlaceholder('Password').fill(password);
|
||||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
await page.getByRole('dialog').getByRole('button', { name: 'Delete Account' }).click();
|
||||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
await page.getByLabel('Email').fill(email);
|
await page.getByLabel('Email').fill(email);
|
||||||
await page.getByLabel('Password').fill(password);
|
await page.getByLabel('Password').fill(password);
|
||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
await expect(page.getByRole('paragraph')).toContainText(
|
await expect(page.getByRole('alert')).toContainText(
|
||||||
'These credentials do not match our records.'
|
'These credentials do not match our records.'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows error for invalid email on forgot password', async ({ page }) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||||
|
|
||||||
|
// Request password reset with non-existent email
|
||||||
|
await page.getByLabel('Email').fill('nonexistent@example.com');
|
||||||
|
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||||
|
|
||||||
|
// Should show error message
|
||||||
|
await expect(page.getByText("We can't find a user with that email address.")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows browser validation for invalid email format on forgot password', async ({ page }) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||||
|
|
||||||
|
// Request password reset with invalid email format
|
||||||
|
const emailInput = page.getByLabel('Email');
|
||||||
|
await emailInput.fill('notanemail');
|
||||||
|
|
||||||
|
// Check for browser validation - the input should be invalid
|
||||||
|
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
||||||
|
expect(isInvalid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows browser validation for empty email on forgot password', async ({ page }) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||||
|
|
||||||
|
// The email input is required, so it should be invalid when empty
|
||||||
|
const emailInput = page.getByLabel('Email');
|
||||||
|
|
||||||
|
// Check for browser validation - the input should be invalid because it's required and empty
|
||||||
|
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valueMissing);
|
||||||
|
expect(isInvalid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can reset password via email link', async ({ page, request }) => {
|
||||||
|
// First register a new user
|
||||||
|
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||||
|
const originalPassword = 'suchagreatpassword123';
|
||||||
|
const newPassword = 'mynewsecurepassword456';
|
||||||
|
await registerNewUser(page, email, originalPassword);
|
||||||
|
|
||||||
|
// Log out
|
||||||
|
await page.getByTestId('current_user_button').click();
|
||||||
|
await page.getByText('Log Out').click();
|
||||||
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
|
|
||||||
|
// Request password reset
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||||
|
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
||||||
|
|
||||||
|
// Get password reset URL from email
|
||||||
|
const resetUrl = await getPasswordResetUrl(request, email);
|
||||||
|
|
||||||
|
// Navigate to reset page
|
||||||
|
await page.goto(resetUrl);
|
||||||
|
|
||||||
|
// Fill in new password
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(newPassword);
|
||||||
|
await page.getByLabel('Confirm Password').fill(newPassword);
|
||||||
|
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||||
|
|
||||||
|
// Should redirect to login page after successful reset
|
||||||
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
|
|
||||||
|
// Try logging in with new password
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password').fill(newPassword);
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows validation error for password mismatch on reset', async ({ page, request }) => {
|
||||||
|
// First register a new user
|
||||||
|
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||||
|
const originalPassword = 'suchagreatpassword123';
|
||||||
|
await registerNewUser(page, email, originalPassword);
|
||||||
|
|
||||||
|
// Log out
|
||||||
|
await page.getByTestId('current_user_button').click();
|
||||||
|
await page.getByText('Log Out').click();
|
||||||
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
|
|
||||||
|
// Request password reset
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||||
|
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
||||||
|
|
||||||
|
// Get password reset URL from email
|
||||||
|
const resetUrl = await getPasswordResetUrl(request, email);
|
||||||
|
|
||||||
|
// Navigate to reset page
|
||||||
|
await page.goto(resetUrl);
|
||||||
|
|
||||||
|
// Fill in mismatched passwords
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill('newpassword123');
|
||||||
|
await page.getByLabel('Confirm Password').fill('differentpassword456');
|
||||||
|
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||||
|
|
||||||
|
// Should show validation error
|
||||||
|
await expect(page.getByText('The password field confirmation does not match.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows validation error for short password on reset', async ({ page, request }) => {
|
||||||
|
// First register a new user
|
||||||
|
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||||
|
const originalPassword = 'suchagreatpassword123';
|
||||||
|
await registerNewUser(page, email, originalPassword);
|
||||||
|
|
||||||
|
// Log out
|
||||||
|
await page.getByTestId('current_user_button').click();
|
||||||
|
await page.getByText('Log Out').click();
|
||||||
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
|
|
||||||
|
// Request password reset
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||||
|
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
||||||
|
|
||||||
|
// Get password reset URL from email
|
||||||
|
const resetUrl = await getPasswordResetUrl(request, email);
|
||||||
|
|
||||||
|
// Navigate to reset page
|
||||||
|
await page.goto(resetUrl);
|
||||||
|
|
||||||
|
// Fill in short password
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill('short');
|
||||||
|
await page.getByLabel('Confirm Password').fill('short');
|
||||||
|
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||||
|
|
||||||
|
// Should show validation error about minimum length
|
||||||
|
await expect(page.getByText('must be at least')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows error for invalid login credentials', async ({ page }) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
|
await page.getByLabel('Email').fill('nonexistent@example.com');
|
||||||
|
await page.getByLabel('Password').fill('wrongpassword123');
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('These credentials do not match our records.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows error when registering with existing email', async ({ page }) => {
|
||||||
|
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||||
|
const password = 'suchagreatpassword123';
|
||||||
|
|
||||||
|
// Register first user
|
||||||
|
await registerNewUser(page, email, password);
|
||||||
|
|
||||||
|
// Log out
|
||||||
|
await page.getByTestId('current_user_button').click();
|
||||||
|
await page.getByText('Log Out').click();
|
||||||
|
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||||
|
|
||||||
|
// Try to register with the same email
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||||
|
await page.getByLabel('Name').fill('Another User');
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||||
|
await page.getByLabel('Confirm Password').fill(password);
|
||||||
|
await page.getByLabel('I agree to the Terms of').click();
|
||||||
|
await page.getByRole('button', { name: 'Register' }).click();
|
||||||
|
|
||||||
|
// Should show error about email already taken
|
||||||
|
await expect(page.getByText('The resource already exists.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows validation error for weak password on registration', async ({ page }) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||||
|
await page.getByLabel('Name').fill('Weak Password User');
|
||||||
|
await page.getByLabel('Email').fill(`weak+${Math.round(Math.random() * 10000)}@test.com`);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill('short');
|
||||||
|
await page.getByLabel('Confirm Password').fill('short');
|
||||||
|
await page.getByLabel('I agree to the Terms of').click();
|
||||||
|
await page.getByRole('button', { name: 'Register' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('must be at least')).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
2882
e2e/calendar.spec.ts
Normal file
2882
e2e/calendar.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,23 @@
|
|||||||
import { expect, Page } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { test } from '../playwright/fixtures';
|
import { test } from '../playwright/fixtures';
|
||||||
|
import {
|
||||||
|
createClientViaApi,
|
||||||
|
createProjectMemberViaApi,
|
||||||
|
createProjectViaApi,
|
||||||
|
createPublicProjectViaApi,
|
||||||
|
} from './utils/api';
|
||||||
|
import { getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
async function goToProjectsOverview(page: Page) {
|
async function goToClientsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new project via modal
|
// Create new client via modal
|
||||||
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
|
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
|
||||||
const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
await goToProjectsOverview(page);
|
await goToClientsOverview(page);
|
||||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
await page.getByRole('button', { name: 'Create Client' }).click();
|
||||||
await page.getByPlaceholder('Client Name').fill(newClientName);
|
await page.getByPlaceholder('Client Name').fill(newClientName);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -26,7 +34,7 @@ test('test that creating and deleting a new client via the modal works', async (
|
|||||||
|
|
||||||
await expect(page.getByTestId('client_table')).toContainText(newClientName);
|
await expect(page.getByTestId('client_table')).toContainText(newClientName);
|
||||||
const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']");
|
const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']");
|
||||||
moreButton.click();
|
await moreButton.click();
|
||||||
const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']");
|
const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']");
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -41,13 +49,11 @@ test('test that creating and deleting a new client via the modal works', async (
|
|||||||
await expect(page.getByTestId('client_table')).not.toContainText(newClientName);
|
await expect(page.getByTestId('client_table')).not.toContainText(newClientName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that archiving and unarchiving clients works', async ({ page }) => {
|
test('test that archiving and unarchiving clients works', async ({ page, ctx }) => {
|
||||||
const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000);
|
const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
await goToProjectsOverview(page);
|
await createClientViaApi(ctx, { name: newClientName });
|
||||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
|
||||||
await page.getByLabel('Client Name').fill(newClientName);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Client' }).click();
|
await goToClientsOverview(page);
|
||||||
await expect(page.getByText(newClientName)).toBeVisible();
|
await expect(page.getByText(newClientName)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
@@ -71,4 +77,300 @@ test('test that archiving and unarchiving clients works', async ({ page }) => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Add Name Update Test
|
test('test that editing a client name works', async ({ page, ctx }) => {
|
||||||
|
const originalName = 'Original Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const updatedName = 'Updated Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createClientViaApi(ctx, { name: originalName });
|
||||||
|
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await expect(page.getByText(originalName)).toBeVisible();
|
||||||
|
|
||||||
|
// Open edit modal via actions menu
|
||||||
|
const moreButton = page.locator("[aria-label='Actions for Client " + originalName + "']");
|
||||||
|
await moreButton.click();
|
||||||
|
await page.getByTestId('client_edit').click();
|
||||||
|
|
||||||
|
// Update the client name
|
||||||
|
await page.getByPlaceholder('Client Name').fill(updatedName);
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Update Client' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/clients') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify updated name is shown and old name is gone
|
||||||
|
await expect(page.getByTestId('client_table')).toContainText(updatedName);
|
||||||
|
await expect(page.getByTestId('client_table')).not.toContainText(originalName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that deleting a client via actions menu works', async ({ page, ctx }) => {
|
||||||
|
const clientName = 'DeleteMe Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
|
||||||
|
await createClientViaApi(ctx, { name: clientName });
|
||||||
|
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await expect(page.getByTestId('client_table')).toContainText(clientName);
|
||||||
|
|
||||||
|
const moreButton = page.locator("[aria-label='Actions for Client " + clientName + "']");
|
||||||
|
await moreButton.click();
|
||||||
|
const deleteButton = page.locator("[aria-label='Delete Client " + clientName + "']");
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
deleteButton.click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/clients') &&
|
||||||
|
response.request().method() === 'DELETE' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Context Menu Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that client context menu edit updates the client', async ({ page, ctx }) => {
|
||||||
|
const clientName = 'CtxEditClient ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const updatedName = 'CtxUpdatedClient ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createClientViaApi(ctx, { name: clientName });
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: clientName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Client Name').fill(updatedName);
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Update Client' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/clients') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('client_table')).toContainText(updatedName);
|
||||||
|
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that client context menu archive archives the client', async ({ page, ctx }) => {
|
||||||
|
const clientName = 'CtxArchiveClient ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createClientViaApi(ctx, { name: clientName });
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: clientName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/clients') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Archive' }).click(),
|
||||||
|
]);
|
||||||
|
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that client context menu delete deletes the client', async ({ page, ctx }) => {
|
||||||
|
const clientName = 'CtxDeleteClient ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createClientViaApi(ctx, { name: clientName });
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: clientName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/clients') &&
|
||||||
|
response.request().method() === 'DELETE' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||||||
|
]);
|
||||||
|
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Sorting Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
async function clearClientTableState(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem('client-table-state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that sorting clients by name and status works', async ({ page, ctx }) => {
|
||||||
|
await createClientViaApi(ctx, { name: 'AAA SortClient' });
|
||||||
|
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
|
||||||
|
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await clearClientTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('client_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// -- Name sorting (default is name asc) --
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortClient')).toBeLessThan(names.indexOf('ZZZ SortClient'));
|
||||||
|
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ZZZ SortClient')).toBeLessThan(names.indexOf('AAA SortClient'));
|
||||||
|
|
||||||
|
// -- Status sorting --
|
||||||
|
const statusHeader = table.getByText('Status').first();
|
||||||
|
await statusHeader.click(); // asc
|
||||||
|
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||||
|
await statusHeader.click(); // desc
|
||||||
|
await expect(statusHeader.locator('svg')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that sorting clients by project count works', async ({ page, ctx }) => {
|
||||||
|
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
|
||||||
|
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
|
||||||
|
|
||||||
|
// Create projects for the first client
|
||||||
|
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
|
||||||
|
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
|
||||||
|
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await clearClientTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('client_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// Click Projects header - first click should sort desc (most projects first)
|
||||||
|
const projectsHeader = table.getByText('Projects').first();
|
||||||
|
await projectsHeader.click();
|
||||||
|
await expect(projectsHeader.locator('svg')).toBeVisible();
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ManyProjects Client')).toBeLessThan(names.indexOf('NoProjects Client'));
|
||||||
|
|
||||||
|
// Second click toggles to asc (least projects first)
|
||||||
|
await projectsHeader.click();
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('NoProjects Client')).toBeLessThan(names.indexOf('ManyProjects Client'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that client sort state persists after page reload', async ({ page }) => {
|
||||||
|
await goToClientsOverview(page);
|
||||||
|
await clearClientTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('client_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('client_table')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('client_table').getByText('Name').first().locator('svg')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Employee Permission Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test.describe('Employee Clients Restrictions', () => {
|
||||||
|
test('employee can view clients but cannot create', async ({ ctx, employee }) => {
|
||||||
|
// Create a client with a public project so the employee can see the client
|
||||||
|
const clientName = 'EmpViewClient ' + Math.floor(Math.random() * 10000);
|
||||||
|
const client = await createClientViaApi(ctx, { name: clientName });
|
||||||
|
await createPublicProjectViaApi(ctx, { name: 'EmpClientProj', client_id: client.id });
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||||
|
await expect(employee.page.getByTestId('clients_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Employee can see the client
|
||||||
|
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Employee cannot see Create Client button
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('button', { name: 'Create Client' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('employee cannot see edit/delete/archive actions on clients', async ({
|
||||||
|
ctx,
|
||||||
|
employee,
|
||||||
|
}) => {
|
||||||
|
const clientName = 'EmpActionsClient ' + Math.floor(Math.random() * 10000);
|
||||||
|
const client = await createClientViaApi(ctx, { name: clientName });
|
||||||
|
await createPublicProjectViaApi(ctx, { name: 'EmpClientActProj', client_id: client.id });
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||||
|
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click the actions dropdown trigger to open the menu
|
||||||
|
const actionsButton = employee.page.locator(
|
||||||
|
`[aria-label='Actions for Client ${clientName}']`
|
||||||
|
);
|
||||||
|
await actionsButton.click();
|
||||||
|
|
||||||
|
// The dropdown menu items (Edit, Archive, Delete) should NOT be visible
|
||||||
|
await expect(
|
||||||
|
employee.page.locator(`[aria-label='Edit Client ${clientName}']`)
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
employee.page.locator(`[aria-label='Archive Client ${clientName}']`)
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
employee.page.locator(`[aria-label='Delete Client ${clientName}']`)
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('employee can see client when they are a member of its private project', async ({
|
||||||
|
ctx,
|
||||||
|
employee,
|
||||||
|
}) => {
|
||||||
|
const clientName = 'EmpPrivateClient ' + Math.floor(Math.random() * 10000);
|
||||||
|
const client = await createClientViaApi(ctx, { name: clientName });
|
||||||
|
|
||||||
|
// Create a private project under this client
|
||||||
|
const project = await createProjectViaApi(ctx, {
|
||||||
|
name: 'PrivateProj',
|
||||||
|
client_id: client.id,
|
||||||
|
is_public: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the employee as a project member
|
||||||
|
await createProjectMemberViaApi(ctx, project.id, {
|
||||||
|
member_id: employee.memberId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||||
|
await expect(employee.page.getByTestId('clients_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Employee can see the client because they are a member of its private project
|
||||||
|
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
474
e2e/command-palette.spec.ts
Normal file
474
e2e/command-palette.spec.ts
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
import { expect, test } from '../playwright/fixtures';
|
||||||
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const TIMER_BUTTON_SELECTOR = '[data-testid="dashboard_timer"] [data-testid="timer_button"]';
|
||||||
|
|
||||||
|
async function goToDashboard(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCommandPalette(page: Page) {
|
||||||
|
await page.getByTestId('command_palette_button').click();
|
||||||
|
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeCommandPalette(page: Page) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchInCommandPalette(page: Page, query: string) {
|
||||||
|
await page.locator('[role="dialog"] input').fill(query);
|
||||||
|
// Wait for search debounce to settle (command palette uses a debounced search)
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectCommand(page: Page, name: string) {
|
||||||
|
const option = page.getByRole('option', { name, exact: true });
|
||||||
|
await option.scrollIntoViewIfNeeded();
|
||||||
|
await option.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertTimerIsRunning(page: Page) {
|
||||||
|
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
|
||||||
|
/bg-red-400\/80/,
|
||||||
|
{
|
||||||
|
timeout: 10000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertTimerIsStopped(page: Page) {
|
||||||
|
await expect(page.locator(TIMER_BUTTON_SELECTOR).and(page.locator(':visible'))).toHaveClass(
|
||||||
|
/bg-accent-300\/70/,
|
||||||
|
{
|
||||||
|
timeout: 10000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Command Palette', () => {
|
||||||
|
test.describe('Opening and Closing', () => {
|
||||||
|
test('opens via search button and closes with Escape', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await expect(
|
||||||
|
page.locator('[role="dialog"] input[placeholder*="command"]')
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await closeCommandPalette(page);
|
||||||
|
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens with keyboard shortcut', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
// Click on body to ensure page has focus
|
||||||
|
await page.locator('body').click();
|
||||||
|
// Use ControlOrMeta which resolves to Ctrl on Linux/Windows and Meta on macOS
|
||||||
|
await page.keyboard.press('ControlOrMeta+k');
|
||||||
|
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears search on close', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'dashboard');
|
||||||
|
await closeCommandPalette(page);
|
||||||
|
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await expect(page.locator('[role="dialog"] input')).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Command Display', () => {
|
||||||
|
test('displays navigation and timer commands', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
|
||||||
|
// Navigation commands
|
||||||
|
await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'Go to Time' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();
|
||||||
|
|
||||||
|
// Timer commands
|
||||||
|
await expect(page.getByRole('option', { name: 'Start Timer' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'Create Time Entry' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('displays create commands', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
|
||||||
|
await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'Create Client' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'Create Tag' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Navigation Commands', () => {
|
||||||
|
// Tests use element visibility assertions for consistency with codebase patterns
|
||||||
|
const navigationTests = [
|
||||||
|
['Go to Dashboard', 'dashboard_view', '/time'],
|
||||||
|
['Go to Time', 'time_view', '/dashboard'],
|
||||||
|
['Go to Calendar', 'calendar_view', '/dashboard'],
|
||||||
|
['Go to Projects', 'projects_view', '/dashboard'],
|
||||||
|
['Go to Clients', 'clients_view', '/dashboard'],
|
||||||
|
['Go to Members', 'members_view', '/dashboard'],
|
||||||
|
['Go to Tags', 'tags_view', '/dashboard'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [commandName, expectedTestId, startUrl] of navigationTests) {
|
||||||
|
test(`${commandName}`, async ({ page }) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + startUrl);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, commandName.replace('Go to ', ''));
|
||||||
|
await selectCommand(page, commandName);
|
||||||
|
await expect(page.getByTestId(expectedTestId)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Go to Profile', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Profile');
|
||||||
|
await selectCommand(page, 'Go to Profile');
|
||||||
|
// Profile page doesn't have a testId, so check for a unique element
|
||||||
|
await expect(page.getByRole('heading', { name: 'Profile Information' })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Go to Reporting Overview', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Reporting Overview');
|
||||||
|
await selectCommand(page, 'Go to Reporting Overview');
|
||||||
|
await expect(page.getByTestId('reporting_view')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Go to Settings', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Settings');
|
||||||
|
await selectCommand(page, 'Go to Settings');
|
||||||
|
// Settings page uses team settings which has an h3 heading
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Organization Name', level: 3 })
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Search and Filtering', () => {
|
||||||
|
test('filters commands when searching', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
|
||||||
|
await searchInCommandPalette(page, 'dashboard');
|
||||||
|
await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
|
||||||
|
|
||||||
|
await searchInCommandPalette(page, 'calendar');
|
||||||
|
await expect(page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search is case insensitive', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
|
||||||
|
await searchInCommandPalette(page, 'DASHBOARD');
|
||||||
|
await expect(page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial word search works', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
|
||||||
|
await searchInCommandPalette(page, 'proj');
|
||||||
|
await expect(page.getByRole('option', { name: 'Go to Projects' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'Create Project' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard navigation and selection works', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Theme Commands', () => {
|
||||||
|
test('switches to dark theme', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Dark Theme');
|
||||||
|
await selectCommand(page, 'Switch to Dark Theme');
|
||||||
|
await expect(page.locator('html')).toHaveClass(/dark/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switches to light theme', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Light Theme');
|
||||||
|
await selectCommand(page, 'Switch to Light Theme');
|
||||||
|
await expect(page.locator('html')).toHaveClass(/light/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Timer Commands', () => {
|
||||||
|
test('starts and stops timer', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
|
||||||
|
// Start timer
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Start Timer');
|
||||||
|
await selectCommand(page, 'Start Timer');
|
||||||
|
await assertTimerIsRunning(page);
|
||||||
|
|
||||||
|
// Stop timer
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Stop Timer');
|
||||||
|
await selectCommand(page, 'Stop Timer');
|
||||||
|
await assertTimerIsStopped(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows active timer commands when running', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
|
||||||
|
// Start timer
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Start Timer');
|
||||||
|
await selectCommand(page, 'Start Timer');
|
||||||
|
await assertTimerIsRunning(page);
|
||||||
|
|
||||||
|
// Check active timer commands - search for them to ensure visibility
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Set Project');
|
||||||
|
await expect(page.getByRole('option', { name: 'Set Project' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Create Commands', () => {
|
||||||
|
test('opens create time entry modal', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Create Time Entry');
|
||||||
|
await selectCommand(page, 'Create Time Entry');
|
||||||
|
await expect(
|
||||||
|
page.locator('[role="dialog"]').getByText('Create manual time entry')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens create project modal', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Create Project');
|
||||||
|
await selectCommand(page, 'Create Project');
|
||||||
|
await expect(
|
||||||
|
page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Project' })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens create client modal', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Create Client');
|
||||||
|
await selectCommand(page, 'Create Client');
|
||||||
|
await expect(
|
||||||
|
page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Client' })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens create tag modal', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Create Tag');
|
||||||
|
await selectCommand(page, 'Create Tag');
|
||||||
|
await expect(page.locator('[role="dialog"]').getByText('Create Tags')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens invite member modal', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Invite Member');
|
||||||
|
await selectCommand(page, 'Invite Member');
|
||||||
|
// Modal has title with "Invite Member" text - use first() to get the title span
|
||||||
|
await expect(
|
||||||
|
page.locator('[role="dialog"]').getByText('Invite Member').first()
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Entity Search', () => {
|
||||||
|
test('searches for projects and navigates on selection', async ({ page }) => {
|
||||||
|
const projectName = 'CmdPalette' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create project first
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||||
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
await page.getByPlaceholder('The next big thing').fill(projectName);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
// Wait for project to be created and page to update
|
||||||
|
await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Search from the projects page where the query cache now has the new project
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, projectName);
|
||||||
|
|
||||||
|
// Wait for entity search to return results
|
||||||
|
const projectOption = page.getByRole('option').filter({ hasText: projectName });
|
||||||
|
await expect(projectOption).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select the project from search results
|
||||||
|
await projectOption.click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Organization Switching', () => {
|
||||||
|
test('shows switch commands only when multiple organizations exist', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await openCommandPalette(page);
|
||||||
|
|
||||||
|
// With only one org, no switch commands should appear
|
||||||
|
await searchInCommandPalette(page, 'Switch to');
|
||||||
|
// Check that no organization switch commands appear (only theme switch commands)
|
||||||
|
const switchOptions = page.getByRole('option', { name: /^Switch to (?!.*Theme)/ });
|
||||||
|
await expect(switchOptions).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switches organization via command palette', async ({ page }) => {
|
||||||
|
const newOrgName = 'TestOrg' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a new organization
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create');
|
||||||
|
await page.getByLabel('Organization Name').fill(newOrgName);
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
|
||||||
|
// Wait for navigation to new org's dashboard
|
||||||
|
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Use visible switcher (desktop sidebar has one, mobile header has another)
|
||||||
|
const orgSwitcher = page.locator('[data-testid="organization_switcher"]:visible');
|
||||||
|
|
||||||
|
// Verify we're in the new org by checking the switcher
|
||||||
|
await expect(orgSwitcher).toContainText(newOrgName);
|
||||||
|
|
||||||
|
// Get the original org name from switcher dropdown
|
||||||
|
await orgSwitcher.click();
|
||||||
|
await expect(page.getByText('Switch Organizations')).toBeVisible();
|
||||||
|
|
||||||
|
// Find the other organization button (has ArrowRightIcon, not CheckCircleIcon)
|
||||||
|
// The button contains an SVG and a div with the org name
|
||||||
|
const otherOrgItem = page.locator('form button').filter({ hasText: /.+/ }).first();
|
||||||
|
await expect(otherOrgItem).toBeVisible();
|
||||||
|
const originalOrgName = (await otherOrgItem.innerText()).trim();
|
||||||
|
await page.keyboard.press('Escape'); // Close dropdown
|
||||||
|
|
||||||
|
// Now use command palette to switch back to original org
|
||||||
|
await openCommandPalette(page);
|
||||||
|
await searchInCommandPalette(page, 'Switch to');
|
||||||
|
|
||||||
|
// Should see the switch command for the original org
|
||||||
|
const switchCommand = page.getByRole('option', {
|
||||||
|
name: new RegExp(`Switch to ${originalOrgName}`),
|
||||||
|
});
|
||||||
|
await expect(switchCommand).toBeVisible();
|
||||||
|
await switchCommand.click();
|
||||||
|
|
||||||
|
// Wait for organization switch to complete
|
||||||
|
await expect(orgSwitcher).toContainText(originalOrgName, {
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('organization switch commands appear in Organization group', async ({ page }) => {
|
||||||
|
const newOrgName = 'GroupTestOrg' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a new organization to ensure we have multiple
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/teams/create');
|
||||||
|
await page.getByLabel('Organization Name').fill(newOrgName);
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Open command palette and check for Organization group heading
|
||||||
|
await openCommandPalette(page);
|
||||||
|
|
||||||
|
// The Organization group should be visible when there are switch commands
|
||||||
|
await expect(page.getByText('Organization', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Employee Permission Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test.describe('Employee Command Palette Restrictions', () => {
|
||||||
|
test('employee command palette does not show restricted navigation commands', async ({
|
||||||
|
employee,
|
||||||
|
}) => {
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open command palette
|
||||||
|
await employee.page.getByTestId('command_palette_button').click();
|
||||||
|
await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Available navigation commands
|
||||||
|
await expect(employee.page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
|
||||||
|
await expect(employee.page.getByRole('option', { name: 'Go to Time' })).toBeVisible();
|
||||||
|
await expect(employee.page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();
|
||||||
|
|
||||||
|
// Restricted commands should NOT be visible
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('option', { name: 'Go to Members' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('option', { name: 'Go to Settings' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('employee command palette does not show create commands for restricted entities', async ({
|
||||||
|
employee,
|
||||||
|
}) => {
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open command palette
|
||||||
|
await employee.page.getByTestId('command_palette_button').click();
|
||||||
|
await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Search for "Create" to filter
|
||||||
|
await employee.page.locator('[role="dialog"] input').fill('Create');
|
||||||
|
await employee.page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Should NOT see create commands for restricted entities
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('option', { name: 'Create Project' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('option', { name: 'Create Client' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(employee.page.getByRole('option', { name: 'Create Tag' })).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('option', { name: 'Invite Member' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
|
// Should still see Create Time Entry (employees can create time entries)
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('option', { name: 'Create Time Entry' })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
198
e2e/dashboard.spec.ts
Normal file
198
e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { expect, test } from '../playwright/fixtures';
|
||||||
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
assertThatTimerHasStarted,
|
||||||
|
assertThatTimerIsStopped,
|
||||||
|
newTimeEntryResponse,
|
||||||
|
startOrStopTimerWithButton,
|
||||||
|
stoppedTimeEntryResponse,
|
||||||
|
} from './utils/currentTimeEntry';
|
||||||
|
import {
|
||||||
|
createBareTimeEntryViaApi,
|
||||||
|
createPublicProjectViaApi,
|
||||||
|
createTimeEntryViaApi,
|
||||||
|
updateOrganizationSettingViaApi,
|
||||||
|
} from './utils/api';
|
||||||
|
|
||||||
|
async function goToDashboard(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that dashboard loads with all expected sections', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Timer section (scoped to dashboard_timer to avoid matching sidebar timer)
|
||||||
|
await expect(page.getByTestId('time_entry_description')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByTestId('dashboard_timer')
|
||||||
|
.getByTestId('timer_button')
|
||||||
|
.and(page.locator(':visible'))
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Dashboard cards
|
||||||
|
await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('Last 7 Days', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('Activity Graph', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('Team Activity', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Weekly overview section
|
||||||
|
await expect(page.getByText('This Week', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that dashboard shows time entry data after creating entries', async ({ page, ctx }) => {
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Dashboard test entry', '1h');
|
||||||
|
|
||||||
|
await goToDashboard(page);
|
||||||
|
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
||||||
|
|
||||||
|
// The "Last 7 Days" or "This Week" section should reflect tracked time
|
||||||
|
await expect(page.getByText('This Week', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that timer on dashboard can start and stop', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||||
|
await assertThatTimerHasStarted(page);
|
||||||
|
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||||
|
await assertThatTimerIsStopped(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that weekly overview section displays stat cards', async ({ page, ctx }) => {
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Stats test entry', '2h');
|
||||||
|
|
||||||
|
await goToDashboard(page);
|
||||||
|
|
||||||
|
// Verify stat card labels are visible
|
||||||
|
await expect(page.getByText('Spent Time')).toBeVisible();
|
||||||
|
await expect(page.getByText('Billable Time')).toBeVisible();
|
||||||
|
await expect(page.getByText('Billable Amount')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that stopping timer refreshes dashboard data', async ({ page }) => {
|
||||||
|
await goToDashboard(page);
|
||||||
|
|
||||||
|
// Start timer
|
||||||
|
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||||
|
await assertThatTimerHasStarted(page);
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Stop timer and verify dashboard queries are refetched
|
||||||
|
await Promise.all([
|
||||||
|
stoppedTimeEntryResponse(page),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/charts/') &&
|
||||||
|
response.request().method() === 'GET' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
startOrStopTimerWithButton(page),
|
||||||
|
]);
|
||||||
|
await assertThatTimerIsStopped(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Employee Permission Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test.describe('Employee Dashboard Restrictions', () => {
|
||||||
|
test('employee dashboard loads and timer is functional', async ({ employee }) => {
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timer should be available
|
||||||
|
await expect(
|
||||||
|
employee.page
|
||||||
|
.getByTestId('dashboard_timer')
|
||||||
|
.getByTestId('timer_button')
|
||||||
|
.and(employee.page.locator(':visible'))
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(employee.page.getByTestId('time_entry_description')).toBeEditable();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('employee cannot see Team Activity card', async ({ employee }) => {
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Other dashboard cards should be visible
|
||||||
|
await expect(employee.page.getByText('Recent Time Entries', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Team Activity should NOT be visible for employees
|
||||||
|
await expect(employee.page.getByText('Team Activity', { exact: true })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('employee cannot see Cost column in This Week table by default', async ({
|
||||||
|
ctx,
|
||||||
|
employee,
|
||||||
|
}) => {
|
||||||
|
const project = await createPublicProjectViaApi(ctx, {
|
||||||
|
name: 'EmpDashBillProj',
|
||||||
|
is_billable: true,
|
||||||
|
billable_rate: 10000,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(
|
||||||
|
{ ...ctx, memberId: employee.memberId },
|
||||||
|
{
|
||||||
|
description: 'Emp dashboard cost entry',
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
billable: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// This Week table should be visible
|
||||||
|
await expect(employee.page.getByText('This Week', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Duration column should be visible, but Cost column should NOT
|
||||||
|
await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible();
|
||||||
|
await expect(employee.page.getByText('Cost', { exact: true })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('employee can see Cost column in This Week table when employees_can_see_billable_rates is enabled', async ({
|
||||||
|
ctx,
|
||||||
|
employee,
|
||||||
|
}) => {
|
||||||
|
await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true });
|
||||||
|
|
||||||
|
const project = await createPublicProjectViaApi(ctx, {
|
||||||
|
name: 'EmpDashBillVisProj',
|
||||||
|
is_billable: true,
|
||||||
|
billable_rate: 10000,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(
|
||||||
|
{ ...ctx, memberId: employee.memberId },
|
||||||
|
{
|
||||||
|
description: 'Emp dashboard cost visible entry',
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
billable: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both Duration and Cost columns should be visible
|
||||||
|
await expect(employee.page.getByText('Duration', { exact: true })).toBeVisible();
|
||||||
|
await expect(employee.page.getByText('Cost', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// 1h at 100.00/h = 100.00 EUR cost should be visible
|
||||||
|
await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
154
e2e/import-export.spec.ts
Normal file
154
e2e/import-export.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { expect, test } from '../playwright/fixtures';
|
||||||
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
async function goToImportExport(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/import');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that import page loads with type dropdown and file upload', async ({ page }) => {
|
||||||
|
await goToImportExport(page);
|
||||||
|
await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Import section
|
||||||
|
await expect(page.getByRole('heading', { name: 'Import Data' })).toBeVisible();
|
||||||
|
await expect(page.locator('#importType')).toBeVisible();
|
||||||
|
|
||||||
|
// Export section
|
||||||
|
await expect(page.getByRole('heading', { name: 'Export Data' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that selecting an import type shows instructions', async ({ page }) => {
|
||||||
|
await goToImportExport(page);
|
||||||
|
|
||||||
|
// Select a Toggl import type
|
||||||
|
await page.getByLabel('Import Type').selectOption({ index: 1 });
|
||||||
|
|
||||||
|
// Instructions should appear
|
||||||
|
await expect(page.getByText('Instructions:')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that importing without selecting type shows error', async ({ page }) => {
|
||||||
|
await goToImportExport(page);
|
||||||
|
|
||||||
|
// Click Import Data without selecting a type
|
||||||
|
await page.getByRole('button', { name: 'Import Data' }).click();
|
||||||
|
|
||||||
|
// Should show an error notification
|
||||||
|
await expect(page.getByText('Please select the import type')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that importing without selecting file shows error', async ({ page }) => {
|
||||||
|
await goToImportExport(page);
|
||||||
|
|
||||||
|
// Select an import type first
|
||||||
|
await page.getByLabel('Import Type').selectOption({ index: 1 });
|
||||||
|
|
||||||
|
// Click Import Data without selecting a file
|
||||||
|
await page.getByRole('button', { name: 'Import Data' }).click();
|
||||||
|
|
||||||
|
// Should show an error notification
|
||||||
|
await expect(
|
||||||
|
page.getByText('Please select the CSV or ZIP file that you want to import')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that export button triggers export and shows success modal', async ({ page }) => {
|
||||||
|
await goToImportExport(page);
|
||||||
|
await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible();
|
||||||
|
|
||||||
|
// Override window.open to prevent the page from navigating away to the
|
||||||
|
// download URL (the app uses window.open(url, '_self') which would navigate
|
||||||
|
// away before we can verify the success modal)
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.open = () => null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click Export Organization Data and wait for the API response
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/export') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 200,
|
||||||
|
{ timeout: 60000 }
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Export Organization Data' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Success modal should appear after export completes
|
||||||
|
await expect(page.getByText('The export was successful!')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that import type dropdown has multiple options', async ({ page }) => {
|
||||||
|
await goToImportExport(page);
|
||||||
|
|
||||||
|
// The dropdown should load with options from the API
|
||||||
|
await page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/importers') &&
|
||||||
|
response.request().method() === 'GET' &&
|
||||||
|
response.status() === 200
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the select has options besides the default placeholder
|
||||||
|
const options = page.getByLabel('Import Type').locator('option');
|
||||||
|
const count = await options.count();
|
||||||
|
// Should have at least the placeholder + some import types
|
||||||
|
expect(count).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that importing a generic time entries CSV works', async ({ page }) => {
|
||||||
|
await goToImportExport(page);
|
||||||
|
await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Select "Generic Time Entries" import type
|
||||||
|
await page.getByLabel('Import Type').selectOption({ label: 'Generic Time Entries' });
|
||||||
|
await expect(page.getByText('Instructions:')).toBeVisible();
|
||||||
|
|
||||||
|
// Upload the test CSV file
|
||||||
|
const csvPath = path.resolve('resources/testfiles/generic_time_entries_import_test_1.csv');
|
||||||
|
await page.locator('#file-upload').setInputFiles(csvPath);
|
||||||
|
|
||||||
|
// Click Import and wait for the API response
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/import') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 200,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Import Data' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify success modal with import results
|
||||||
|
await expect(page.getByRole('heading', { name: 'Import Result' })).toBeVisible();
|
||||||
|
await expect(page.getByText('The import was successful!')).toBeVisible();
|
||||||
|
|
||||||
|
// The CSV has 2 time entries, 1 client, 2 projects, 1 task
|
||||||
|
await expect(page.getByText('Time entries created:').locator('..')).toContainText('2');
|
||||||
|
await expect(page.getByText('Projects created:').locator('..')).toContainText('2');
|
||||||
|
await expect(page.getByText('Clients created:').locator('..')).toContainText('1');
|
||||||
|
await expect(page.getByText('Tasks created:').locator('..')).toContainText('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Employee Permission Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test.describe('Employee Import Restrictions', () => {
|
||||||
|
test('employee does not see Import / Export link in the sidebar', async ({ employee }) => {
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Import / Export link should NOT be visible in the sidebar for employees
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('link', { name: 'Import / Export' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,53 +3,69 @@
|
|||||||
// TODO: Remove Invitation
|
// TODO: Remove Invitation
|
||||||
import { expect, test } from '../playwright/fixtures';
|
import { expect, test } from '../playwright/fixtures';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { inviteAndAcceptMember } from './utils/members';
|
||||||
|
import {
|
||||||
|
createPlaceholderMemberViaImportApi,
|
||||||
|
getMembersViaApi,
|
||||||
|
updateMemberBillableRateViaApi,
|
||||||
|
updateOrganizationSettingViaApi,
|
||||||
|
} from './utils/api';
|
||||||
|
import { getTableRowNames } from './utils/table';
|
||||||
|
|
||||||
async function goToMembersPage(page) {
|
// Tests that invite + accept members need more time
|
||||||
|
test.describe.configure({ timeout: 45000 });
|
||||||
|
|
||||||
|
async function goToMembersPage(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openInviteMemberModal(page) {
|
async function openInviteMemberModal(page: Page) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.getByRole('button', { name: 'Invite Member' }).click(),
|
page.getByRole('button', { name: 'Invite Member' }).click(),
|
||||||
expect(page.getByPlaceholder('Member Email')).toBeVisible(),
|
expect(page.getByPlaceholder('Member Email')).toBeVisible(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('test that new manager can be invited', async ({ page }) => {
|
test('test that new manager can be invited and accepted', async ({ page, browser }) => {
|
||||||
|
const memberId = Math.round(Math.random() * 100000);
|
||||||
|
const memberEmail = `manager+${memberId}@invite.test`;
|
||||||
|
|
||||||
|
await inviteAndAcceptMember(page, browser, 'Invited Mgr', memberEmail, 'Manager');
|
||||||
|
|
||||||
|
// Verify the member appears in the members table with the correct role
|
||||||
await goToMembersPage(page);
|
await goToMembersPage(page);
|
||||||
await openInviteMemberModal(page);
|
const memberRow = page.getByRole('row').filter({ hasText: 'Invited Mgr' });
|
||||||
const editorId = Math.round(Math.random() * 10000);
|
await expect(memberRow).toBeVisible();
|
||||||
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
|
await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Manager' }).click();
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
|
||||||
expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that new employee can be invited', async ({ page }) => {
|
test('test that new employee can be invited and accepted', async ({ page, browser }) => {
|
||||||
|
const memberId = Math.round(Math.random() * 100000);
|
||||||
|
const memberEmail = `employee+${memberId}@invite.test`;
|
||||||
|
|
||||||
|
await inviteAndAcceptMember(page, browser, 'Invited Emp', memberEmail, 'Employee');
|
||||||
|
|
||||||
|
// Verify the member appears in the members table with the correct role
|
||||||
await goToMembersPage(page);
|
await goToMembersPage(page);
|
||||||
await openInviteMemberModal(page);
|
const memberRow = page.getByRole('row').filter({ hasText: 'Invited Emp' });
|
||||||
const editorId = Math.round(Math.random() * 10000);
|
await expect(memberRow).toBeVisible();
|
||||||
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
|
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Employee' }).click();
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
|
||||||
await expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that new admin can be invited', async ({ page }) => {
|
test('test that new admin can be invited and accepted', async ({ page, browser }) => {
|
||||||
|
const memberId = Math.round(Math.random() * 100000);
|
||||||
|
const memberEmail = `admin+${memberId}@invite.test`;
|
||||||
|
|
||||||
|
await inviteAndAcceptMember(page, browser, 'Invited Adm', memberEmail, 'Administrator');
|
||||||
|
|
||||||
|
// Verify the member appears in the members table with the correct role
|
||||||
await goToMembersPage(page);
|
await goToMembersPage(page);
|
||||||
await openInviteMemberModal(page);
|
const memberRow = page.getByRole('row').filter({ hasText: 'Invited Adm' });
|
||||||
const adminId = Math.round(Math.random() * 10000);
|
await expect(memberRow).toBeVisible();
|
||||||
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
|
await expect(memberRow.getByText('Admin', { exact: true })).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
|
||||||
expect(page.getByRole('main')).toContainText(`new+${adminId}@admin.test`),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that error shows if no role is selected', async ({ page }) => {
|
test('test that error shows if no role is selected', async ({ page }) => {
|
||||||
await goToMembersPage(page);
|
await goToMembersPage(page);
|
||||||
await openInviteMemberModal(page);
|
await openInviteMemberModal(page);
|
||||||
@@ -69,8 +85,8 @@ test('test that organization billable rate can be updated with all existing time
|
|||||||
const newBillableRate = Math.round(Math.random() * 10000);
|
const newBillableRate = Math.round(Math.random() * 10000);
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
await page.getByRole('menuitem').getByText('Edit').click();
|
await page.getByRole('menuitem').getByText('Edit').click();
|
||||||
await page.getByText('Organization Default Rate').click();
|
await page.getByRole('combobox').last().click();
|
||||||
await page.getByText('Custom Rate').click();
|
await page.getByRole('option', { name: 'Custom Rate' }).click();
|
||||||
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
|
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
|
||||||
await page.getByRole('button', { name: 'Update Member' }).click();
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
||||||
|
|
||||||
@@ -91,3 +107,835 @@ test('test that organization billable rate can be updated with all existing time
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that switching member billable rate from custom back to default rate works', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Set a known org billable rate
|
||||||
|
await updateOrganizationSettingViaApi(ctx, { billable_rate: 12000 });
|
||||||
|
|
||||||
|
// Create a placeholder member with a custom billable rate
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'CustomToDefault Member');
|
||||||
|
const members = await getMembersViaApi(ctx);
|
||||||
|
const member = members.find((m) => m.name === 'CustomToDefault Member');
|
||||||
|
expect(member).toBeDefined();
|
||||||
|
await updateMemberBillableRateViaApi(ctx, member!.id, 25000);
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
const memberRow = page.getByRole('row').filter({ hasText: 'CustomToDefault Member' });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
await memberRow.getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Verify it starts on Custom Rate
|
||||||
|
const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();
|
||||||
|
await expect(billableCombobox).toContainText('Custom Rate');
|
||||||
|
|
||||||
|
// Switch to Default Rate
|
||||||
|
await billableCombobox.click();
|
||||||
|
await page.getByRole('option', { name: 'Default Rate' }).click();
|
||||||
|
await expect(billableCombobox).toContainText('Default Rate');
|
||||||
|
|
||||||
|
// Verify the billable rate input is disabled
|
||||||
|
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
|
||||||
|
|
||||||
|
// Submit — billable_rate changes from 25000 to null, so confirmation dialog appears
|
||||||
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
|
||||||
|
await expect(page.getByText('the default rate of the organization')).toBeVisible();
|
||||||
|
|
||||||
|
// Confirm the update
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
|
||||||
|
page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.url().includes('/members/') &&
|
||||||
|
request.method() === 'PUT' &&
|
||||||
|
request.postDataJSON().billable_rate === null
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify both dialogs are closed
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that default rate shows disabled input with organization billable rate', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Set a known org billable rate (150.00)
|
||||||
|
await updateOrganizationSettingViaApi(ctx, { billable_rate: 15000 });
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
|
||||||
|
// Open edit modal for the owner (who uses default rate by default)
|
||||||
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Verify it's on Default Rate
|
||||||
|
const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();
|
||||||
|
await expect(billableCombobox).toContainText('Default Rate');
|
||||||
|
|
||||||
|
// Verify the input is disabled and shows the org rate (formatted with currency)
|
||||||
|
const billableInput = page.getByPlaceholder('Billable Rate');
|
||||||
|
await expect(billableInput).toBeDisabled();
|
||||||
|
await expect(billableInput).toHaveAttribute('aria-valuenow', '150');
|
||||||
|
|
||||||
|
// Close the dialog
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that cancelling the billable rate confirmation dialog does not update the member', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
// Create a placeholder member with a custom billable rate
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'CancelConfirm Member');
|
||||||
|
const members = await getMembersViaApi(ctx);
|
||||||
|
const member = members.find((m) => m.name === 'CancelConfirm Member');
|
||||||
|
expect(member).toBeDefined();
|
||||||
|
await updateMemberBillableRateViaApi(ctx, member!.id, 10000);
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
const memberRow = page.getByRole('row').filter({ hasText: 'CancelConfirm Member' });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
await memberRow.getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Change the billable rate
|
||||||
|
await page.getByPlaceholder('Billable Rate').fill('200');
|
||||||
|
|
||||||
|
// Click Update Member — confirmation dialog should appear
|
||||||
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
|
||||||
|
|
||||||
|
// Set up listener to verify no PUT request is sent after cancel
|
||||||
|
let putRequestSent = false;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('/members/') && request.method() === 'PUT') {
|
||||||
|
putRequestSent = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click Cancel on the confirmation dialog
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
|
||||||
|
// Verify confirmation dialog is closed
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Update Member Billable Rate' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify no API call was made
|
||||||
|
expect(putRequestSent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => {
|
||||||
|
const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a placeholder member via import
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
|
||||||
|
|
||||||
|
// Go to members page and verify placeholder exists with role "Placeholder"
|
||||||
|
await goToMembersPage(page);
|
||||||
|
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
await expect(memberRow.getByText('Placeholder', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Open the edit modal for the placeholder member
|
||||||
|
await memberRow.getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Change role to Employee
|
||||||
|
const roleSelect = page.getByRole('dialog').getByRole('combobox').first();
|
||||||
|
await roleSelect.click();
|
||||||
|
await expect(page.getByRole('option', { name: 'Employee' })).toBeVisible();
|
||||||
|
await page.getByRole('option', { name: 'Employee' }).click();
|
||||||
|
await expect(roleSelect).toContainText('Employee');
|
||||||
|
|
||||||
|
// Submit the change - the API should reject it with 400
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Update Member' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/members/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 400
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify error notification is shown
|
||||||
|
await expect(page.getByText('Failed to update member')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that changing member role updates the role in the member table', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const memberId = Math.floor(Math.random() * 100000);
|
||||||
|
const memberEmail = `member+${memberId}@rolechange.test`;
|
||||||
|
|
||||||
|
// Invite and accept a new Employee member
|
||||||
|
await inviteAndAcceptMember(page, browser, 'Jane Smith', memberEmail, 'Employee');
|
||||||
|
|
||||||
|
// Verify the new member appears with the Employee role
|
||||||
|
await goToMembersPage(page);
|
||||||
|
const memberRow = page.getByRole('row').filter({ hasText: 'Jane Smith' });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Open the edit modal
|
||||||
|
await memberRow.getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Change role to Manager
|
||||||
|
const roleSelect = page.getByRole('dialog').getByRole('combobox').first();
|
||||||
|
await roleSelect.click();
|
||||||
|
await expect(page.getByRole('option', { name: 'Manager' })).toBeVisible();
|
||||||
|
await page.getByRole('option', { name: 'Manager' }).click();
|
||||||
|
await expect(roleSelect).toContainText('Manager');
|
||||||
|
|
||||||
|
// Submit the change and verify the API call succeeds
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Update Member' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/members/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify dialog closed
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify the role updated in the table
|
||||||
|
await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that merging a placeholder member works', async ({ page, ctx }) => {
|
||||||
|
const placeholderName = 'Merge Target ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a placeholder member via import
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
|
||||||
|
|
||||||
|
// Go to members page
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await expect(page.getByText(placeholderName)).toBeVisible();
|
||||||
|
|
||||||
|
// Find the placeholder member row and open actions menu
|
||||||
|
const placeholderRow = page.getByRole('row').filter({ hasText: placeholderName });
|
||||||
|
await placeholderRow.getByRole('button').click();
|
||||||
|
|
||||||
|
// Click Merge
|
||||||
|
await page.getByTestId('member_merge').click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Select the current user (the owner) as merge target via MemberCombobox
|
||||||
|
// The MemberCombobox renders a Button as trigger; clicking it opens the popover with the combobox input
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click();
|
||||||
|
|
||||||
|
// Wait for dropdown options to load
|
||||||
|
const firstOption = page.getByRole('option').first();
|
||||||
|
await expect(firstOption).toBeVisible({ timeout: 10000 });
|
||||||
|
await firstOption.click();
|
||||||
|
|
||||||
|
// Submit merge
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Merge Member' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/member/') &&
|
||||||
|
response.url().includes('/merge-into') &&
|
||||||
|
response.ok()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wait for merge dialog to close after successful merge
|
||||||
|
await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify placeholder member is no longer in the members table
|
||||||
|
await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that deleting a placeholder member works', async ({ page, ctx }) => {
|
||||||
|
const placeholderName = 'Delete Target ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a placeholder member via import
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
|
||||||
|
|
||||||
|
// Go to members page
|
||||||
|
await goToMembersPage(page);
|
||||||
|
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
|
||||||
|
// Open actions menu and click Delete
|
||||||
|
await memberRow.getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Delete').click();
|
||||||
|
|
||||||
|
// Verify delete modal is shown
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Try to delete without checking the confirmation checkbox
|
||||||
|
await page.getByRole('button', { name: 'Delete Member' }).click();
|
||||||
|
|
||||||
|
// Should show validation error
|
||||||
|
await expect(
|
||||||
|
page.getByText('You must confirm that you understand the consequences of this action')
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Check the confirmation checkbox
|
||||||
|
await page.getByRole('checkbox').click();
|
||||||
|
|
||||||
|
// Click Delete Member button and wait for API response
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Delete Member' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/members/') &&
|
||||||
|
response.request().method() === 'DELETE' &&
|
||||||
|
response.ok()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify modal is closed
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify member is removed from the table
|
||||||
|
await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that member delete modal can be cancelled', async ({ page, ctx }) => {
|
||||||
|
const placeholderName = 'Delete Cancel ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
// Create a placeholder member via import
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
|
||||||
|
|
||||||
|
// Go to members page
|
||||||
|
await goToMembersPage(page);
|
||||||
|
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
|
||||||
|
// Open actions menu and click Delete
|
||||||
|
await memberRow.getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Delete').click();
|
||||||
|
|
||||||
|
// Verify delete modal is shown
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Set up listener to verify no DELETE request is sent
|
||||||
|
let deleteRequestSent = false;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('/members/') && request.method() === 'DELETE') {
|
||||||
|
deleteRequestSent = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click Cancel
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
|
||||||
|
// Verify modal is closed
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify member is still in the table
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
|
||||||
|
// Verify no DELETE request was sent
|
||||||
|
expect(deleteRequestSent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that organization owner cannot be deleted', async ({ page }) => {
|
||||||
|
await goToMembersPage(page);
|
||||||
|
|
||||||
|
// Find the owner row (John Doe with Owner role)
|
||||||
|
const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' });
|
||||||
|
await expect(ownerRow).toBeVisible();
|
||||||
|
|
||||||
|
// Open the actions menu for the owner
|
||||||
|
await ownerRow.getByRole('button').click();
|
||||||
|
|
||||||
|
// Click Delete
|
||||||
|
await page.getByRole('menuitem').getByText('Delete').click();
|
||||||
|
|
||||||
|
// Verify delete modal is shown
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Check the confirmation checkbox
|
||||||
|
await page.getByRole('checkbox').click();
|
||||||
|
|
||||||
|
// Try to delete - should fail with 400 error
|
||||||
|
const responsePromise = page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/members/') && response.request().method() === 'DELETE'
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Delete Member' }).click();
|
||||||
|
const response = await responsePromise;
|
||||||
|
|
||||||
|
// Verify the API returned an error status
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
|
||||||
|
// Close the modal by pressing Escape
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// Refresh and verify the owner is still there
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await expect(page.getByRole('row').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Context Menu Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that member context menu edit updates the member billable rate', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const memberName = 'CtxEditMember ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, memberName);
|
||||||
|
await goToMembersPage(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: memberName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Change billable rate from default to custom
|
||||||
|
const billableRateSelect = page.getByRole('dialog').getByRole('combobox').last();
|
||||||
|
await billableRateSelect.click();
|
||||||
|
await page.getByRole('option', { name: 'Custom Rate' }).click();
|
||||||
|
|
||||||
|
// Set a custom billable rate
|
||||||
|
await page.getByPlaceholder('Billable Rate').fill('150');
|
||||||
|
|
||||||
|
// Click Update Member — confirmation dialog should appear
|
||||||
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
|
||||||
|
|
||||||
|
// Confirm the billable rate change
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Yes, update existing time entries' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/members/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify dialog closed
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that member context menu merge merges the member', async ({ page, ctx }) => {
|
||||||
|
const memberName = 'CtxMergeMember ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, memberName);
|
||||||
|
await goToMembersPage(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: memberName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Merge' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Select the first available member as merge target
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click();
|
||||||
|
const firstOption = page.getByRole('option').first();
|
||||||
|
await expect(firstOption).toBeVisible({ timeout: 10000 });
|
||||||
|
await firstOption.click();
|
||||||
|
|
||||||
|
// Submit merge
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Merge Member' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/member/') &&
|
||||||
|
response.url().includes('/merge-into') &&
|
||||||
|
response.ok()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify placeholder member is no longer visible
|
||||||
|
await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('main').getByText(memberName)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that member context menu deactivate deactivates the member', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const memberId = Math.floor(Math.random() * 100000);
|
||||||
|
const memberEmail = `member+${memberId}@deactivate.test`;
|
||||||
|
const memberName = 'Deactivate Target';
|
||||||
|
|
||||||
|
// Invite and accept a new Employee member
|
||||||
|
await inviteAndAcceptMember(page, browser, memberName, memberEmail, 'Employee');
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
const row = page.getByRole('row').filter({ hasText: memberName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
// Open context menu and click Deactivate
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Deactivate' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Deactivate User' })).toBeVisible();
|
||||||
|
|
||||||
|
// Confirm deactivation
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Deactivate' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/make-placeholder') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.ok()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify dialog closed and member role changed to Placeholder
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
await expect(row.getByText('Placeholder', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that member context menu delete deletes the member', async ({ page, ctx }) => {
|
||||||
|
const memberName = 'CtxDeleteMember ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, memberName);
|
||||||
|
await goToMembersPage(page);
|
||||||
|
|
||||||
|
const row = page.getByRole('row').filter({ hasText: memberName }).first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await row.click({ button: 'right' });
|
||||||
|
await expect(page.getByRole('menu')).toBeVisible();
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Check the confirmation checkbox
|
||||||
|
await page.getByRole('checkbox').click();
|
||||||
|
|
||||||
|
// Click Delete Member button and wait for API response
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Delete Member' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/members/') &&
|
||||||
|
response.request().method() === 'DELETE' &&
|
||||||
|
response.ok()
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify modal closed and member removed from table
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('main').getByText(memberName)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Invitations Tab Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that invitation shows in invitations tab and can be revoked', async ({ page }) => {
|
||||||
|
const inviteEmail = `invite+${Math.floor(Math.random() * 100000)}@pending.test`;
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await openInviteMemberModal(page);
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Member Email').fill(inviteEmail);
|
||||||
|
await page.getByRole('button', { name: 'Employee' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/invitations') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wait for modal to close
|
||||||
|
await expect(page.getByPlaceholder('Member Email')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Switch to Invitations tab and verify the invitation is visible
|
||||||
|
await page.getByText('Invitations', { exact: true }).click();
|
||||||
|
await expect(page.getByText(inviteEmail)).toBeVisible();
|
||||||
|
|
||||||
|
// Find and click the actions menu for this invitation
|
||||||
|
const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail });
|
||||||
|
await invitationRow.getByRole('button').click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/invitations/') &&
|
||||||
|
response.request().method() === 'DELETE' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem').getByText('Delete').click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify invitation is removed
|
||||||
|
await expect(page.getByText(inviteEmail)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that invitation can be resent', async ({ page }) => {
|
||||||
|
const inviteEmail = `resend+${Math.floor(Math.random() * 100000)}@invite.test`;
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await openInviteMemberModal(page);
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Member Email').fill(inviteEmail);
|
||||||
|
await page.getByRole('button', { name: 'Employee' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/invitations') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wait for modal to close
|
||||||
|
await expect(page.getByPlaceholder('Member Email')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Switch to Invitations tab
|
||||||
|
await page.getByText('Invitations', { exact: true }).click();
|
||||||
|
await expect(page.getByText(inviteEmail)).toBeVisible();
|
||||||
|
|
||||||
|
// Find and click the actions menu, then resend
|
||||||
|
const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail });
|
||||||
|
await invitationRow.getByRole('button').click();
|
||||||
|
// Wait for dropdown menu to appear
|
||||||
|
await expect(page.getByRole('menuitem').getByText('Resend Invitation')).toBeVisible();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/resend') && response.request().method() === 'POST'
|
||||||
|
),
|
||||||
|
page.getByRole('menuitem').getByText('Resend Invitation').click(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that admin user cannot transfer ownership', async ({ page, browser }) => {
|
||||||
|
const memberId = Math.floor(Math.random() * 100000);
|
||||||
|
const memberEmail = `admin+${memberId}@perms.test`;
|
||||||
|
|
||||||
|
// Invite and accept an admin member
|
||||||
|
await inviteAndAcceptMember(
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
'Admin User ' + memberId,
|
||||||
|
memberEmail,
|
||||||
|
'Administrator'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Go to members page and verify the admin exists
|
||||||
|
await goToMembersPage(page);
|
||||||
|
const adminRow = page.getByRole('row').filter({ hasText: 'Admin User' });
|
||||||
|
await expect(adminRow).toBeVisible();
|
||||||
|
|
||||||
|
// The owner should still be the owner
|
||||||
|
const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' });
|
||||||
|
await expect(ownerRow).toBeVisible();
|
||||||
|
|
||||||
|
// Open actions menu for the admin - should NOT have "Transfer Ownership" option
|
||||||
|
await adminRow.getByRole('button').click();
|
||||||
|
await expect(page.getByRole('menuitem').getByText('Edit')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that accepted invitation disappears from invitations tab', async ({ page, browser }) => {
|
||||||
|
const memberId = Math.round(Math.random() * 100000);
|
||||||
|
const memberEmail = `accepted+${memberId}@invite.test`;
|
||||||
|
|
||||||
|
// Invite and accept the member
|
||||||
|
await inviteAndAcceptMember(page, browser, 'Accepted Member', memberEmail, 'Employee');
|
||||||
|
|
||||||
|
// Go to members page and switch to Invitations tab
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await page.getByRole('tab', { name: 'Invitations' }).click();
|
||||||
|
|
||||||
|
// The accepted invitation should not be visible
|
||||||
|
await expect(page.getByText(memberEmail)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Sorting Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Helper to clear localStorage before tests that check sorting
|
||||||
|
async function clearMemberTableState(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem('member-table-state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
|
||||||
|
// Create two placeholder members with names that sort predictably around "John Doe"
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await clearMemberTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('member_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// -- Name sorting (default is already name asc after clearing state) --
|
||||||
|
const nameHeader = table.getByText('Name').first();
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
|
||||||
|
|
||||||
|
await nameHeader.click(); // toggle to desc
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('ZZZ SortLast')).toBeLessThan(names.indexOf('AAA SortFirst'));
|
||||||
|
|
||||||
|
// -- Role sorting --
|
||||||
|
const roleHeader = table.getByText('Role').first();
|
||||||
|
await roleHeader.click(); // asc: Owner(0) < Placeholder(4)
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
const ownerIdx = names.indexOf('John Doe');
|
||||||
|
const placeholderIdx = names.indexOf('AAA SortFirst');
|
||||||
|
expect(ownerIdx).toBeLessThan(placeholderIdx);
|
||||||
|
|
||||||
|
await roleHeader.click(); // desc: Placeholder first
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
|
||||||
|
|
||||||
|
// -- Status sorting --
|
||||||
|
const statusHeader = table.getByText('Status').first();
|
||||||
|
await statusHeader.click(); // asc: Active(0) < Inactive(1)
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
|
||||||
|
|
||||||
|
await statusHeader.click(); // desc: Inactive first
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
|
||||||
|
|
||||||
|
// -- Email: just verify sort indicator appears --
|
||||||
|
const emailHeader = table.getByText('Email').first();
|
||||||
|
await emailHeader.click();
|
||||||
|
await expect(emailHeader.locator('svg')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that member sort state persists after page reload', async ({ page }) => {
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await clearMemberTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('member_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// Click Role header twice to set descending sort
|
||||||
|
const roleHeader = table.getByText('Role').first();
|
||||||
|
await roleHeader.click();
|
||||||
|
await expect(roleHeader.locator('svg')).toBeVisible();
|
||||||
|
await roleHeader.click();
|
||||||
|
await expect(roleHeader.locator('svg')).toBeVisible();
|
||||||
|
|
||||||
|
// Reload the page
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify the sort indicator is still visible on Role column
|
||||||
|
await expect(page.getByTestId('member_table')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('member_table').getByText('Role').first().locator('svg')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that sorting members by billable rate works', async ({ page, ctx }) => {
|
||||||
|
// Create two placeholder members and set different billable rates
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'HighRate Member');
|
||||||
|
await createPlaceholderMemberViaImportApi(ctx, 'LowRate Member');
|
||||||
|
|
||||||
|
const members = await getMembersViaApi(ctx);
|
||||||
|
const highRateMember = members.find((m) => m.name === 'HighRate Member');
|
||||||
|
const lowRateMember = members.find((m) => m.name === 'LowRate Member');
|
||||||
|
expect(highRateMember).toBeDefined();
|
||||||
|
expect(lowRateMember).toBeDefined();
|
||||||
|
|
||||||
|
await updateMemberBillableRateViaApi(ctx, highRateMember!.id, 20000);
|
||||||
|
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
|
||||||
|
|
||||||
|
await goToMembersPage(page);
|
||||||
|
await clearMemberTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const table = page.getByTestId('member_table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// First click = desc (highest first), null rates last
|
||||||
|
const billableHeader = table.getByText('Billable Rate').first();
|
||||||
|
await billableHeader.click();
|
||||||
|
await expect(billableHeader.locator('svg')).toBeVisible();
|
||||||
|
let names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('HighRate Member')).toBeLessThan(names.indexOf('LowRate Member'));
|
||||||
|
|
||||||
|
// Second click = asc (lowest first), null rates still last
|
||||||
|
await billableHeader.click();
|
||||||
|
names = await getTableRowNames(table);
|
||||||
|
expect(names.indexOf('LowRate Member')).toBeLessThan(names.indexOf('HighRate Member'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Employee Permission Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test.describe('Employee Sidebar Navigation', () => {
|
||||||
|
test('employee sidebar shows correct navigation links', async ({ employee }) => {
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||||
|
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Visible links
|
||||||
|
await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
|
||||||
|
await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible();
|
||||||
|
await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible();
|
||||||
|
await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible();
|
||||||
|
await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible();
|
||||||
|
await expect(employee.page.getByRole('link', { name: 'Tags' })).toBeVisible();
|
||||||
|
|
||||||
|
// Hidden links
|
||||||
|
await expect(employee.page.getByRole('link', { name: 'Members' })).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('link', { name: 'Settings', exact: true })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('employee cannot see members list or invite members', async ({ employee }) => {
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
|
||||||
|
// Page loads but the members API returns 403 (no members:view permission)
|
||||||
|
await expect(employee.page.getByRole('heading', { name: 'Members' })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Member table is empty — no rows rendered (only headers)
|
||||||
|
await expect(employee.page.getByTestId('member_table').locator('[role="row"]')).toHaveCount(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Employee should NOT see the Invite Member button
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('button', { name: 'Invite member' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -223,9 +223,211 @@ test('test that format settings are reflected in the dashboard', async ({ page }
|
|||||||
|
|
||||||
// check that the current date is displayed in the dd/mm/yyyy format on the time page
|
// check that the current date is displayed in the dd/mm/yyyy format on the time page
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||||
|
// Wait for time entries to load so organization data is available for date formatting
|
||||||
|
await page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/time-entries') && response.status() === 200
|
||||||
|
);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)
|
page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)
|
||||||
).toBeVisible();
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Test 12-hour clock format
|
test('test that organization time entry settings can be toggled', async ({ page }) => {
|
||||||
|
await goToOrganizationSettings(page);
|
||||||
|
|
||||||
|
const preventOverlappingCheckbox = page.getByLabel(
|
||||||
|
'Prevent overlapping time entries (new entries only)'
|
||||||
|
);
|
||||||
|
const manageTasksCheckbox = page.getByLabel('Allow Employees to manage tasks');
|
||||||
|
|
||||||
|
// Get current states and toggle both
|
||||||
|
const wasOverlappingChecked = await preventOverlappingCheckbox.isChecked();
|
||||||
|
const wasManageTasksChecked = await manageTasksCheckbox.isChecked();
|
||||||
|
|
||||||
|
if (wasOverlappingChecked) {
|
||||||
|
await preventOverlappingCheckbox.uncheck();
|
||||||
|
} else {
|
||||||
|
await preventOverlappingCheckbox.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasManageTasksChecked) {
|
||||||
|
await manageTasksCheckbox.uncheck();
|
||||||
|
} else {
|
||||||
|
await manageTasksCheckbox.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const settingsForm = page.locator('form').filter({ hasText: 'Prevent overlapping' });
|
||||||
|
await Promise.all([
|
||||||
|
settingsForm.getByRole('button', { name: 'Save' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/organizations/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
(await response.json()).data.prevent_overlapping_time_entries ===
|
||||||
|
!wasOverlappingChecked
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reload and verify both settings persisted
|
||||||
|
await page.reload();
|
||||||
|
await expect(preventOverlappingCheckbox).toBeChecked({ checked: !wasOverlappingChecked });
|
||||||
|
await expect(manageTasksCheckbox).toBeChecked({ checked: !wasManageTasksChecked });
|
||||||
|
|
||||||
|
// Toggle both back to restore original state
|
||||||
|
if (!wasOverlappingChecked) {
|
||||||
|
await preventOverlappingCheckbox.uncheck();
|
||||||
|
} else {
|
||||||
|
await preventOverlappingCheckbox.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wasManageTasksChecked) {
|
||||||
|
await manageTasksCheckbox.uncheck();
|
||||||
|
} else {
|
||||||
|
await manageTasksCheckbox.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
settingsForm.getByRole('button', { name: 'Save' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/organizations/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
(await response.json()).data.prevent_overlapping_time_entries ===
|
||||||
|
wasOverlappingChecked
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that 12-hour clock format can be set', async ({ page }) => {
|
||||||
|
await goToOrganizationSettings(page);
|
||||||
|
|
||||||
|
await page.getByLabel('Time Format').click();
|
||||||
|
await page.getByRole('option', { name: '12-hour clock' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page
|
||||||
|
.locator('form')
|
||||||
|
.filter({ hasText: 'Time Format' })
|
||||||
|
.getByRole('button', { name: 'Save' })
|
||||||
|
.click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/organizations/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
(await response.json()).data.time_format === '12-hours'
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reload and verify it persisted
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel('Time Format')).toContainText('12-hour clock');
|
||||||
|
|
||||||
|
// Reset back to 24-hour
|
||||||
|
await page.getByLabel('Time Format').click();
|
||||||
|
await page.getByRole('option', { name: '24-hour clock' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page
|
||||||
|
.locator('form')
|
||||||
|
.filter({ hasText: 'Time Format' })
|
||||||
|
.getByRole('button', { name: 'Save' })
|
||||||
|
.click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/organizations/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
(await response.json()).data.time_format === '24-hours'
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that format settings persist after page reload', async ({ page }) => {
|
||||||
|
await goToOrganizationSettings(page);
|
||||||
|
|
||||||
|
// Set a specific date format
|
||||||
|
await page.getByLabel('Date Format').click();
|
||||||
|
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page
|
||||||
|
.locator('form')
|
||||||
|
.filter({ hasText: 'Date Format' })
|
||||||
|
.getByRole('button', { name: 'Save' })
|
||||||
|
.click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/organizations/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reload and verify it persisted
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Admin Permission Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test.describe('Admin Organization Settings Access', () => {
|
||||||
|
test('admin can see and edit organization settings', async ({ ctx, admin }) => {
|
||||||
|
await admin.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
|
||||||
|
|
||||||
|
// Organization Name section is visible
|
||||||
|
await expect(
|
||||||
|
admin.page.getByRole('heading', { name: 'Organization Name', level: 3 })
|
||||||
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Editable settings sections should be visible
|
||||||
|
await expect(
|
||||||
|
admin.page.getByRole('heading', { name: 'Billable Rate', level: 3 })
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
admin.page.getByRole('heading', { name: 'Format Settings', level: 3 })
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
admin.page.getByRole('heading', { name: 'Organization Settings', level: 3 })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Save buttons should be visible (admin can update)
|
||||||
|
await expect(admin.page.getByRole('button', { name: 'Save' }).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Delete organization should NOT be visible (owner only)
|
||||||
|
await expect(
|
||||||
|
admin.page.getByRole('heading', { name: 'Delete Organization' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Employee Permission Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test.describe('Employee Organization Settings Restrictions', () => {
|
||||||
|
test('employee can see org name but not editable settings', async ({ ctx, employee }) => {
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
|
||||||
|
|
||||||
|
// Organization Name section is visible (but inputs are disabled)
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('heading', { name: 'Organization Name', level: 3 })
|
||||||
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Editable settings sections should NOT be visible
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('heading', { name: 'Billable Rate', level: 3 })
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('heading', { name: 'Format Settings', level: 3 })
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('heading', { name: 'Organization Settings', level: 3 })
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
|
// Save button should not be visible (employee cannot update)
|
||||||
|
await expect(employee.page.getByRole('button', { name: 'Save' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { test, expect } from '../playwright/fixtures';
|
import { test, expect } from '../playwright/fixtures';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
async function goToProfilePage(page: Page) {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
|
}
|
||||||
|
|
||||||
test('test that user name can be updated', async ({ page }) => {
|
test('test that user name can be updated', async ({ page }) => {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
@@ -39,6 +44,28 @@ test('test that user can create an API key', async ({ page }) => {
|
|||||||
await createNewApiToken(page);
|
await createNewApiToken(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that creating an API key with empty name shows validation error', async ({ page }) => {
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
|
|
||||||
|
// Wait for the API Key Name input to be visible before interacting
|
||||||
|
const nameInput = page.getByLabel('API Key Name');
|
||||||
|
await expect(nameInput).toBeVisible();
|
||||||
|
|
||||||
|
// Ensure the API Key Name input is empty
|
||||||
|
await nameInput.fill('');
|
||||||
|
|
||||||
|
// Click the create button and wait for the 422 response
|
||||||
|
const [response] = await Promise.all([
|
||||||
|
page.waitForResponse('**/users/me/api-tokens'),
|
||||||
|
page.getByRole('button', { name: 'Create API Key' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(response.status()).toBe(422);
|
||||||
|
|
||||||
|
// Verify that an error notification is shown with validation message about the name field
|
||||||
|
await expect(page.getByText('name field is required')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
test('test that user can delete an API key', async ({ page }) => {
|
test('test that user can delete an API key', async ({ page }) => {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||||
await createNewApiToken(page);
|
await createNewApiToken(page);
|
||||||
@@ -68,3 +95,285 @@ test('test that user can revoke an API key', async ({ page }) => {
|
|||||||
await expect(page.locator('body')).toContainText('NEW API KEY');
|
await expect(page.locator('body')).toContainText('NEW API KEY');
|
||||||
await expect(page.locator('body')).toContainText('Revoked');
|
await expect(page.locator('body')).toContainText('Revoked');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Update Password Form Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that password mismatch shows error', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
|
||||||
|
// Fill in with mismatched passwords
|
||||||
|
await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);
|
||||||
|
await page.getByLabel('New Password').fill('newSecurePassword456');
|
||||||
|
await page.getByLabel('Confirm Password').fill('differentPassword789');
|
||||||
|
|
||||||
|
// Find the form containing the Confirm Password field and click its Save button
|
||||||
|
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/user/password') && response.request().method() === 'PUT'
|
||||||
|
),
|
||||||
|
passwordForm.getByRole('button', { name: 'Save' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify error message about password confirmation
|
||||||
|
await expect(page.getByText('confirmation does not match')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that short password shows validation error', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
|
||||||
|
// Fill in with a too short password
|
||||||
|
await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);
|
||||||
|
await page.getByLabel('New Password').fill('short');
|
||||||
|
await page.getByLabel('Confirm Password').fill('short');
|
||||||
|
|
||||||
|
// Find the form containing the Confirm Password field and click its Save button
|
||||||
|
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/user/password') && response.request().method() === 'PUT'
|
||||||
|
),
|
||||||
|
passwordForm.getByRole('button', { name: 'Save' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify error message about password length
|
||||||
|
await expect(page.getByText('must be at least')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that incorrect current password shows validation error', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
|
||||||
|
// Fill in with wrong current password
|
||||||
|
await page.getByLabel('Current Password').fill('wrongCurrentPassword123');
|
||||||
|
await page.getByLabel('New Password').fill('newSecurePassword456');
|
||||||
|
await page.getByLabel('Confirm Password').fill('newSecurePassword456');
|
||||||
|
|
||||||
|
// Find the form containing the Confirm Password field and click its Save button
|
||||||
|
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/user/password') && response.request().method() === 'PUT'
|
||||||
|
),
|
||||||
|
passwordForm.getByRole('button', { name: 'Save' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify error message about incorrect password
|
||||||
|
await expect(page.getByText('does not match')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that password can be updated successfully', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
const newPassword = 'newSecurePassword456';
|
||||||
|
|
||||||
|
// Change password to new password
|
||||||
|
await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD);
|
||||||
|
await page.getByLabel('New Password').fill(newPassword);
|
||||||
|
await page.getByLabel('Confirm Password').fill(newPassword);
|
||||||
|
|
||||||
|
const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form');
|
||||||
|
const responsePromise = page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/user/password') && response.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
await passwordForm.getByRole('button', { name: 'Save' }).click();
|
||||||
|
const response = await responsePromise;
|
||||||
|
|
||||||
|
// Verify successful response (303 is Inertia redirect on success, means password was updated)
|
||||||
|
expect(response.status()).toBe(303);
|
||||||
|
|
||||||
|
// Verify no error messages are displayed
|
||||||
|
await expect(page.getByText('does not match')).not.toBeVisible();
|
||||||
|
await expect(page.getByText('must be at least')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Theme Selection Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that theme can be changed to dark and light', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
|
||||||
|
// The theme select is a Reka UI combobox (button), not a native <select>
|
||||||
|
const themeSelect = page.locator('button[role="combobox"]');
|
||||||
|
|
||||||
|
// Change theme to dark
|
||||||
|
await themeSelect.click();
|
||||||
|
await page.getByRole('option', { name: 'Dark' }).click();
|
||||||
|
|
||||||
|
// Verify the html element has 'dark' class
|
||||||
|
await expect(page.locator('html')).toHaveClass(/dark/);
|
||||||
|
|
||||||
|
// Change theme to light
|
||||||
|
await themeSelect.click();
|
||||||
|
await page.getByRole('option', { name: 'Light' }).click();
|
||||||
|
|
||||||
|
// Verify the html element has 'light' class and no 'dark' class
|
||||||
|
await expect(page.locator('html')).toHaveClass(/light/);
|
||||||
|
await expect(page.locator('html')).not.toHaveClass(/dark/);
|
||||||
|
|
||||||
|
// Verify localStorage persists the setting
|
||||||
|
const storedTheme = await page.evaluate(() => localStorage.getItem('theme'));
|
||||||
|
expect(storedTheme).toContain('light');
|
||||||
|
|
||||||
|
// Reload and verify the theme persists
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('html')).toHaveClass(/light/);
|
||||||
|
|
||||||
|
// Reset to system
|
||||||
|
await page.locator('button[role="combobox"]').click();
|
||||||
|
await page.getByRole('option', { name: 'System' }).click();
|
||||||
|
await expect(page.getByText('System default:')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Group similar time entries
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that group similar time entries setting can be toggled', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
|
||||||
|
// Get the checkbox
|
||||||
|
const checkbox = page.getByLabel('Group similar time entries');
|
||||||
|
|
||||||
|
// Get initial value and verify it is checked (default is true)
|
||||||
|
const initialValue = await checkbox.isChecked();
|
||||||
|
await expect(checkbox).toBeChecked();
|
||||||
|
|
||||||
|
// Toggle the checkbox
|
||||||
|
await checkbox.click();
|
||||||
|
|
||||||
|
// Reload
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify the value is toggled
|
||||||
|
const afterValue = await page.getByLabel('Group similar time entries').isChecked();
|
||||||
|
expect(afterValue).toBe(!initialValue);
|
||||||
|
|
||||||
|
// Verify localStorage persists the setting
|
||||||
|
const storedValue = await page.evaluate(() =>
|
||||||
|
localStorage.getItem('group-similar-time-entries')
|
||||||
|
);
|
||||||
|
expect(storedValue).toBe(String(!initialValue));
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Two Factor Authentication Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that password confirmation modal can be cancelled without sending API request', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
|
||||||
|
// Find the Enable button in the 2FA section
|
||||||
|
const enableButton = page
|
||||||
|
.getByText('You have not enabled two factor authentication.')
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('button', { name: 'Enable' });
|
||||||
|
await enableButton.click();
|
||||||
|
|
||||||
|
// Verify password confirmation modal appears
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Set up listener to verify no POST request is sent to confirm-password
|
||||||
|
let confirmPasswordRequestSent = false;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('/user/confirm-password') && request.method() === 'POST') {
|
||||||
|
confirmPasswordRequestSent = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click Cancel
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
|
||||||
|
// Verify modal is closed
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify no confirm-password request was sent
|
||||||
|
expect(confirmPasswordRequestSent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that password confirmation modal shows error for incorrect password', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
|
||||||
|
// Find the Enable button in the 2FA section
|
||||||
|
const enableButton = page
|
||||||
|
.getByText('You have not enabled two factor authentication.')
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('button', { name: 'Enable' });
|
||||||
|
await enableButton.click();
|
||||||
|
|
||||||
|
// Verify password confirmation modal appears
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Enter incorrect password and confirm
|
||||||
|
await page.getByPlaceholder('Password').fill('wrongpassword123');
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
|
||||||
|
// Should show error message (wait longer for API response)
|
||||||
|
await expect(page.getByRole('dialog').getByText('incorrect')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that 2FA can be enabled with correct password', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
|
||||||
|
// Verify 2FA is not enabled
|
||||||
|
await expect(page.getByText('You have not enabled two factor authentication.')).toBeVisible();
|
||||||
|
|
||||||
|
// Find the Enable button in the 2FA section
|
||||||
|
const enableButton = page
|
||||||
|
.getByText('You have not enabled two factor authentication.')
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('button', { name: 'Enable' });
|
||||||
|
await enableButton.click();
|
||||||
|
|
||||||
|
// Verify password confirmation modal appears
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Enter correct password and confirm
|
||||||
|
await page.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/user/two-factor-authentication') &&
|
||||||
|
response.request().method() === 'POST'
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify QR code is shown
|
||||||
|
await expect(page.getByRole('heading', { name: 'Finish enabling two factor' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Setup Key:')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Code')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Logout Other Browser Sessions Tests
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test('test that logout other browser sessions works with correct password', async ({ page }) => {
|
||||||
|
await goToProfilePage(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Log Out Other Browser Sessions' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Password').fill(TEST_USER_PASSWORD);
|
||||||
|
await Promise.all([
|
||||||
|
page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByRole('button', { name: 'Log Out Other Browser Sessions' })
|
||||||
|
.click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/user/other-browser-sessions') &&
|
||||||
|
response.request().method() === 'DELETE'
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,33 +1,27 @@
|
|||||||
import { expect, Page } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { test } from '../playwright/fixtures';
|
import { test } from '../playwright/fixtures';
|
||||||
import { formatCentsWithOrganizationDefaults } from './utils/money';
|
import { formatCentsWithOrganizationDefaults } from './utils/money';
|
||||||
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
|
import { createProjectViaApi, createProjectMemberViaApi, type TestContext } from './utils/api';
|
||||||
import { NumberFormat } from '@/packages/ui/src/utils/number';
|
|
||||||
|
|
||||||
async function goToProjectsOverview(page: Page) {
|
async function createProjectWithMemberViaApi(ctx: TestContext, page: Page, projectName: string) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
await createProjectMemberViaApi(ctx, project.id, { member_id: ctx.memberId });
|
||||||
|
|
||||||
|
// Navigate to the project detail page
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
|
||||||
|
await expect(page.getByTestId('project_member_table').getByRole('row').first()).toBeVisible();
|
||||||
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('test that updating project member billable rate works for existing time entries', async ({
|
test('test that updating project member billable rate works for existing time entries', async ({
|
||||||
page,
|
page,
|
||||||
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
const newBillableRate = Math.round(Math.random() * 10000);
|
const newBillableRate = Math.round(Math.random() * 10000);
|
||||||
await goToProjectsOverview(page);
|
await createProjectWithMemberViaApi(ctx, page, newProjectName);
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
|
||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByText(newProjectName).click();
|
|
||||||
await page.getByRole('button', { name: 'Add Member' }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText('Add Project Member').first()).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'Select a member' }).click();
|
|
||||||
await page.keyboard.press('Enter');
|
|
||||||
await page.getByRole('button', { name: 'Add Project Member' }).click();
|
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByTestId('project_member_table')
|
.getByTestId('project_member_table')
|
||||||
@@ -62,3 +56,197 @@ test('test that updating project member billable rate works for existing time en
|
|||||||
.getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100))
|
.getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100))
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('test that project member edit modal can be cancelled without sending API request', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const projectName = 'Cancel Test ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
|
||||||
|
await createProjectWithMemberViaApi(ctx, page, projectName);
|
||||||
|
|
||||||
|
// Open the edit modal
|
||||||
|
await page
|
||||||
|
.getByTestId('project_member_table')
|
||||||
|
.getByRole('row')
|
||||||
|
.first()
|
||||||
|
.getByRole('button')
|
||||||
|
.click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
|
||||||
|
|
||||||
|
// Verify the modal is open and shows the member name
|
||||||
|
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('dialog').getByText('John Doe')).toBeVisible();
|
||||||
|
|
||||||
|
// Enter a new billable rate
|
||||||
|
await page.getByLabel('Billable Rate').fill('999');
|
||||||
|
|
||||||
|
// Set up listener to verify no PUT request is sent
|
||||||
|
let putRequestSent = false;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('/project-members/') && request.method() === 'PUT') {
|
||||||
|
putRequestSent = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click Cancel
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
|
||||||
|
// Verify the modal is closed
|
||||||
|
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify no PUT request was sent
|
||||||
|
expect(putRequestSent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that project member update without billable rate change skips confirmation and completes', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const projectName = 'No Change ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
|
||||||
|
await createProjectWithMemberViaApi(ctx, page, projectName);
|
||||||
|
|
||||||
|
// Open the edit modal
|
||||||
|
await page
|
||||||
|
.getByTestId('project_member_table')
|
||||||
|
.getByRole('row')
|
||||||
|
.first()
|
||||||
|
.getByRole('button')
|
||||||
|
.click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
|
||||||
|
|
||||||
|
// Click Update without changing anything - no confirmation modal since rate didn't change
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/project-members/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Update Project Member' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify the edit modal is closed (confirmation modal was skipped)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that billable rate confirmation modal can be cancelled without sending API request', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const projectName = 'Rate Cancel ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const newBillableRate = Math.round(Math.random() * 10000);
|
||||||
|
|
||||||
|
await createProjectWithMemberViaApi(ctx, page, projectName);
|
||||||
|
|
||||||
|
// Open the edit modal
|
||||||
|
await page
|
||||||
|
.getByTestId('project_member_table')
|
||||||
|
.getByRole('row')
|
||||||
|
.first()
|
||||||
|
.getByRole('button')
|
||||||
|
.click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
|
||||||
|
|
||||||
|
// Change the billable rate
|
||||||
|
await page.getByLabel('Billable Rate').fill(newBillableRate.toString());
|
||||||
|
|
||||||
|
// Set up listener to verify no PUT request is sent
|
||||||
|
let putRequestSent = false;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('/project-members/') && request.method() === 'PUT') {
|
||||||
|
putRequestSent = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click Update - this should show the confirmation modal
|
||||||
|
await page.getByRole('button', { name: 'Update Project Member' }).click();
|
||||||
|
|
||||||
|
// Verify the confirmation modal is shown
|
||||||
|
await expect(page.getByText('update all existing time entries')).toBeVisible();
|
||||||
|
|
||||||
|
// Click Cancel to close the confirmation modal without updating
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
|
||||||
|
// Verify the confirmation modal is closed but edit modal is still open
|
||||||
|
await expect(page.getByText('update all existing time entries')).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).toBeVisible();
|
||||||
|
|
||||||
|
// Close the edit modal
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
|
||||||
|
// Verify the edit modal is closed
|
||||||
|
await expect(page.getByRole('heading', { name: 'Edit Project Member' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify no PUT request was sent
|
||||||
|
expect(putRequestSent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that clearing billable rate reverts to project default', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'Revert Default ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const customRate = Math.round(100 + Math.random() * 10000);
|
||||||
|
|
||||||
|
await createProjectWithMemberViaApi(ctx, page, projectName);
|
||||||
|
|
||||||
|
// Verify the billable rate shows "--" (project default) initially
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('project_member_table').getByRole('row').first().getByText('--')
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Set a custom billable rate
|
||||||
|
await page
|
||||||
|
.getByTestId('project_member_table')
|
||||||
|
.getByRole('row')
|
||||||
|
.first()
|
||||||
|
.getByRole('button')
|
||||||
|
.click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
|
||||||
|
await page.getByLabel('Billable Rate').fill(customRate.toString());
|
||||||
|
await page.getByRole('button', { name: 'Update Project Member' }).click();
|
||||||
|
|
||||||
|
// Confirm the billable rate update
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/project-members/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify the custom rate is shown in the table (not "--")
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('project_member_table').getByRole('row').first().getByText('--')
|
||||||
|
).not.toBeVisible();
|
||||||
|
|
||||||
|
// Now clear the billable rate to revert to project default
|
||||||
|
await page
|
||||||
|
.getByTestId('project_member_table')
|
||||||
|
.getByRole('row')
|
||||||
|
.first()
|
||||||
|
.getByRole('button')
|
||||||
|
.click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
|
||||||
|
|
||||||
|
// Set billable rate to 0 to revert to project default
|
||||||
|
await page.getByLabel('Billable Rate').fill('0');
|
||||||
|
await page.getByRole('button', { name: 'Update Project Member' }).click();
|
||||||
|
|
||||||
|
// Confirm the billable rate update
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/project-members/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify the billable rate shows "--" again (project default)
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('project_member_table').getByRole('row').first().getByText('--')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
import { expect, Page } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||||
import { test } from '../playwright/fixtures';
|
import { test } from '../playwright/fixtures';
|
||||||
import { formatCentsWithOrganizationDefaults } from './utils/money';
|
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';
|
||||||
|
|
||||||
async function goToProjectsOverview(page: Page) {
|
async function goToProjectsOverview(page: Page) {
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to clear localStorage before tests that check persistence
|
||||||
|
async function clearProjectTableState(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem('project-table-state');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create new project via modal
|
// Create new project via modal
|
||||||
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
|
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
|
||||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
@@ -30,7 +46,7 @@ test('test that creating and deleting a new project via the modal works', async
|
|||||||
|
|
||||||
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||||
const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
|
const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
|
||||||
moreButton.click();
|
await moreButton.click();
|
||||||
const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");
|
const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -45,50 +61,80 @@ test('test that creating and deleting a new project via the modal works', async
|
|||||||
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
|
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that archiving and unarchiving projects works', async ({ page }) => {
|
// Helper to select a status filter using the new dropdown UI
|
||||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
async function selectStatusFilter(page: Page, status: 'Active' | 'Archived') {
|
||||||
await goToProjectsOverview(page);
|
// Click the Filter button to open the dropdown
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await page.getByRole('button', { name: 'Filter projects' }).click();
|
||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
// Click on Status submenu
|
||||||
|
await page.getByRole('menuitem', { name: 'Status' }).click();
|
||||||
|
// Select the status option
|
||||||
|
await page.getByRole('menuitem', { name: status }).click();
|
||||||
|
}
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
// Helper to remove status filter by clicking the X on the badge
|
||||||
|
async function removeStatusFilter(page: Page) {
|
||||||
|
const statusBadge = page.getByTestId('status-filter-badge');
|
||||||
|
// Click the remove button (second button in the badge, contains XMarkIcon)
|
||||||
|
await statusBadge.locator('button').last().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that archiving and unarchiving projects works', async ({ page, ctx }) => {
|
||||||
|
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createProjectViaApi(ctx, { name: newProjectName });
|
||||||
|
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearProjectTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Archive the project
|
||||||
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Archive').first().click();
|
||||||
|
|
||||||
|
// Project should still be visible since default is "all" (no filter)
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
// Apply Active filter - archived project should disappear
|
||||||
await Promise.all([
|
await selectStatusFilter(page, 'Active');
|
||||||
page.getByRole('menuitem').getByText('Archive').first().click(),
|
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
||||||
expect(page.getByText(newProjectName)).not.toBeVisible(),
|
|
||||||
]);
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('tab', { name: 'Archived' }).click(),
|
|
||||||
expect(page.getByText(newProjectName)).toBeVisible(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
// Remove Active filter and apply Archived filter
|
||||||
|
await removeStatusFilter(page);
|
||||||
|
await selectStatusFilter(page, 'Archived');
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Unarchive the project
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
await Promise.all([
|
await page.getByRole('menuitem').getByText('Unarchive').first().click();
|
||||||
page.getByRole('menuitem').getByText('Unarchive').first().click(),
|
|
||||||
expect(page.getByText(newProjectName)).not.toBeVisible(),
|
// Project should disappear from Archived view
|
||||||
]);
|
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('tab', { name: 'Active' }).click(),
|
// Remove Archived filter and apply Active filter to see the project
|
||||||
expect(page.getByText(newProjectName)).toBeVisible(),
|
await removeStatusFilter(page);
|
||||||
]);
|
await selectStatusFilter(page, 'Active');
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that updating billable rate works with existing time entries', async ({ page }) => {
|
test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => {
|
||||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
const newBillableRate = Math.round(Math.random() * 10000);
|
const newBillableRate = Math.round(Math.random() * 10000);
|
||||||
await goToProjectsOverview(page);
|
await createProjectViaApi(ctx, { name: newProjectName });
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
|
||||||
await page.getByLabel('Project Name').fill(newProjectName);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
await goToProjectsOverview(page);
|
||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
await page.getByRole('menuitem').getByText('Edit').first().click();
|
await page.getByRole('menuitem').getByText('Edit').first().click();
|
||||||
await page.getByText('Non-Billable').click();
|
|
||||||
await page.getByText('Custom Rate').click();
|
// Set billable default to Billable
|
||||||
|
await page.getByRole('dialog').locator('#billable').click();
|
||||||
|
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||||
|
|
||||||
|
// Set billable rate to Custom Rate
|
||||||
|
await page.getByRole('dialog').locator('#billableRateType').click();
|
||||||
|
await page.getByRole('option', { name: 'Custom Rate' }).click();
|
||||||
|
|
||||||
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
|
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
|
||||||
await page.getByRole('button', { name: 'Update Project' }).click();
|
await page.getByRole('button', { name: 'Update Project' }).click();
|
||||||
|
|
||||||
@@ -116,22 +162,808 @@ test('test that updating billable rate works with existing time entries', async
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create new project with new Client
|
test('test that creating a project with default billable rate works', async ({ page }) => {
|
||||||
|
const newProjectName = 'Default Rate Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
// Create new project with existing Client
|
// Set billable default to Billable (leaves rate type as Default Rate)
|
||||||
|
await page.getByRole('dialog').locator('#billable').click();
|
||||||
|
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||||
|
|
||||||
// Delete project via More Options
|
// Verify rate type is "Default Rate" and the rate input is disabled
|
||||||
|
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
|
||||||
|
'Default Rate'
|
||||||
|
);
|
||||||
|
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
|
||||||
|
|
||||||
// Test that project task count is displayed correctly
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201 &&
|
||||||
|
(await response.json()).data.is_billable === true &&
|
||||||
|
(await response.json()).data.billable_rate === null
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
// Test that active / archive / all filter works (once implemented)
|
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||||
|
});
|
||||||
|
|
||||||
// Edit Project Modal Test
|
test('test that creating a non-billable project works', async ({ page }) => {
|
||||||
|
const newProjectName = 'Non-Billable Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
// Add Project with billable rate
|
// Billable default should already be "Non-billable" by default
|
||||||
|
await expect(page.getByRole('dialog').locator('#billable')).toContainText('Non-billable');
|
||||||
|
|
||||||
// Edit Project with billable rate
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201 &&
|
||||||
|
(await response.json()).data.is_billable === false &&
|
||||||
|
(await response.json()).data.billable_rate === null
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
// Edit Project Member Billable Rate
|
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||||
|
});
|
||||||
|
|
||||||
// Edit Task Name
|
test('test that switching from custom rate to default rate clears billable rate', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const newProjectName = 'Rate Switch Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
// Create a project with an existing custom billable rate
|
||||||
|
await createProjectViaApi(ctx, {
|
||||||
|
name: newProjectName,
|
||||||
|
is_billable: true,
|
||||||
|
billable_rate: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').first().click();
|
||||||
|
|
||||||
|
// Verify it loaded as Billable with Custom Rate
|
||||||
|
await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');
|
||||||
|
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
|
||||||
|
'Custom Rate'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switch to Default Rate
|
||||||
|
await page.getByRole('dialog').locator('#billableRateType').click();
|
||||||
|
await page.getByRole('option', { name: 'Default Rate' }).click();
|
||||||
|
|
||||||
|
// Rate input should now be disabled
|
||||||
|
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
|
||||||
|
|
||||||
|
// Submit — billable_rate changes from 15000 to null, so confirmation dialog appears
|
||||||
|
await page.getByRole('button', { name: 'Update Project' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
(await response.json()).data.is_billable === true &&
|
||||||
|
(await response.json()).data.billable_rate === null
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that switching from billable to non-billable preserves rate settings', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const newProjectName = 'Billable Reset Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
// Create a project with a custom billable rate
|
||||||
|
await createProjectViaApi(ctx, {
|
||||||
|
name: newProjectName,
|
||||||
|
is_billable: true,
|
||||||
|
billable_rate: 20000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').first().click();
|
||||||
|
|
||||||
|
// Verify it loaded correctly as Billable with Custom Rate
|
||||||
|
await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');
|
||||||
|
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
|
||||||
|
'Custom Rate'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switch to Non-billable
|
||||||
|
await page.getByRole('dialog').locator('#billable').click();
|
||||||
|
await page.getByRole('option', { name: 'Non-billable' }).click();
|
||||||
|
|
||||||
|
// Rate type should still be Custom Rate (not reset)
|
||||||
|
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
|
||||||
|
'Custom Rate'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Submit and verify project is non-billable but keeps its custom rate
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Update Project' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
(await response.json()).data.is_billable === false &&
|
||||||
|
(await response.json()).data.billable_rate === 20000
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that editing an existing billable project with default rate loads correctly', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const newProjectName = 'Default Rate Edit Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
// Create a project that is billable but has no custom rate (= default rate)
|
||||||
|
await createProjectViaApi(ctx, {
|
||||||
|
name: newProjectName,
|
||||||
|
is_billable: true,
|
||||||
|
billable_rate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').first().click();
|
||||||
|
|
||||||
|
// Verify it loaded as Billable with Default Rate
|
||||||
|
await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable');
|
||||||
|
await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText(
|
||||||
|
'Default Rate'
|
||||||
|
);
|
||||||
|
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sorting tests
|
||||||
|
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();
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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 order;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Sort by Name ---
|
||||||
|
// Default is name asc (A-Z)
|
||||||
|
let order = await getOrder();
|
||||||
|
expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']);
|
||||||
|
|
||||||
|
// Click to toggle to Z-A
|
||||||
|
await clickSortHeader('Name', 'CCC Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order).toEqual(['CCC Project', 'BBB Project', 'AAA Project']);
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
|
||||||
|
// Reverse: lowest first
|
||||||
|
await clickSortHeader('Total Time', 'AAA Project');
|
||||||
|
order = await getOrder();
|
||||||
|
expect(order[2]).toBe('CCC Project'); // C=3h last
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
test('test that filtering projects by status works', async ({ page, ctx }) => {
|
||||||
|
const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createProjectViaApi(ctx, { name: newProjectName });
|
||||||
|
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearProjectTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Archive the project
|
||||||
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Archive').first().click();
|
||||||
|
|
||||||
|
// Project should still be visible (default is "all" - no filter)
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Apply Active filter - archived project should disappear
|
||||||
|
await selectStatusFilter(page, 'Active');
|
||||||
|
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
||||||
|
|
||||||
|
// Remove Active filter - project should reappear (back to "all")
|
||||||
|
await removeStatusFilter(page);
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Apply Archived filter - project should still be visible
|
||||||
|
await selectStatusFilter(page, 'Archived');
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
|
|
||||||
|
// Remove Archived filter and apply Active filter - project should not be visible
|
||||||
|
await removeStatusFilter(page);
|
||||||
|
await selectStatusFilter(page, 'Active');
|
||||||
|
await expect(page.getByText(newProjectName)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that filter state persists after page reload', async ({ page }) => {
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearProjectTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Apply Active status filter
|
||||||
|
await selectStatusFilter(page, 'Active');
|
||||||
|
|
||||||
|
// Verify the filter badge is visible
|
||||||
|
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
|
||||||
|
|
||||||
|
// Reload the page
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify the filter badge is still visible after reload
|
||||||
|
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that sort state persists after page reload', async ({ page }) => {
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await clearProjectTableState(page);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Click on Name header twice to sort descending
|
||||||
|
const nameHeader = page.getByText('Name').first();
|
||||||
|
await nameHeader.click();
|
||||||
|
await expect(nameHeader.locator('svg')).toBeVisible();
|
||||||
|
await nameHeader.click();
|
||||||
|
|
||||||
|
// Reload the page
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify descending sort indicator is visible on Name column
|
||||||
|
await expect(page.getByTestId('project_table')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that custom billable rate is displayed correctly on project detail page', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const newProjectName = 'Billable Rate Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const newBillableRate = Math.round(10 + Math.random() * 1000);
|
||||||
|
await createProjectViaApi(ctx, { name: newProjectName });
|
||||||
|
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Edit the project to set a custom billable rate
|
||||||
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').first().click();
|
||||||
|
|
||||||
|
// Set billable default to Billable
|
||||||
|
await page.getByRole('dialog').locator('#billable').click();
|
||||||
|
await page.getByRole('option', { name: 'Billable', exact: true }).click();
|
||||||
|
|
||||||
|
// Set billable rate to Custom Rate
|
||||||
|
await page.getByRole('dialog').locator('#billableRateType').click();
|
||||||
|
await page.getByRole('option', { name: 'Custom Rate' }).click();
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
|
||||||
|
await page.getByRole('button', { name: 'Update Project' }).click();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Navigate to the project detail page by clicking the project name
|
||||||
|
await page.getByText(newProjectName).first().click();
|
||||||
|
await page.waitForURL(/\/projects\/[a-f0-9-]+/);
|
||||||
|
|
||||||
|
// Verify the badge displays the correctly formatted billable rate
|
||||||
|
const expectedFormattedRate = formatCentsWithOrganizationDefaults(newBillableRate * 100);
|
||||||
|
await expect(page.locator('nav[aria-label="Breadcrumb"]').locator('..')).toContainText(
|
||||||
|
expectedFormattedRate
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tests for estimated time input (Issue #460)
|
||||||
|
test('test that creating a project with estimated time in human-readable format works', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const newProjectName = 'Estimated Time Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
|
// Fill in estimated time using human-readable format
|
||||||
|
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
||||||
|
await estimatedTimeInput.fill('2h 30m');
|
||||||
|
await estimatedTimeInput.press('Tab');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201 &&
|
||||||
|
// 2h 30m = 9000 seconds
|
||||||
|
(await response.json()).data.estimated_time === 9000
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that creating a project with estimated time using decimal notation works', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const newProjectName = 'Decimal Estimated Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
|
// Fill in estimated time using decimal notation (1.5 hours = 1h 30m)
|
||||||
|
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
||||||
|
await estimatedTimeInput.fill('1.5');
|
||||||
|
await estimatedTimeInput.press('Tab');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201 &&
|
||||||
|
// 1.5 hours = 5400 seconds
|
||||||
|
(await response.json()).data.estimated_time === 5400
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that creating a project with estimated time using comma decimal notation works', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const newProjectName = 'Comma Decimal Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
await page.getByLabel('Project Name').fill(newProjectName);
|
||||||
|
|
||||||
|
// Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m)
|
||||||
|
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
||||||
|
await estimatedTimeInput.fill('2,5');
|
||||||
|
await estimatedTimeInput.press('Tab');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 201 &&
|
||||||
|
// 2.5 hours = 9000 seconds
|
||||||
|
(await response.json()).data.estimated_time === 9000
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that updating estimated time on existing project works', async ({ page, ctx }) => {
|
||||||
|
const newProjectName = 'Update Estimated Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
await createProjectViaApi(ctx, { name: newProjectName });
|
||||||
|
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Edit the project to add estimated time
|
||||||
|
await page.getByRole('row').first().getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem').getByText('Edit').first().click();
|
||||||
|
|
||||||
|
// Fill in estimated time
|
||||||
|
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
||||||
|
await estimatedTimeInput.fill('4h 15m');
|
||||||
|
await estimatedTimeInput.press('Tab');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Update Project' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/projects/') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
// 4h 15m = 15300 seconds
|
||||||
|
(await response.json()).data.estimated_time === 15300
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that estimated time input displays formatted value after blur', async ({ page }) => {
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||||
|
|
||||||
|
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
|
||||||
|
|
||||||
|
// Enter time in various formats and check the displayed value
|
||||||
|
await estimatedTimeInput.fill('90');
|
||||||
|
await estimatedTimeInput.press('Tab');
|
||||||
|
// 90 hours should be displayed as "90h 00min" (default format)
|
||||||
|
await expect(estimatedTimeInput).toHaveValue(/90h/);
|
||||||
|
|
||||||
|
await estimatedTimeInput.fill('1:30');
|
||||||
|
await estimatedTimeInput.press('Tab');
|
||||||
|
// 1:30 should be displayed as "1h 30min"
|
||||||
|
await expect(estimatedTimeInput).toHaveValue(/1h.*30/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
const updatedTaskName = 'Updated Task ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
await createTaskViaApi(ctx, { name: originalTaskName, project_id: project.id });
|
||||||
|
|
||||||
|
// Navigate to the project detail page
|
||||||
|
await goToProjectsOverview(page);
|
||||||
|
await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
await page.getByText(projectName).first().click();
|
||||||
|
await page.waitForURL(/\/projects\/[a-f0-9-]+/);
|
||||||
|
|
||||||
|
// Verify task is visible
|
||||||
|
await expect(page.getByTestId('task_table')).toContainText(originalTaskName);
|
||||||
|
|
||||||
|
// Open edit modal via actions menu
|
||||||
|
const moreButton = page.locator("[aria-label='Actions for Task " + originalTaskName + "']");
|
||||||
|
await moreButton.click();
|
||||||
|
await page.getByTestId('task_edit').click();
|
||||||
|
|
||||||
|
// Update the task name
|
||||||
|
await page.locator('#taskName').fill(updatedTaskName);
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('button', { name: 'Update Task' }).click(),
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/tasks') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify updated name is shown and old name is gone
|
||||||
|
await expect(page.getByTestId('task_table')).toContainText(updatedTaskName);
|
||||||
|
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
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
test.describe('Employee Projects Restrictions', () => {
|
||||||
|
test('employee can view public projects but cannot create', async ({ ctx, employee }) => {
|
||||||
|
const projectName = 'EmpViewProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
await createPublicProjectViaApi(ctx, { name: projectName });
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||||
|
await expect(employee.page.getByTestId('projects_view')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Employee can see the public project
|
||||||
|
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Employee cannot see Create Project button
|
||||||
|
await expect(
|
||||||
|
employee.page.getByRole('button', { name: 'Create Project' })
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('employee cannot see edit/delete/archive actions on projects', async ({
|
||||||
|
ctx,
|
||||||
|
employee,
|
||||||
|
}) => {
|
||||||
|
const projectName = 'EmpActionsProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
await createPublicProjectViaApi(ctx, { name: projectName });
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||||
|
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click the actions dropdown trigger to open the menu
|
||||||
|
const actionsButton = employee.page.locator(
|
||||||
|
`[aria-label='Actions for Project ${projectName}']`
|
||||||
|
);
|
||||||
|
await actionsButton.click();
|
||||||
|
|
||||||
|
// The dropdown menu items (Edit, Archive, Delete) should NOT be visible
|
||||||
|
await expect(
|
||||||
|
employee.page.locator(`[aria-label='Edit Project ${projectName}']`)
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
employee.page.locator(`[aria-label='Archive Project ${projectName}']`)
|
||||||
|
).not.toBeVisible();
|
||||||
|
await expect(
|
||||||
|
employee.page.locator(`[aria-label='Delete Project ${projectName}']`)
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Employee Billable Rate Visibility', () => {
|
||||||
|
test('employee cannot see billable rate column by default', async ({ ctx, employee }) => {
|
||||||
|
const projectName = 'EmpBillableProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
await createPublicProjectViaApi(ctx, {
|
||||||
|
name: projectName,
|
||||||
|
is_billable: true,
|
||||||
|
billable_rate: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||||
|
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Billable Rate column should not be visible to employee by default
|
||||||
|
await expect(employee.page.getByText('Billable Rate')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('employee can see billable rate column when employees_can_see_billable_rates is enabled', async ({
|
||||||
|
ctx,
|
||||||
|
employee,
|
||||||
|
}) => {
|
||||||
|
await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true });
|
||||||
|
|
||||||
|
const projectName = 'EmpBillableVisProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
await createPublicProjectViaApi(ctx, {
|
||||||
|
name: projectName,
|
||||||
|
is_billable: true,
|
||||||
|
billable_rate: 20000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||||
|
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Billable Rate column header should be visible
|
||||||
|
await expect(employee.page.getByText('Billable Rate')).toBeVisible();
|
||||||
|
|
||||||
|
// The project row should show the formatted billable rate
|
||||||
|
const projectRow = employee.page.getByRole('row').filter({ hasText: projectName });
|
||||||
|
await expect(projectRow).toContainText('200');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
719
e2e/reporting-detailed.spec.ts
Normal file
719
e2e/reporting-detailed.spec.ts
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { test } from '../playwright/fixtures';
|
||||||
|
import { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting';
|
||||||
|
import {
|
||||||
|
createProjectViaApi,
|
||||||
|
createClientViaApi,
|
||||||
|
createTaskViaApi,
|
||||||
|
createTimeEntryViaApi,
|
||||||
|
createTimeEntryWithTagViaApi,
|
||||||
|
createBareTimeEntryViaApi,
|
||||||
|
} from './utils/api';
|
||||||
|
|
||||||
|
// Each test registers a new user and creates test data via API
|
||||||
|
test.describe.configure({ timeout: 30000 });
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Basic Detailed View Tests
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('test that detailed view shows time entries correctly', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Go to detailed reporting view
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
// Verify the time entry is shown with all details
|
||||||
|
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1:00:00');
|
||||||
|
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that updating duration in detailed view works correctly', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'Duration Update Project ' + Math.floor(Math.random() * 10000);
|
||||||
|
const initialDuration = '1h';
|
||||||
|
const updatedDuration = '2h 30min';
|
||||||
|
|
||||||
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName}`,
|
||||||
|
duration: initialDuration,
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Go to detailed reporting view
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
// Find and update the duration
|
||||||
|
const durationInput = page.locator('input[name="Duration"]').first();
|
||||||
|
await durationInput.click();
|
||||||
|
await durationInput.fill(updatedDuration);
|
||||||
|
await Promise.all([
|
||||||
|
durationInput.press('Enter'),
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/time-entries') && response.status() === 200
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify the new duration is displayed (reporting views promote to HH:MM:SS format)
|
||||||
|
await expect(durationInput).toHaveValue('2:30:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Project Filter Tests
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('test that project multiselect filters work on detailed reporting page', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const project1 = 'DetailProj1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
const project2 = 'DetailProj2 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const p1 = await createProjectViaApi(ctx, { name: project1 });
|
||||||
|
const p2 = await createProjectViaApi(ctx, { name: project2 });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${project1}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: p1.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${project2}`,
|
||||||
|
duration: '2h',
|
||||||
|
projectId: p2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
// Wait for initial data load
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${project2}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Open project multiselect and select project1
|
||||||
|
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||||
|
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Verify only project1 entry is shown
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${project2}`).first()).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Client Filter Tests
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('test that client multiselect filters work on detailed reporting page', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const client1 = 'DetailClient1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
const project1 = 'DetailClientProj1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
const project2 = 'DetailClientProj2 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const c1 = await createClientViaApi(ctx, { name: client1 });
|
||||||
|
const p1 = await createProjectViaApi(ctx, { name: project1, client_id: c1.id });
|
||||||
|
const p2 = await createProjectViaApi(ctx, { name: project2 });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${project1}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: p1.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${project2}`,
|
||||||
|
duration: '2h',
|
||||||
|
projectId: p2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${project2}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Filter by client1
|
||||||
|
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: client1 }).click();
|
||||||
|
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Only entries for project1 (with client1) should be visible
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${project2}`).first()).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Task Filter Tests
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('test that task multiselect dropdown filters reporting by task', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'TaskFilterProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
const task1 = 'TaskFilter1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
const task2 = 'TaskFilter2 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id });
|
||||||
|
const t2 = await createTaskViaApi(ctx, { name: task2, project_id: project.id });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName} - ${task1}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
taskId: t1.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName} - ${task2}`,
|
||||||
|
duration: '2h',
|
||||||
|
projectId: project.id,
|
||||||
|
taskId: t2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the detailed view to verify task filtering (shows individual entries)
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Open task multiselect dropdown
|
||||||
|
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||||
|
|
||||||
|
// Verify both tasks appear
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: task1 })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: task2 })).toBeVisible();
|
||||||
|
|
||||||
|
// Select task1
|
||||||
|
await page.getByRole('option').filter({ hasText: task1 }).click();
|
||||||
|
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Verify badge shows count of 1
|
||||||
|
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();
|
||||||
|
|
||||||
|
// Verify only task1 entry is shown
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that selecting multiple tasks shows correct badge count', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'MultiTaskProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
const task1 = 'MultiTask1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
const task2 = 'MultiTask2 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id });
|
||||||
|
const t2 = await createTaskViaApi(ctx, { name: task2, project_id: project.id });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName} - ${task1}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
taskId: t1.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName} - ${task2}`,
|
||||||
|
duration: '2h',
|
||||||
|
projectId: project.id,
|
||||||
|
taskId: t2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the detailed view to verify task filtering
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Select both tasks
|
||||||
|
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: task1 }).click();
|
||||||
|
await page.getByRole('option').filter({ hasText: task2 }).click();
|
||||||
|
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Verify badge shows count of 2
|
||||||
|
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('2')).toBeVisible();
|
||||||
|
|
||||||
|
// Verify both task entries are shown
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task2}`).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that deselecting a task removes the filter', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'TaskDeselectProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
const task1 = 'TaskDeselect1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName} - ${task1}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
taskId: t1.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Select task
|
||||||
|
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: task1 }).click();
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();
|
||||||
|
|
||||||
|
// Deselect task
|
||||||
|
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: task1 }).click();
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Tasks' }).first().getByText(/^\d+$/)
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Member Filter Tests
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('test that member multiselect filters work on detailed reporting page', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const projectName = 'DetailMemberProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Filter by the current member
|
||||||
|
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
|
||||||
|
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Data should still be visible since all entries belong to this member
|
||||||
|
await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Verify badge shows count of 1
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Members' }).first().getByText('1')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Tag Filter Tests
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('test that tag filter works on detailed reporting page', async ({ page, ctx }) => {
|
||||||
|
const tag1 = 'DetailTag1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
const tag2 = 'DetailTag2 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
await createTimeEntryWithTagViaApi(ctx, tag1, '1h');
|
||||||
|
await createTimeEntryWithTagViaApi(ctx, tag2, '2h');
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry with tag ${tag2}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Filter by tag1
|
||||||
|
await page.getByRole('button', { name: 'Tags' }).click();
|
||||||
|
await page.getByRole('option').filter({ hasText: tag1 }).click();
|
||||||
|
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry with tag ${tag2}`).first()).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Billable Filter Tests
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('test that billable filter works on detailed reporting page', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'DetailBillProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Filter by billable only
|
||||||
|
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('option', { name: 'Billable', exact: true }).click(),
|
||||||
|
waitForDetailedReportingUpdate(page),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Switch to Non Billable
|
||||||
|
await page.getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('option', { name: 'Non Billable', exact: true }).click(),
|
||||||
|
waitForDetailedReportingUpdate(page),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Switch back to Both
|
||||||
|
await page.getByRole('combobox').filter({ hasText: 'Non Billable' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.getByRole('option', { name: 'Both' }).click(),
|
||||||
|
waitForDetailedReportingUpdate(page),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Combined Filter Tests
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('test that combining project and task filters narrows results', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'CombinedProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
const otherProject = 'OtherCombProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
const task1 = 'CombinedTask1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const p1 = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
const p2 = await createProjectViaApi(ctx, { name: otherProject });
|
||||||
|
const t1 = await createTaskViaApi(ctx, { name: task1, project_id: p1.id });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName} - ${task1}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: p1.id,
|
||||||
|
taskId: t1.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${otherProject}`,
|
||||||
|
duration: '2h',
|
||||||
|
projectId: p2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${otherProject}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Filter by project
|
||||||
|
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: projectName }).click();
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Additionally filter by task
|
||||||
|
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: task1 }).click();
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Verify both badges show count of 1
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();
|
||||||
|
|
||||||
|
// Verify only the combined entry is shown
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${otherProject}`).first()).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that combining client and member filters narrows results on detailed page', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const client1 = 'CombClient ' + Math.floor(Math.random() * 10000);
|
||||||
|
const project1 = 'CombClientProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
const project2 = 'CombNoClientProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const c1 = await createClientViaApi(ctx, { name: client1 });
|
||||||
|
const p1 = await createProjectViaApi(ctx, { name: project1, client_id: c1.id });
|
||||||
|
const p2 = await createProjectViaApi(ctx, { name: project2 });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${project1}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: p1.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${project2}`,
|
||||||
|
duration: '2h',
|
||||||
|
projectId: p2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${project2}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Filter by client
|
||||||
|
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: client1 }).click();
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Additionally filter by member
|
||||||
|
await page.getByRole('button', { name: 'Members' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Only project1 entry should be visible (filtered by client + member)
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${project2}`).first()).not.toBeVisible();
|
||||||
|
|
||||||
|
// Both badges should show count of 1
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Clients' }).first().getByText('1')
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Members' }).first().getByText('1')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that combining tag and project filters narrows results', async ({ page, ctx }) => {
|
||||||
|
const tag1 = 'CombTag ' + Math.floor(Math.random() * 10000);
|
||||||
|
const project1 = 'CombTagProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const p1 = await createProjectViaApi(ctx, { name: project1 });
|
||||||
|
|
||||||
|
// Create a time entry with a project (no tag)
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${project1}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: p1.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a time entry with a tag (no specific project)
|
||||||
|
await createTimeEntryWithTagViaApi(ctx, tag1, '2h');
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Filter by project
|
||||||
|
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Only the project entry should be visible (tagged entry has no project)
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry with tag ${tag1}`).first()).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// "No X" Filter Tests
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('test that "No Project" filter shows entries without a project', async ({ page, ctx }) => {
|
||||||
|
const project1 = 'NoProj1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const p1 = await createProjectViaApi(ctx, { name: project1 });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${project1}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: p1.id,
|
||||||
|
});
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Bare entry no project', '30min');
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText('Bare entry no project').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Open project dropdown and select "No Project"
|
||||||
|
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: 'No Project' }).click();
|
||||||
|
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Verify badge shows 1
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Only the bare entry (no project) should be visible
|
||||||
|
await expect(page.getByText('Bare entry no project').first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that "No Task" filter shows entries without a task', async ({ page, ctx }) => {
|
||||||
|
const projectName = 'NoTaskProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
const task1 = 'NoTaskFilter1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||||
|
const t1 = await createTaskViaApi(ctx, { name: task1, project_id: project.id });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName} - ${task1}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: project.id,
|
||||||
|
taskId: t1.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectName}`,
|
||||||
|
duration: '30min',
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${projectName}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Open task dropdown and select "No Task"
|
||||||
|
await page.getByRole('button', { name: 'Tasks' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: 'No Task' }).click();
|
||||||
|
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: 'Tasks' }).first().getByText('1')).toBeVisible();
|
||||||
|
|
||||||
|
// Only the entry without a task should be visible
|
||||||
|
await expect(page.getByText(`Entry for ${projectName} - ${task1}`).first()).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that "No Tag" filter shows entries without tags', async ({ page, ctx }) => {
|
||||||
|
const tag1 = 'NoTagFilter1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
await createTimeEntryWithTagViaApi(ctx, tag1, '1h');
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Entry without any tag', '30min');
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry with tag ${tag1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText('Entry without any tag').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Open tag dropdown and select "No Tag"
|
||||||
|
await page.getByRole('button', { name: 'Tags' }).click();
|
||||||
|
await page.getByRole('option').filter({ hasText: 'No Tag' }).click();
|
||||||
|
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: 'Tags' }).getByText('1')).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByText('Entry without any tag').first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry with tag ${tag1}`).first()).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that "No Client" filter shows entries without a client', async ({ page, ctx }) => {
|
||||||
|
const client1 = 'NoClientFilter ' + Math.floor(Math.random() * 10000);
|
||||||
|
const projectWithClient = 'NoClientProj1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
const projectNoClient = 'NoClientProj2 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const c1 = await createClientViaApi(ctx, { name: client1 });
|
||||||
|
const pWithClient = await createProjectViaApi(ctx, {
|
||||||
|
name: projectWithClient,
|
||||||
|
client_id: c1.id,
|
||||||
|
});
|
||||||
|
const pNoClient = await createProjectViaApi(ctx, { name: projectNoClient });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectWithClient}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: pWithClient.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${projectNoClient}`,
|
||||||
|
duration: '30min',
|
||||||
|
projectId: pNoClient.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry for ${projectWithClient}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${projectNoClient}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Open client dropdown and select "No Client"
|
||||||
|
await page.getByRole('button', { name: 'Clients' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: 'No Client' }).click();
|
||||||
|
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Clients' }).first().getByText('1')
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry for ${projectNoClient}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText(`Entry for ${projectWithClient}`).first()).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that combining "No Project" with a project ID shows both', async ({ page, ctx }) => {
|
||||||
|
const project1 = 'CombNoProj ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const p1 = await createProjectViaApi(ctx, { name: project1 });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${project1}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: p1.id,
|
||||||
|
});
|
||||||
|
await createBareTimeEntryViaApi(ctx, 'Bare combined entry', '30min');
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText('Bare combined entry').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Select both "No Project" and the specific project
|
||||||
|
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||||
|
await page.getByRole('option').filter({ hasText: 'No Project' }).click();
|
||||||
|
await page.getByRole('option').filter({ hasText: project1 }).click();
|
||||||
|
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Badge should show 2
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Projects' }).first().getByText('2')
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Both entries should be visible
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||||
|
await expect(page.getByText('Bare combined entry').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// Keyboard Navigation Tests
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('test that keyboard navigation works in multiselect dropdown', async ({ page, ctx }) => {
|
||||||
|
const project1 = 'KbNavProj1 ' + Math.floor(Math.random() * 10000);
|
||||||
|
const project2 = 'KbNavProj2 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|
||||||
|
const p1 = await createProjectViaApi(ctx, { name: project1 });
|
||||||
|
const p2 = await createProjectViaApi(ctx, { name: project2 });
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${project1}`,
|
||||||
|
duration: '1h',
|
||||||
|
projectId: p1.id,
|
||||||
|
});
|
||||||
|
await createTimeEntryViaApi(ctx, {
|
||||||
|
description: `Entry for ${project2}`,
|
||||||
|
duration: '2h',
|
||||||
|
projectId: p2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToReportingDetailed(page);
|
||||||
|
await expect(page.getByText(`Entry for ${project1}`).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Open project dropdown
|
||||||
|
await page.getByRole('button', { name: 'Projects' }).first().click();
|
||||||
|
|
||||||
|
// The search input should be focused, first item ("No Project") highlighted
|
||||||
|
await expect(page.getByPlaceholder('Search for a Project...')).toBeFocused();
|
||||||
|
|
||||||
|
// Press ArrowDown to move to first project, then Enter to select it
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
// Close dropdown and verify filter applied
|
||||||
|
await Promise.all([page.keyboard.press('Escape'), waitForDetailedReportingUpdate(page)]);
|
||||||
|
|
||||||
|
// Badge should show 1
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user