Compare commits

...

19 Commits

Author SHA1 Message Date
Gregor Vostrak
68e369811c add e2e tests for shared reports 2025-08-14 16:24:46 +02:00
Constantin Graf
da98e0571c Add on premise build 2025-08-12 16:59:52 +02:00
Constantin Graf
f68f05d1aa Updated the PR template 2025-07-31 14:01:17 +02:00
Gregor Vostrak
8fdc4c1219 add contributing notice that you need to run the format command 2025-07-31 14:01:17 +02:00
Gregor Vostrak
93148299a9 add CONTRIBUTING.md 2025-07-31 14:01:17 +02:00
Constantin Graf
78d2ea1a25 Add API doc description for chart endpoints 2025-07-31 13:43:00 +02:00
Constantin Graf
14f559c4c2 Removed FORWARD_WEB_PORT from local setup 2025-07-31 13:42:37 +02:00
Gregor Vostrak
61fd2b1187 update font-face file names for font loading 2025-07-31 12:08:51 +02:00
Gregor Vostrak
9ea3c5dc29 fix font embeds #864 2025-07-31 11:53:32 +02:00
Gregor Vostrak
cb30487a21 add format check, update prettier rules, apply rules consistently 2025-07-31 11:53:00 +02:00
Constantin Graf
b11672732b Fixed modules service providers 2025-07-23 16:11:34 +02:00
Gregor Vostrak
97dcadc795 add frontend blocking for rounding for non-premium users 2025-07-23 16:09:36 +02:00
Constantin Graf
e7fa414c06 Restrict rounding to premium users 2025-07-23 16:09:36 +02:00
Gregor Vostrak
43073b5be2 fix design inconsistency in timeentryaggregaterow 2025-07-18 16:38:09 +02:00
Gregor Vostrak
9589c9106d e2e: make sure reporting tests do not check the dropdown values when verifying table results 2025-07-17 18:41:48 +02:00
Gregor Vostrak
8a0d2235a8 fix flakyness in e2e tests for reporting 2025-07-17 18:38:21 +02:00
Gregor Vostrak
38f38790d5 change font to inter, scale down fonts, improve rounding/filter elements 2025-07-17 18:38:21 +02:00
Gregor Vostrak
e3cfc155b8 add rounding frontend to reports, and support for shared reports 2025-07-17 18:38:21 +02:00
Constantin Graf
4b726635b2 Add rounding feature 2025-07-17 18:38:21 +02:00
367 changed files with 6699 additions and 5641 deletions

View File

@@ -80,8 +80,7 @@ GOTENBERG_URL=http://gotenberg:3000
# Local setup
NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083
FORWARD_DB_PORT=54329
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
#SAIL_XDEBUG_MODE=develop,debug,coverage

View File

@@ -1,8 +1,11 @@
<!--
This project is early stage. The structure and APIs are still subject to change and not stable.
Therefore, we do not currently accept any contributions, unless you are a member of the team.
## What does this PR do?
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
-->
- Fixes #XXXX (GitHub issue number)
## Checklist (DO NOT REMOVE)
- [ ] I read the [contributing guide](https://github.com/solidtime-io/solidtime/blob/main/CONTRIBUTING.md)
- [ ] I signed the [Contributor License Agreement](https://cla-assistant.io/solidtime-io/solidtime).
- [ ] I commented my code, particularly in hard-to-understand areas

216
.github/workflows/build-onpremise.yml vendored Normal file
View File

@@ -0,0 +1,216 @@
on:
push:
branches:
- main
- develop
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-onpremise.yml'
- 'docker/prod/**'
workflow_dispatch:
permissions:
packages: write
contents: read
attestations: write
id-token: write
env:
DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime
name: Build - On Premise
jobs:
build:
strategy:
matrix:
include:
- runs-on: "ubuntu-24.04-arm"
platform: "linux/arm64"
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }}
timeout-minutes: 90
steps:
- name: "Check out code"
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: release-build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
prefix: "v"
- name: "Get version"
id: release-version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
else
echo "ERROR: No previous tag found";
exit 1;
fi
else
version=$(echo "${{ github.ref }}" | cut -c 12-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
fi
- name: "Copy .env template for production"
run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql
- name: "Install dependencies"
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install npm dependencies"
run: npm ci
- name: "Build"
run: npm run build
- name: "Prepare"
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Build and push by digest"
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: "Export digest"
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest"
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
timeout-minutes: 90
needs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Docker meta"
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_REPO }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Create manifest list and push"
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: "Inspect image"
run: |
docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}

23
.github/workflows/npm-format-check.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: NPM Format Check
on: [push]
jobs:
format-check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Check code formatting"
run: npm run format:check

27
.prettierignore Normal file
View File

@@ -0,0 +1,27 @@
# Ignore build outputs
node_modules/
vendor/
storage/
bootstrap/cache/
public/build/
public/hot/
# Ignore lock files
package-lock.json
composer.lock
# Ignore generated files
*.min.js
*.min.css
# Ignore test results
test-results/
playwright-report/
# Ignore IDE files
.idea/
.vscode/
# Ignore OS files
.DS_Store
Thumbs.db

View File

@@ -3,5 +3,6 @@
"tabWidth": 4,
"singleQuote": true,
"bracketSameLine": true,
"quoteProps": "preserve"
"quoteProps": "preserve",
"printWidth": 100
}

81
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,81 @@
# Contributing to solidtime
Contributions are greatly apprecited, please make sure to read the rules and vision for solidtime before contributing.
## Rules
### Issues for Bugs, Discussions for Feature requests
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancement are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
### Only work on approved issues
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
### Contributor License Agreement
You'll also notice that weve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Dont worry - the process is quick and only takes a few clicks.
We want to be transparent about why we require the CLA and what it means for your contributions and the codebase. Thats why weve written a few paragraphs below outlining our plans and vision for solidtime in the **Vision** part of this document.
### Prevent Duplicate Work
Before you submit a new PR, make sure that none exists already. If you plan to work on an issue, make sure to let us and others know by commenting on the issue/discussion.
### Give context
Tell us what you thinking was behind the decisions you made while drafting the PR. Treat the PR itself as documentation for everyone who wants to go back and understand why certain decisions were made.
### Summarize your PR
Please make sure to include a short summary at the top of your PR to make it easy for us to quickly check what the PR is about, without looking at the code changes.
### Use Github Keywords and Auto-Link Issues
Use phrases like "Closes #123" or "Fixes #123" in the PR description to link the PR with the issue that you are adressing.
### Mention what you tested and how
Explain how you tested and validated the implementation.
### Keep Naming consistent
Look at existing code patterns and use naming conventions that already exist in the code base.
### Testing
We have an exhaustive test-suite of PHPUnit (Backend) and Playwright (Frontend) testing. Whereever applicable please make sure to write add tests to the codebase.
### Linting & Formatting
Make sure to run linting and formatting commands before you commit the changes.
For backend changes:
```
composer fix
composer analyse
```
For frontend changes:
```
npm run lint:fix
npm run format
```
## Vision
We started solidtime to provide an open infrastructure solution for time tracking—one that empowers teams and individuals to fully own their data, instead of depending on proprietary platforms. We believe infrastructure software should be open, accessible, and built to last. However, competing with established market leaders in this space requires long-term financial sustainability.
solidtime is licensed under the AGPL, which we believe is the best available license to strike a balance between openness and financial viability. The AGPL gives us, as the copyright holders, certain exclusive rights that we plan to leverage to fund development. To ensure we retain those rights across the entire codebase, we've put a CLA in place that contributors must sign before submitting code.
One of solidtimes key advantages is that it's built to be self-hostable. This makes it a great solution for organizations like governments, healthcare providers, and enterprises that are required to keep data on their own infrastructure due to regulations or internal policies. These organizations may need custom licenses, integrations, or modifications that aren't suitable for the open-source version. To support them, we offer relicensed versions of solidtime along with support plans.
Well also provide proprietary extensions for solidtime. These will be available to enterprise customers with support plans, but also to individual users or teams who dont need support, at much more accessible price points. For companies running solidtime on their own infrastructure, this is the easiest way to support the project while gaining additional functionality. While we plan to make it easier to build custom extensions in the future, our current APIs are still highly experimental.
Finally - and perhaps most importantly - we offer a hosted SaaS version called solidtime Cloud, for users who cant or dont want to run the software themselves. This version includes proprietary extensions, always runs the latest commit, and includes monitoring and billing features available exclusively on this hosted instance. We expect solidtime Cloud to play a critical role in funding the project long-term.
Having full control over the source codes licensing also gives us the ability to change the license of the main project in the future. That said, we have no plans to do so and would only consider it in extreme cases - for example, if a malicious actor were to directly compete with our hosted service in a way that threatens the sustainability of the project, the legal interpretation of AGPL changes in a way that would make it unreasonable to use for certain companies, or a new similar license gains wide-spread adoption. Regardless, solidtime will always remain free to self-host for individuals and companies who use it as part of their work, and all previous releases will remain licensed under AGPL.
If you are using the open-source version of solidtime and want to support us, the best way to do so is to spread the word.

View File

@@ -35,10 +35,9 @@ If you have a **feature request**, please [**create a discussion**](https://gith
## Contributing
This project is in a very early stage. The structure and APIs are still subject to change and not stable.
Therefore, we do not currently accept any contributions, unless you are a member of the team.
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.
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryRoundingType: string
{
use LaravelEnumHelper;
case Up = 'up';
case Down = 'down';
case Nearest = 'nearest';
}

View File

@@ -14,6 +14,8 @@ use Illuminate\Http\JsonResponse;
class ChartController extends Controller
{
/**
* Get chart data for the weekly project overview.
*
* @throws AuthorizationException
*
* @operationId weeklyProjectOverview
@@ -31,6 +33,8 @@ class ChartController extends Controller
}
/**
* Get chart data for the latest tasks.
*
* @throws AuthorizationException
*
* @operationId latestTasks
@@ -48,6 +52,8 @@ class ChartController extends Controller
}
/**
* Get chart data for the last seven days.
*
* @throws AuthorizationException
*
* @operationId lastSevenDays
@@ -65,6 +71,8 @@ class ChartController extends Controller
}
/**
* Get chart data for the latest team activity.
*
* @throws AuthorizationException
*
* @operationId latestTeamActivity
@@ -81,6 +89,8 @@ class ChartController extends Controller
}
/**
* Get chart data for daily tracked hours.
*
* @throws AuthorizationException
*
* @operationId dailyTrackedHours
@@ -98,6 +108,8 @@ class ChartController extends Controller
}
/**
* Get chart data for total weekly time.
*
* @throws AuthorizationException
*
* @operationId totalWeeklyTime
@@ -115,6 +127,8 @@ class ChartController extends Controller
}
/**
* Get chart data for total weekly billable time.
*
* @throws AuthorizationException
*
* @operationId totalWeeklyBillableTime
@@ -132,6 +146,8 @@ class ChartController extends Controller
}
/**
* Get chart data for total weekly billable amount.
*
* @throws AuthorizationException
*
* @operationId totalWeeklyBillableAmount
@@ -154,6 +170,8 @@ class ChartController extends Controller
}
/**
* Get chart data for weekly history.
*
* @throws AuthorizationException
*
* @operationId weeklyHistory

View File

@@ -73,7 +73,9 @@ class ReportController extends Controller
false,
$report->properties->start,
$report->properties->end,
true
true,
$report->properties->roundingType,
$report->properties->roundingMinutes,
);
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(),
@@ -84,7 +86,9 @@ class ReportController extends Controller
true,
$report->properties->start,
$report->properties->end,
true
true,
$report->properties->roundingType,
$report->properties->roundingMinutes,
);
return new DetailedWithDataReportResource($report, $data, $historyData);

View File

@@ -107,6 +107,8 @@ class ReportController extends Controller
}
}
$properties->timezone = $timezone;
$properties->roundingType = $request->getPropertyRoundingType();
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
$report->properties = $properties;
if ($isPublic) {
$report->share_secret = $reportService->generateSecret();

View File

@@ -33,6 +33,7 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\ReportExport\TimeEntriesReportExport;
use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter;
use App\Service\TimeEntryService;
use App\Service\TimezoneService;
use Gotenberg\Exceptions\GotenbergApiErrored;
use Gotenberg\Exceptions\NoOutputFileInResponse;
@@ -47,6 +48,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
@@ -84,7 +86,8 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all');
}
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$totalCount = $timeEntriesQuery->count();
@@ -138,10 +141,19 @@ class TimeEntryController extends Controller
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member, bool $canAccessPremiumFeatures): Builder
{
$select = TimeEntry::SELECT_COLUMNS;
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
if ($roundingType !== null && $roundingMinutes !== null) {
$select = array_diff($select, ['start', 'end']);
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
}
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->select($select)
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery);
@@ -175,16 +187,19 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$debug = $request->getDebug();
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
if ($format === ExportFormat::PDF && ! $canAccessPremiumFeatures) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$user = $this->user();
$timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery->with([
'task',
'client',
@@ -207,8 +222,9 @@ class TimeEntryController extends Controller
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesQuery->clone()->reorder()->withOnly([]),
$timeEntriesAggregateQuery,
null,
null,
$user->timezone,
@@ -216,7 +232,9 @@ class TimeEntryController extends Controller
false,
null,
null,
$showBillableRate
$showBillableRate,
$roundingType,
$roundingMinutes,
);
$html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(),
@@ -318,12 +336,15 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
@@ -334,7 +355,9 @@ class TimeEntryController extends Controller
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd(),
$showBillableRate
$showBillableRate,
$roundingType,
$roundingMinutes
);
return [
@@ -362,6 +385,7 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
@@ -373,6 +397,8 @@ class TimeEntryController extends Controller
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesAggregateQuery->clone(),
@@ -383,7 +409,9 @@ class TimeEntryController extends Controller
false,
$request->getStart(),
$request->getEnd(),
$showBillableRate
$showBillableRate,
$roundingType,
$roundingMinutes
);
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(),
@@ -394,7 +422,9 @@ class TimeEntryController extends Controller
true,
$request->getStart(),
$request->getEnd(),
$showBillableRate
$showBillableRate,
$roundingType,
$roundingMinutes
);
$currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
@@ -477,7 +507,7 @@ class TimeEntryController extends Controller
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization');

View File

@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
@@ -128,6 +129,18 @@ class ReportStoreRequest extends BaseFormRequest
'nullable',
'timezone:all',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'properties.rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'properties.rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -205,4 +218,22 @@ class ReportStoreRequest extends BaseFormRequest
{
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
}
public function getPropertyRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->input('properties.rounding_type'));
}
public function getPropertyRoundingMinutes(): ?int
{
if (! $this->has('properties.rounding_minutes') || $this->input('properties.rounding_minutes') === null) {
return null;
}
return (int) $this->input('properties.rounding_minutes');
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -164,6 +165,18 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -211,4 +224,22 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
{
return ExportFormat::from($this->validated('format'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -146,6 +147,18 @@ class TimeEntryAggregateRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -173,4 +186,22 @@ class TimeEntryAggregateRequest extends BaseFormRequest
{
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryRoundingType;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -133,6 +134,18 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
'string',
'in:true,false',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -170,4 +183,22 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{
return ExportFormat::from($this->validated('format'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -11,8 +12,10 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\Rule as RuleContract;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -23,7 +26,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
* @return array<string, array<string|ValidationRule|RuleContract>>
*/
public function rules(): array
{
@@ -136,6 +139,18 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -153,4 +168,22 @@ class TimeEntryIndexRequest extends BaseFormRequest
{
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -58,6 +58,10 @@ class DetailedReportResource extends BaseResource
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */
'rounding_type' => $this->resource->properties->roundingType?->value,
/** @var int|null $rounding_minutes Rounding minutes for time entries */
'rounding_minutes' => $this->resource->properties->roundingMinutes,
],
/** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at),

View File

@@ -77,6 +77,26 @@ class TimeEntry extends Model implements AuditableContract
'still_active_email_sent_at' => 'datetime',
];
public const array SELECT_COLUMNS = [
'id',
'description',
'start',
'end',
'billable_rate',
'billable',
'user_id',
'organization_id',
'project_id',
'task_id',
'tags',
'created_at',
'updated_at',
'member_id',
'client_id',
'is_imported',
'still_active_email_sent_at',
];
/**
* The attributes that are computed. (f.e. for performance reasons)
* These attributes can be regenerated at any time.

View File

@@ -6,6 +6,7 @@ namespace App\Service\Dto;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
@@ -59,6 +60,10 @@ class ReportPropertiesDto implements Castable
*/
public ?Collection $taskIds = null;
public ?TimeEntryRoundingType $roundingType = null;
public ?int $roundingMinutes = null;
/**
* Get the caster class to use when casting from / to this cast target.
*
@@ -115,6 +120,10 @@ class ReportPropertiesDto implements Castable
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
$dto->weekStart = Weekday::from($data->weekStart);
$dto->timezone = $data->timezone;
// Note: roundingType was added later so it is possible that the value is missing in persisted reports in the DB
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
return $dto;
}
@@ -140,6 +149,8 @@ class ReportPropertiesDto implements Castable
'historyGroup' => $value->historyGroup->value,
'weekStart' => $value->weekStart->value,
'timezone' => $value->timezone,
'roundingType' => $value->roundingType?->value,
'roundingMinutes' => $value->roundingMinutes,
];
$jsonString = json_encode($data);

View File

@@ -6,6 +6,7 @@ namespace App\Service;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Project;
@@ -41,7 +42,7 @@ class TimeEntryAggregationService
* cost: int|null
* }
*/
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
{
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
$group1Select = null;
@@ -56,15 +57,14 @@ class TimeEntryAggregationService
}
}
$startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes);
$endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes);
$timeEntriesQuery->selectRaw(
($group1Select !== null ? $group1Select.' as group_1,' : '').
($group2Select !== null ? $group2Select.' as group_2,' : '').
' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'.
' round(
sum(
extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60)
)
) as cost'
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
);
if ($groupBy !== null) {
$timeEntriesQuery->groupBy($groupBy);
@@ -164,9 +164,9 @@ class TimeEntryAggregationService
* cost: int|null
* }
*/
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
{
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes);
$keysGroup1 = [];
$keysGroup2 = [];

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enums\TimeEntryRoundingType;
use Illuminate\Support\Carbon;
use LogicException;
class TimeEntryService
{
public function getStartSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
{
if ($roundingType === null || $roundingMinutes === null) {
return 'start';
}
if ($roundingMinutes < 1) {
throw new LogicException('Rounding minutes must be greater than 0');
}
return 'date_bin(\'1 minutes\', start, TIMESTAMP \'1970-01-01\')';
}
public function getEndSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
{
if ($roundingType === null || $roundingMinutes === null) {
return 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
}
if ($roundingMinutes < 1) {
throw new LogicException('Rounding minutes must be greater than 0');
}
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Up) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
}
}
}

View File

@@ -118,7 +118,8 @@
"extra": {
"laravel": {
"dont-discover": [
"laravel/telescope"
"laravel/telescope",
"nwidart/laravel-modules"
]
}
},

View File

@@ -9,6 +9,7 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider;
use Nwidart\Modules\LaravelModulesServiceProvider;
return [
@@ -197,6 +198,7 @@ return [
App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
LaravelModulesServiceProvider::class,
])->toArray(),
/*

View File

@@ -153,6 +153,16 @@ class TimeEntryFactory extends Factory
});
}
public function endWithDuration(Carbon $end, int $durationInSeconds): self
{
return $this->state(function (array $attributes) use ($end, $durationInSeconds): array {
return [
'start' => $end->copy()->utc()->subSeconds($durationInSeconds),
'end' => $end->copy()->utc(),
];
});
}
public function start(Carbon $start): self
{
return $this->state(function (array $attributes) use ($start): array {

View File

@@ -5,8 +5,6 @@ services:
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP}'
ports:
- '${FORWARD_WEB_PORT:-8083}:80'
image: sail-8.3/app
labels:
- "traefik.enable=true"

View File

@@ -7,11 +7,8 @@ async function goToProjectsOverview(page: Page) {
}
// Create new project via modal
test('test that creating and deleting a new client via the modal works', async ({
page,
}) => {
const newClientName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Client' }).click();
await page.getByPlaceholder('Client Name').fill(newClientName);
@@ -28,13 +25,9 @@ test('test that creating and deleting a new client via the modal works', async (
]);
await expect(page.getByTestId('client_table')).toContainText(newClientName);
const moreButton = page.locator(
"[aria-label='Actions for Client " + newClientName + "']"
);
const moreButton = page.locator("[aria-label='Actions for Client " + newClientName + "']");
moreButton.click();
const deleteButton = page.locator(
"[aria-label='Delete Client " + newClientName + "']"
);
const deleteButton = page.locator("[aria-label='Delete Client " + newClientName + "']");
await Promise.all([
deleteButton.click(),
@@ -45,9 +38,7 @@ test('test that creating and deleting a new client via the modal works', async (
response.status() === 204
),
]);
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 }) => {

View File

@@ -22,12 +22,8 @@ test('test that new manager can be invited', async ({ page }) => {
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Manager' }).click();
await Promise.all([
page
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
]);
});
@@ -38,12 +34,8 @@ test('test that new employee can be invited', async ({ page }) => {
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
await expect(page.getByRole('main')).toContainText(
`new+${editorId}@editor.test`
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
await expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
]);
});
@@ -54,12 +46,8 @@ test('test that new admin can be invited', async ({ page }) => {
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
await page.getByRole('button', { name: 'Administrator' }).click();
await Promise.all([
page
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
expect(page.getByRole('main')).toContainText(
`new+${adminId}@admin.test`
),
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 }) => {
@@ -69,9 +57,7 @@ test('test that error shows if no role is selected', async ({ page }) => {
await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`);
await Promise.all([
page
.getByRole('button', { name: 'Invite Member', exact: true })
.click(),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
expect(page.getByText('Please select a role')).toBeVisible(),
]);
});
@@ -85,9 +71,7 @@ test('test that organization billable rate can be updated with all existing time
await page.getByRole('menuitem').getByText('Edit').click();
await page.getByText('Organization Default Rate').click();
await page.getByText('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 Promise.all([
@@ -103,8 +87,7 @@ test('test that organization billable rate can be updated with all existing time
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate ===
newBillableRate * 100
(await response.json()).data.billable_rate === newBillableRate * 100
),
]);
});

View File

@@ -35,9 +35,9 @@ test('test that organization name can be updated', async ({ page }) => {
await page.getByLabel('Organization Name').fill('NEW ORG NAME');
await page.getByLabel('Organization Name').press('Enter');
await page.getByLabel('Organization Name').press('Meta+r');
await expect(
page.locator('[data-testid="organization_switcher"]:visible')
).toContainText('NEW ORG NAME');
await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText(
'NEW ORG NAME'
);
});
test('test that organization billable rate can be updated with all existing time entries', async ({
@@ -46,9 +46,7 @@ test('test that organization billable rate can be updated with all existing time
await goToOrganizationSettings(page);
const newBillableRate = Math.round(Math.random() * 10000);
await page.getByLabel('Organization Billable Rate').click();
await page
.getByLabel('Organization Billable Rate')
.fill(newBillableRate.toString());
await page.getByLabel('Organization Billable Rate').fill(newBillableRate.toString());
await page
.locator('form')
.filter({ hasText: 'Organization Billable' })
@@ -56,9 +54,7 @@ test('test that organization billable rate can be updated with all existing time
.click();
await Promise.all([
page
.getByRole('button', { name: 'Yes, update existing time entries' })
.click(),
page.getByRole('button', { name: 'Yes, update existing time entries' }).click(),
page.waitForRequest(
async (request) =>
request.url().includes('/organizations/') &&
@@ -70,15 +66,12 @@ test('test that organization billable rate can be updated with all existing time
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate ===
newBillableRate * 100
(await response.json()).data.billable_rate === newBillableRate * 100
),
]);
});
test('test that organization format settings can be updated', async ({
page,
}) => {
test('test that organization format settings can be updated', async ({ page }) => {
await goToOrganizationSettings(page);
// Test number format
@@ -113,8 +106,7 @@ test('test that organization format settings can be updated', async ({
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.currency_format ===
'iso-code-after-with-space'
(await response.json()).data.currency_format === 'iso-code-after-with-space'
),
]);
@@ -132,8 +124,7 @@ test('test that organization format settings can be updated', async ({
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.date_format ===
'slash-separated-dd-mm-yyyy'
(await response.json()).data.date_format === 'slash-separated-dd-mm-yyyy'
),
]);
@@ -169,19 +160,14 @@ test('test that organization format settings can be updated', async ({
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.interval_format ===
'hours-minutes-colon-separated'
(await response.json()).data.interval_format === 'hours-minutes-colon-separated'
),
]);
});
test('test that format settings are reflected in the dashboard', async ({
page,
}) => {
test('test that format settings are reflected in the dashboard', async ({ page }) => {
// check that 0h 00min is displayed
await expect(
page.getByText('0h 00min', { exact: true }).nth(0)
).toBeVisible();
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).toBeVisible();
// First set the format settings
await goToOrganizationSettings(page);
@@ -213,10 +199,8 @@ test('test that format settings are reflected in the dashboard', async ({
response.url().includes('/organizations/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.interval_format ===
'hours-minutes-colon-separated' &&
(await response.json()).data.currency_format ===
'symbol-after' &&
(await response.json()).data.interval_format === 'hours-minutes-colon-separated' &&
(await response.json()).data.currency_format === 'symbol-after' &&
(await response.json()).data.number_format === 'comma-point'
),
]);
@@ -232,16 +216,12 @@ test('test that format settings are reflected in the dashboard', async ({
// check that 00:00 is displayed
await expect(page.getByText('0:00', { exact: true }).nth(0)).toBeVisible();
// check that 0h 00min is not displayed
await expect(
page.getByText('0h 00min', { exact: true }).nth(0)
).not.toBeVisible();
await expect(page.getByText('0h 00min', { exact: true }).nth(0)).not.toBeVisible();
// 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 expect(
page
.getByText(new Date().toLocaleDateString('en-GB'), { exact: true })
.nth(0)
page.getByText(new Date().toLocaleDateString('en-GB'), { exact: true }).nth(0)
).toBeVisible();
});

View File

@@ -1,34 +1,32 @@
import {test, expect} from '../playwright/fixtures';
import {PLAYWRIGHT_BASE_URL} from '../playwright/config';
import { test, expect } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
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.getByLabel('Name', {exact: true} ).fill('NEW NAME');
await page.getByLabel('Name', { exact: true }).fill('NEW NAME');
await Promise.all([
page.getByRole('button', {name: 'Save'}).first().click(),
page.getByRole('button', { name: 'Save' }).first().click(),
page.waitForResponse('**/user/profile-information'),
]);
await page.reload();
await expect(page.getByLabel('Name', {exact: true})).toHaveValue('NEW NAME');
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME');
});
test.skip('test that user email can be updated', async ({page}) => {
test.skip('test that user email can be updated', async ({ page }) => {
// this does not work because of email verification currently
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
const emailId = Math.round(Math.random() * 10000);
await page.getByLabel('Email').fill(`newemail+${emailId}@test.com`);
await page.getByRole('button', {name: 'Save'}).first().click();
await page.getByRole('button', { name: 'Save' }).first().click();
await page.reload();
await expect(page.getByLabel('Email')).toHaveValue(
`newemail+${emailId}@test.com`
);
await expect(page.getByLabel('Email')).toHaveValue(`newemail+${emailId}@test.com`);
});
async function createNewApiToken(page) {
await page.getByLabel('API Key Name').fill('NEW API KEY');
await Promise.all([
page.getByRole('button', {name: 'Create API Key'}).click(),
page.waitForResponse('**/users/me/api-tokens')
page.getByRole('button', { name: 'Create API Key' }).click(),
page.waitForResponse('**/users/me/api-tokens'),
]);
await expect(page.locator('body')).toContainText('API Token created successfully');
@@ -36,34 +34,37 @@ async function createNewApiToken(page) {
await expect(page.locator('body')).toContainText('NEW API KEY');
}
test('test that user can create an API key', async ({page}) => {
test('test that user can create an API key', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
});
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 createNewApiToken(page);
page.getByLabel('Delete API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to delete this API token?');
await expect(page.getByRole('dialog')).toContainText(
'Are you sure you would like to delete this API token?'
);
await Promise.all([
page.getByRole('dialog').getByRole('button', {name: 'Delete'}).click(),
page.waitForResponse('**/users/me/api-tokens')
page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(),
page.waitForResponse('**/users/me/api-tokens'),
]);
await expect(page.locator('body')).not.toContainText('NEW API KEY');
});
test('test that user can revoke an API key', async ({page}) => {
test('test that user can revoke an API key', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
await createNewApiToken(page);
page.getByLabel('Revoke API Token NEW API KEY').click();
await expect(page.getByRole('dialog')).toContainText('Are you sure you would like to revoke this API token?');
await expect(page.getByRole('dialog')).toContainText(
'Are you sure you would like to revoke this API token?'
);
await Promise.all([
page.getByRole('dialog').getByRole('button', {name: 'Revoke'}).click(),
page.waitForResponse('**/users/me/api-tokens')
page.getByRole('dialog').getByRole('button', { name: 'Revoke' }).click(),
page.waitForResponse('**/users/me/api-tokens'),
]);
await expect(page.getByRole('button', {name: 'Revoke'})).toBeHidden();
await expect(page.getByRole('button', { name: 'Revoke' })).toBeHidden();
await expect(page.locator('body')).toContainText('NEW API KEY');
await expect(page.locator('body')).toContainText('Revoked');
});

View File

@@ -12,8 +12,7 @@ async function goToProjectsOverview(page: Page) {
test('test that updating project member billable rate works for existing time entries', async ({
page,
}) => {
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);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
@@ -36,9 +35,7 @@ test('test that updating project member billable rate works for existing time en
.first()
.getByRole('button')
.click();
await page
.getByRole('menuitem', { name: 'Edit Project Member' })
.click();
await page.getByRole('menuitem', { name: 'Edit Project Member' }).click();
await page.getByLabel('Billable Rate').fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Project Member' }).click();
@@ -55,8 +52,7 @@ test('test that updating project member billable rate works for existing time en
response.url().includes('/project-members/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate ===
newBillableRate * 100
(await response.json()).data.billable_rate === newBillableRate * 100
),
]);
await expect(

View File

@@ -9,11 +9,8 @@ async function goToProjectsOverview(page: Page) {
}
// Create new project via modal
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);
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);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
@@ -31,16 +28,10 @@ test('test that creating and deleting a new project via the modal works', async
),
]);
await expect(page.getByTestId('project_table')).toContainText(
newProjectName
);
const moreButton = page.locator(
"[aria-label='Actions for Project " + newProjectName + "']"
);
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
moreButton.click();
const deleteButton = page.locator(
"[aria-label='Delete Project " + newProjectName + "']"
);
const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");
await Promise.all([
deleteButton.click(),
@@ -51,14 +42,11 @@ test('test that creating and deleting a new project via the modal works', async
response.status() === 204
),
]);
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 }) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
const newProjectName = 'New 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);
@@ -87,11 +75,8 @@ test('test that archiving and unarchiving projects works', async ({ page }) => {
]);
});
test('test that updating billable rate works with existing time entries', async ({
page,
}) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
test('test that updating billable rate works with existing time entries', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
@@ -104,15 +89,11 @@ test('test that updating billable rate works with existing time entries', async
await page.getByRole('menuitem').getByText('Edit').first().click();
await page.getByText('Non-Billable').click();
await page.getByText('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 Promise.all([
page
.locator('button').filter({ hasText: 'Yes, update existing time' })
.click(),
page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(),
page.waitForRequest(
async (request) =>
request.url().includes('/projects/') &&
@@ -124,8 +105,7 @@ test('test that updating billable rate works with existing time entries', async
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.billable_rate ===
newBillableRate * 100
(await response.json()).data.billable_rate === newBillableRate * 100
),
]);
await expect(

View File

@@ -2,8 +2,6 @@ import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
async function goToTimeOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
}
@@ -31,7 +29,10 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
@@ -43,7 +44,9 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
// Submit the time entry
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(response => response.url().includes('/time-entries') && response.status() === 201)
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]);
}
@@ -52,7 +55,10 @@ async function createTimeEntryWithTag(page: Page, tagName: string, duration: str
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Time entry with tag ${tagName}`);
// Add tag
await page.getByRole('button', { name: 'Tags' }).click();
@@ -69,12 +75,19 @@ async function createTimeEntryWithTag(page: Page, tagName: string, duration: str
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createTimeEntryWithBillableStatus(page: Page, isBillable: boolean, duration: string) {
async function createTimeEntryWithBillableStatus(
page: Page,
isBillable: boolean,
duration: string
) {
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Fill in the time entry details
await page.getByTestId('time_entry_description').fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
await page
.getByRole('dialog')
.getByRole('textbox', { name: 'Description' })
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
// Set billable status
await page.getByRole('button', { name: 'Non-Billable' }).click();
@@ -103,19 +116,22 @@ test('test that project filtering works in reporting', async ({ page }) => {
// Go to reporting and filter by project1
await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(project1).click();
await page.getByRole('dialog').getByText(project1).click();
await Promise.all([
// escape
page.keyboard.press('Escape'),
// wait for API request to finish
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
),
]);
await page.waitForLoadState('networkidle');
// Verify only project1 time entries are shown
await expect(page.getByText(project1)).toBeVisible();
await expect(page.getByText(project2)).not.toBeVisible();
await expect(page.getByTestId('reporting_view').getByText(project1)).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText(project2)).not.toBeVisible();
});
test('test that tag filtering works in reporting', async ({ page }) => {
@@ -138,11 +154,14 @@ test('test that tag filtering works in reporting', async ({ page }) => {
// escape
page.keyboard.press('Escape'),
// wait for API request to finish
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
),
]);
// Verify only time entries with tag1 are shown
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
});
test('test that billable status filtering works in reporting', async ({ page }) => {
@@ -160,14 +179,16 @@ test('test that billable status filtering works in reporting', async ({ page })
// escape
page.keyboard.press('Escape'),
// wait for API request to finish
page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200)
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
),
]);
await page.waitForLoadState('networkidle');
await expect(page.getByText('1h 00min').first()).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible();
});
test('test that detailed view shows time entries correctly', async ({ page }) => {
const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000);

View File

@@ -0,0 +1,508 @@
import { expect, Page, Browser } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
async function goToSharedReports(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
}
async function goToReporting(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
}
async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(projectName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
await page.getByText(projectName).waitFor({ state: 'visible' });
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]);
}
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createTimeEntryWithBillableStatus(
page: Page,
isBillable: boolean,
duration: string
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page
.getByTestId('time_entry_description')
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
await page.getByRole('button', { name: 'Non-Billable' }).click();
if (!isBillable) {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
}
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createReport(
page: Page,
reportName: string,
options: {
projectFilter?: string;
tagFilter?: string;
billableFilter?: 'billable' | 'non-billable' | 'all';
timeRange?: { start: string; end: string };
} = {}
) {
await goToReporting(page);
await page.waitForLoadState('networkidle');
// Apply filters if specified
if (options.projectFilter) {
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(options.projectFilter).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
if (options.tagFilter) {
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText(options.tagFilter).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
if (options.billableFilter && options.billableFilter !== 'all') {
await page.getByRole('button', { name: 'Billable' }).click();
if (options.billableFilter === 'billable') {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
}
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
// Set custom time range if specified
if (options.timeRange) {
await page.getByRole('button', { name: 'This Week' }).click();
await page.getByRole('option', { name: 'Custom Range' }).click();
await page.locator('input[name="startDate"]').fill(options.timeRange.start);
await page.locator('input[name="endDate"]').fill(options.timeRange.end);
await page.getByRole('button', { name: 'Apply' }).click();
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
await page.waitForLoadState('networkidle');
// Save the report
await page.getByRole('button', { name: 'Save Report' }).click();
await page.getByLabel('Report Name').fill(reportName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click();
await page.waitForLoadState('networkidle');
}
async function makeReportPublic(page: Page, reportName: string): Promise<string> {
await goToSharedReports(page);
await page.waitForLoadState('networkidle');
// Find the report row and click the edit button
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Edit' }).click();
// Make the report public
await page.getByRole('switch', { name: 'Make report public' }).click();
// Wait for the API response
await page.waitForResponse(
(response) => response.url().includes('/reports/') && response.status() === 200
);
// Save the changes
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
// Get the public URL
const copyButton = reportRow.getByRole('button', { name: 'Copy URL' });
await copyButton.click();
// Extract the URL from clipboard or from the button's data attribute
const publicUrl = await page.evaluate(() => navigator.clipboard.readText());
return publicUrl;
}
async function createUnauthenticatedPage(browser: Browser): Promise<Page> {
const context = await browser.newContext();
const page = await context.newPage();
return page;
}
test('access public shared report without authentication', async ({ page, browser }) => {
const projectName = 'Public Access Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Public Access Report ' + Math.floor(Math.random() * 10000);
// Create test data with authenticated user
await createTimeEntryWithProject(page, projectName, '2h 30min');
// Create and make report public
await createReport(page, reportName, { projectFilter: projectName });
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify the report is accessible and displays data
await expect(unauthenticatedPage.getByText(reportName)).toBeVisible();
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
await expect(unauthenticatedPage.getByText('2h 30min')).toBeVisible();
// Verify no authentication elements are present
await expect(unauthenticatedPage.getByRole('button', { name: 'Login' })).not.toBeVisible();
await expect(unauthenticatedPage.getByRole('button', { name: 'Register' })).not.toBeVisible();
await unauthenticatedPage.close();
});
test('access public shared report with project filter shows filtered data', async ({
page,
browser,
}) => {
const projectName = 'Filtered Project ' + Math.floor(Math.random() * 10000);
const otherProjectName = 'Other Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Filtered Report ' + Math.floor(Math.random() * 10000);
// Create test data for two projects
await createTimeEntryWithProject(page, projectName, '1h 30min');
await createTimeEntryWithProject(page, otherProjectName, '45min');
// Create and make report public with project filter
await createReport(page, reportName, { projectFilter: projectName });
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify only filtered project data is shown
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
await expect(unauthenticatedPage.getByText(otherProjectName)).not.toBeVisible();
await expect(unauthenticatedPage.getByText('1h 30min')).toBeVisible();
await expect(unauthenticatedPage.getByText('45min')).not.toBeVisible();
await unauthenticatedPage.close();
});
test('access public shared report with tag filter shows filtered data', async ({
page,
browser,
}) => {
const tagName = 'PublicTag' + Math.floor(Math.random() * 10000);
const otherTagName = 'PrivateTag' + Math.floor(Math.random() * 10000);
const reportName = 'Tag Filtered Report ' + Math.floor(Math.random() * 10000);
// Create test data for two tags
await createTimeEntryWithTag(page, tagName, '2h');
await createTimeEntryWithTag(page, otherTagName, '1h');
// Create and make report public with tag filter
await createReport(page, reportName, { tagFilter: tagName });
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify only filtered tag data is shown
await expect(unauthenticatedPage.getByText(tagName)).toBeVisible();
await expect(unauthenticatedPage.getByText(otherTagName)).not.toBeVisible();
await expect(unauthenticatedPage.getByText('2h 00min')).toBeVisible();
await expect(unauthenticatedPage.getByText('1h 00min')).not.toBeVisible();
await unauthenticatedPage.close();
});
test('access public shared report with billable filter shows filtered data', async ({
page,
browser,
}) => {
const reportName = 'Billable Filtered Report ' + Math.floor(Math.random() * 10000);
// Create test data for billable and non-billable entries
await createTimeEntryWithBillableStatus(page, true, '3h');
await createTimeEntryWithBillableStatus(page, false, '1h 30min');
// Create and make report public with billable filter
await createReport(page, reportName, { billableFilter: 'billable' });
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify only billable data is shown
await expect(unauthenticatedPage.getByText('3h 00min')).toBeVisible();
await expect(unauthenticatedPage.getByText('1h 30min')).not.toBeVisible();
await unauthenticatedPage.close();
});
test('access public shared report with custom time range shows filtered data', async ({
page,
browser,
}) => {
const projectName = 'TimeRange Project ' + Math.floor(Math.random() * 10000);
const reportName = 'TimeRange Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '2h 15min');
// Create and make report public with custom time range
const startDate = new Date();
startDate.setDate(startDate.getDate() - 7);
const endDate = new Date();
await createReport(page, reportName, {
projectFilter: projectName,
timeRange: {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
},
});
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify the data is shown within the time range
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
await expect(unauthenticatedPage.getByText('2h 15min')).toBeVisible();
await unauthenticatedPage.close();
});
test('access public shared report with multiple filters shows correctly filtered data', async ({
page,
browser,
}) => {
const projectName = 'MultiFilter Project ' + Math.floor(Math.random() * 10000);
const tagName = 'MultiTag' + Math.floor(Math.random() * 10000);
const reportName = 'MultiFilter Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '1h');
// Create a time entry with project, tag, and billable status
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill('Multi-filter entry');
// Set project
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
// Set tag
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
// Set as billable
await page.getByRole('button', { name: 'Non-Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill('2h 30min');
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
// Create and make report public with multiple filters
await createReport(page, reportName, {
projectFilter: projectName,
tagFilter: tagName,
billableFilter: 'billable',
});
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify the filtered data is shown
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
await expect(unauthenticatedPage.getByText(tagName)).toBeVisible();
await expect(unauthenticatedPage.getByText('2h 30min')).toBeVisible();
await unauthenticatedPage.close();
});
test('cannot access private shared report without authentication', async ({ page, browser }) => {
const projectName = 'Private Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Private Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '1h');
// Create report but don't make it public
await createReport(page, reportName, { projectFilter: projectName });
// Try to access the shared reports page without authentication
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
// Should redirect to login or show unauthorized
await expect(unauthenticatedPage.getByRole('button', { name: 'Login' })).toBeVisible();
await unauthenticatedPage.close();
});
test('cannot access public shared report with invalid share secret', async ({ page, browser }) => {
const projectName = 'Invalid Secret Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Invalid Secret Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '1h');
// Create and make report public
await createReport(page, reportName, { projectFilter: projectName });
await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Try to access with invalid share secret
const invalidUrl = PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-123';
await unauthenticatedPage.goto(invalidUrl);
// Should show error or not found
await expect(unauthenticatedPage.getByText('Report not found')).toBeVisible();
await unauthenticatedPage.close();
});
test('public shared report displays charts and visualizations', async ({ page, browser }) => {
const projectName = 'Chart Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Chart Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '4h');
// Create and make report public
await createReport(page, reportName, { projectFilter: projectName });
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify charts are displayed
await expect(unauthenticatedPage.locator('canvas')).toBeVisible();
// Verify summary statistics
await expect(unauthenticatedPage.getByText('Total Time')).toBeVisible();
await expect(unauthenticatedPage.getByText('4h 00min')).toBeVisible();
await unauthenticatedPage.close();
});
test('public shared report shows correct report metadata', async ({ page, browser }) => {
const projectName = 'Metadata Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Metadata Report ' + Math.floor(Math.random() * 10000);
const description = 'This is a public report showing project data';
// Create test data
await createTimeEntryWithProject(page, projectName, '1h 45min');
// Create report
await createReport(page, reportName, { projectFilter: projectName });
// Add description and make public
await goToSharedReports(page);
await page.waitForLoadState('networkidle');
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Edit' }).click();
await page.getByLabel('Description').fill(description);
await page.getByRole('switch', { name: 'Make report public' }).click();
await page.waitForResponse(
(response) => response.url().includes('/reports/') && response.status() === 200
);
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
// Get public URL
const copyButton = reportRow.getByRole('button', { name: 'Copy URL' });
await copyButton.click();
const publicUrl = await page.evaluate(() => navigator.clipboard.readText());
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify report metadata
await expect(unauthenticatedPage.getByText(reportName)).toBeVisible();
await expect(unauthenticatedPage.getByText(description)).toBeVisible();
await unauthenticatedPage.close();
});

View File

@@ -0,0 +1,542 @@
import { expect, Page, Browser } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
async function goToSharedReports(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
}
async function goToReporting(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
}
async function createTimeEntryWithProject(
page: Page,
projectName: string,
duration: string,
description: string = ''
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(projectName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
await page.getByText(projectName).waitFor({ state: 'visible' });
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page
.getByTestId('time_entry_description')
.fill(description || `Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]);
}
async function createTimeEntryWithTag(
page: Page,
tagName: string,
duration: string,
description: string = ''
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page
.getByTestId('time_entry_description')
.fill(description || `Time entry with tag ${tagName}`);
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createTimeEntryWithBillableStatus(
page: Page,
isBillable: boolean,
duration: string,
description: string = ''
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page
.getByTestId('time_entry_description')
.fill(description || `Time entry ${isBillable ? 'billable' : 'non-billable'}`);
await page.getByRole('button', { name: 'Non-Billable' }).click();
if (!isBillable) {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
}
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createReport(
page: Page,
reportName: string,
options: {
projectFilter?: string;
tagFilter?: string;
billableFilter?: 'billable' | 'non-billable' | 'all';
timeRange?: { start: string; end: string };
} = {}
) {
await goToReporting(page);
await page.waitForLoadState('networkidle');
// Apply filters if specified
if (options.projectFilter) {
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(options.projectFilter).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
if (options.tagFilter) {
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText(options.tagFilter).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
if (options.billableFilter && options.billableFilter !== 'all') {
await page.getByRole('button', { name: 'Billable' }).click();
if (options.billableFilter === 'billable') {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
}
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
// Set custom time range if specified
if (options.timeRange) {
await page.getByRole('button', { name: 'This Week' }).click();
await page.getByRole('option', { name: 'Custom Range' }).click();
await page.locator('input[name="startDate"]').fill(options.timeRange.start);
await page.locator('input[name="endDate"]').fill(options.timeRange.end);
await page.getByRole('button', { name: 'Apply' }).click();
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
await page.waitForLoadState('networkidle');
// Save the report
await page.getByRole('button', { name: 'Save Report' }).click();
await page.getByLabel('Report Name').fill(reportName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click();
await page.waitForLoadState('networkidle');
}
async function makeReportPublic(page: Page, reportName: string): Promise<string> {
await goToSharedReports(page);
await page.waitForLoadState('networkidle');
// Find the report row and click the edit button
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Edit' }).click();
// Make the report public
await page.getByRole('switch', { name: 'Make report public' }).click();
// Wait for the API response
await page.waitForResponse(
(response) => response.url().includes('/reports/') && response.status() === 200
);
// Save the changes
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
// Get the public URL
const copyButton = reportRow.getByRole('button', { name: 'Copy URL' });
await copyButton.click();
// Extract the URL from clipboard or from the button's data attribute
const publicUrl = await page.evaluate(() => navigator.clipboard.readText());
return publicUrl;
}
async function createUnauthenticatedPage(browser: Browser): Promise<Page> {
const context = await browser.newContext();
const page = await context.newPage();
return page;
}
test('verify shared report data accuracy with project filter', async ({ page, browser }) => {
const projectName = 'Accuracy Project ' + Math.floor(Math.random() * 10000);
const otherProjectName = 'Other Accuracy Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Accuracy Report ' + Math.floor(Math.random() * 10000);
// Create test data with specific durations
await createTimeEntryWithProject(page, projectName, '2h 30min', 'Task 1');
await createTimeEntryWithProject(page, projectName, '1h 15min', 'Task 2');
await createTimeEntryWithProject(page, otherProjectName, '3h', 'Other task');
// Create and make report public with project filter
await createReport(page, reportName, { projectFilter: projectName });
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in authenticated reporting view
await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(projectName).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
// Note expected total: 2h 30min + 1h 15min = 3h 45min
await expect(page.getByText('3h 45min')).toBeVisible();
// Verify same data in public view
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify total time matches
await expect(unauthenticatedPage.getByText('3h 45min')).toBeVisible();
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
await expect(unauthenticatedPage.getByText('Task 1')).toBeVisible();
await expect(unauthenticatedPage.getByText('Task 2')).toBeVisible();
await expect(unauthenticatedPage.getByText(otherProjectName)).not.toBeVisible();
await unauthenticatedPage.close();
});
test('verify shared report data accuracy with tag filter', async ({ page, browser }) => {
const tagName = 'AccuracyTag' + Math.floor(Math.random() * 10000);
const otherTagName = 'OtherTag' + Math.floor(Math.random() * 10000);
const reportName = 'Tag Accuracy Report ' + Math.floor(Math.random() * 10000);
// Create test data with specific durations
await createTimeEntryWithTag(page, tagName, '1h 30min', 'Tagged task 1');
await createTimeEntryWithTag(page, tagName, '2h 15min', 'Tagged task 2');
await createTimeEntryWithTag(page, otherTagName, '45min', 'Other tagged task');
// Create and make report public with tag filter
await createReport(page, reportName, { tagFilter: tagName });
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in authenticated reporting view
await goToReporting(page);
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText(tagName).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
// Note expected total: 1h 30min + 2h 15min = 3h 45min
await expect(page.getByText('3h 45min')).toBeVisible();
// Verify same data in public view
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify total time matches
await expect(unauthenticatedPage.getByText('3h 45min')).toBeVisible();
await expect(unauthenticatedPage.getByText(tagName)).toBeVisible();
await expect(unauthenticatedPage.getByText('Tagged task 1')).toBeVisible();
await expect(unauthenticatedPage.getByText('Tagged task 2')).toBeVisible();
await expect(unauthenticatedPage.getByText(otherTagName)).not.toBeVisible();
await unauthenticatedPage.close();
});
test('verify shared report data accuracy with billable filter', async ({ page, browser }) => {
const reportName = 'Billable Accuracy Report ' + Math.floor(Math.random() * 10000);
// Create test data with specific durations
await createTimeEntryWithBillableStatus(page, true, '2h', 'Billable task 1');
await createTimeEntryWithBillableStatus(page, true, '1h 30min', 'Billable task 2');
await createTimeEntryWithBillableStatus(page, false, '45min', 'Non-billable task');
// Create and make report public with billable filter
await createReport(page, reportName, { billableFilter: 'billable' });
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in authenticated reporting view
await goToReporting(page);
await page.getByRole('button', { name: 'Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
// Note expected total: 2h + 1h 30min = 3h 30min
await expect(page.getByText('3h 30min')).toBeVisible();
// Verify same data in public view
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify total time matches
await expect(unauthenticatedPage.getByText('3h 30min')).toBeVisible();
await expect(unauthenticatedPage.getByText('Billable task 1')).toBeVisible();
await expect(unauthenticatedPage.getByText('Billable task 2')).toBeVisible();
await expect(unauthenticatedPage.getByText('Non-billable task')).not.toBeVisible();
await unauthenticatedPage.close();
});
test('verify shared report data accuracy with non-billable filter', async ({ page, browser }) => {
const reportName = 'Non-Billable Accuracy Report ' + Math.floor(Math.random() * 10000);
// Create test data with specific durations
await createTimeEntryWithBillableStatus(page, false, '1h 45min', 'Non-billable task 1');
await createTimeEntryWithBillableStatus(page, false, '2h 30min', 'Non-billable task 2');
await createTimeEntryWithBillableStatus(page, true, '1h', 'Billable task');
// Create and make report public with non-billable filter
await createReport(page, reportName, { billableFilter: 'non-billable' });
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in authenticated reporting view
await goToReporting(page);
await page.getByRole('button', { name: 'Billable' }).click();
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
// Note expected total: 1h 45min + 2h 30min = 4h 15min
await expect(page.getByText('4h 15min')).toBeVisible();
// Verify same data in public view
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify total time matches
await expect(unauthenticatedPage.getByText('4h 15min')).toBeVisible();
await expect(unauthenticatedPage.getByText('Non-billable task 1')).toBeVisible();
await expect(unauthenticatedPage.getByText('Non-billable task 2')).toBeVisible();
await expect(unauthenticatedPage.getByText('Billable task')).not.toBeVisible();
await unauthenticatedPage.close();
});
test('verify shared report data accuracy with multiple filters', async ({ page, browser }) => {
const projectName = 'MultiAccuracy Project ' + Math.floor(Math.random() * 10000);
const tagName = 'MultiAccuracyTag' + Math.floor(Math.random() * 10000);
const reportName = 'MultiAccuracy Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '1h', 'Project only');
// Create a time entry with project, tag, and billable status
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill('Multi-filter matched entry');
// Set project
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
// Set tag
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
// Set as billable
await page.getByRole('button', { name: 'Non-Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill('2h 30min');
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
// Create another entry that won't match all filters
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill('Partial match entry');
// Set same project but different tag and non-billable
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill('DifferentTag');
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h 15min');
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
// Create and make report public with multiple filters
await createReport(page, reportName, {
projectFilter: projectName,
tagFilter: tagName,
billableFilter: 'billable',
});
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in authenticated reporting view
await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(projectName).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText(tagName).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
await page.getByRole('button', { name: 'Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
// Should only show the entry that matches all filters (2h 30min)
await expect(page.getByText('2h 30min')).toBeVisible();
// Verify same data in public view
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify only the matching entry is shown
await expect(unauthenticatedPage.getByText('2h 30min')).toBeVisible();
await expect(unauthenticatedPage.getByText('Multi-filter matched entry')).toBeVisible();
await expect(unauthenticatedPage.getByText('Project only')).not.toBeVisible();
await expect(unauthenticatedPage.getByText('Partial match entry')).not.toBeVisible();
await unauthenticatedPage.close();
});
test('verify shared report data accuracy with time range filter', async ({ page, browser }) => {
const projectName = 'TimeRange Accuracy Project ' + Math.floor(Math.random() * 10000);
const reportName = 'TimeRange Accuracy Report ' + Math.floor(Math.random() * 10000);
// Create test data within date range
await createTimeEntryWithProject(page, projectName, '1h 30min', 'Within range 1');
await createTimeEntryWithProject(page, projectName, '2h 15min', 'Within range 2');
// Create and make report public with time range
const startDate = new Date();
startDate.setDate(startDate.getDate() - 1);
const endDate = new Date();
endDate.setDate(endDate.getDate() + 1);
await createReport(page, reportName, {
projectFilter: projectName,
timeRange: {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
},
});
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in authenticated reporting view
await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(projectName).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
await page.getByRole('button', { name: 'This Week' }).click();
await page.getByRole('option', { name: 'Custom Range' }).click();
await page.locator('input[name="startDate"]').fill(startDate.toISOString().split('T')[0]);
await page.locator('input[name="endDate"]').fill(endDate.toISOString().split('T')[0]);
await page.getByRole('button', { name: 'Apply' }).click();
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
// Note expected total: 1h 30min + 2h 15min = 3h 45min
await expect(page.getByText('3h 45min')).toBeVisible();
// Verify same data in public view
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify total time matches
await expect(unauthenticatedPage.getByText('3h 45min')).toBeVisible();
await expect(unauthenticatedPage.getByText('Within range 1')).toBeVisible();
await expect(unauthenticatedPage.getByText('Within range 2')).toBeVisible();
await unauthenticatedPage.close();
});
test('verify shared report shows zero data when no entries match filters', async ({
page,
browser,
}) => {
const projectName = 'NoMatch Project ' + Math.floor(Math.random() * 10000);
const tagName = 'NoMatchTag' + Math.floor(Math.random() * 10000);
const reportName = 'NoMatch Report ' + Math.floor(Math.random() * 10000);
// Create test data that won't match our filters
await createTimeEntryWithProject(page, 'Other Project', '1h', 'Other entry');
// Create and make report public with filters that won't match
await createReport(page, reportName, {
projectFilter: projectName, // This project doesn't exist
tagFilter: tagName, // This tag doesn't exist
});
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in public view shows zero/empty results
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify no data is shown
await expect(unauthenticatedPage.getByText('0h 00min')).toBeVisible();
await expect(unauthenticatedPage.getByText('No data available')).toBeVisible();
await unauthenticatedPage.close();
});

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

@@ -0,0 +1,392 @@
import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
async function goToSharedReports(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
}
async function goToReporting(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
}
async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(projectName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
await page.getByText(projectName).waitFor({ state: 'visible' });
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]);
}
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createTimeEntryWithBillableStatus(
page: Page,
isBillable: boolean,
duration: string
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page
.getByTestId('time_entry_description')
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
await page.getByRole('button', { name: 'Non-Billable' }).click();
if (!isBillable) {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
}
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createReport(
page: Page,
reportName: string,
options: {
projectFilter?: string;
tagFilter?: string;
billableFilter?: 'billable' | 'non-billable' | 'all';
timeRange?: { start: string; end: string };
} = {}
) {
await goToReporting(page);
await page.waitForLoadState('networkidle');
// Apply filters if specified
if (options.projectFilter) {
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(options.projectFilter).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
if (options.tagFilter) {
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText(options.tagFilter).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
if (options.billableFilter && options.billableFilter !== 'all') {
await page.getByRole('button', { name: 'Billable' }).click();
if (options.billableFilter === 'billable') {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
}
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
// Set custom time range if specified
if (options.timeRange) {
await page.getByRole('button', { name: 'This Week' }).click();
await page.getByRole('option', { name: 'Custom Range' }).click();
await page.locator('input[name="startDate"]').fill(options.timeRange.start);
await page.locator('input[name="endDate"]').fill(options.timeRange.end);
await page.getByRole('button', { name: 'Apply' }).click();
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
await page.waitForLoadState('networkidle');
// Save the report
await page.getByRole('button', { name: 'Save Report' }).click();
await page.getByLabel('Report Name').fill(reportName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click();
await page.waitForLoadState('networkidle');
}
async function makeReportPublic(page: Page, reportName: string): Promise<string> {
await goToSharedReports(page);
await page.waitForLoadState('networkidle');
// Find the report row and click the edit button
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Edit' }).click();
// Make the report public
await page.getByRole('switch', { name: 'Make report public' }).click();
// Wait for the API response
await page.waitForResponse(
(response) => response.url().includes('/reports/') && response.status() === 200
);
// Save the changes
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
// Get the public URL
const copyButton = reportRow.getByRole('button', { name: 'Copy URL' });
await copyButton.click();
// Extract the URL from clipboard or from the button's data attribute
const publicUrl = await page.evaluate(() => navigator.clipboard.readText());
return publicUrl;
}
test('create shared report with project filter', async ({ page }) => {
const projectName = 'Shared Report Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Project Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '2h');
await createTimeEntryWithProject(page, 'Other Project', '1h');
// Create a report with project filter
await createReport(page, reportName, { projectFilter: projectName });
// Make the report public
const publicUrl = await makeReportPublic(page, reportName);
// Verify the report appears in shared reports list
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Public')).toBeVisible();
expect(publicUrl).toContain('/shared-report#');
});
test('create shared report with tag filter', async ({ page }) => {
const tagName = 'SharedTag' + Math.floor(Math.random() * 10000);
const reportName = 'Tag Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithTag(page, tagName, '1h 30min');
await createTimeEntryWithTag(page, 'OtherTag', '45min');
// Create a report with tag filter
await createReport(page, reportName, { tagFilter: tagName });
// Make the report public
const publicUrl = await makeReportPublic(page, reportName);
// Verify the report appears in shared reports list
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Public')).toBeVisible();
expect(publicUrl).toContain('/shared-report#');
});
test('create shared report with billable filter', async ({ page }) => {
const reportName = 'Billable Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithBillableStatus(page, true, '2h');
await createTimeEntryWithBillableStatus(page, false, '1h');
// Create a report with billable filter
await createReport(page, reportName, { billableFilter: 'billable' });
// Make the report public
const publicUrl = await makeReportPublic(page, reportName);
// Verify the report appears in shared reports list
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Public')).toBeVisible();
expect(publicUrl).toContain('/shared-report#');
});
test('create shared report with custom time range', async ({ page }) => {
const projectName = 'TimeRange Project ' + Math.floor(Math.random() * 10000);
const reportName = 'TimeRange Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '3h');
// Create a report with custom time range (last 30 days)
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
const endDate = new Date();
await createReport(page, reportName, {
projectFilter: projectName,
timeRange: {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
},
});
// Make the report public
const publicUrl = await makeReportPublic(page, reportName);
// Verify the report appears in shared reports list
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Public')).toBeVisible();
expect(publicUrl).toContain('/shared-report#');
});
test('create shared report with multiple filters', async ({ page }) => {
const projectName = 'MultiFilter Project ' + Math.floor(Math.random() * 10000);
const tagName = 'MultiTag' + Math.floor(Math.random() * 10000);
const reportName = 'MultiFilter Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '2h');
// Create a time entry with both project and tag
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill('Multi-filter entry');
// Set project
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
// Set tag
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
// Set as billable
await page.getByRole('button', { name: 'Non-Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h 30min');
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
// Create a report with multiple filters
await createReport(page, reportName, {
projectFilter: projectName,
tagFilter: tagName,
billableFilter: 'billable',
});
// Make the report public
const publicUrl = await makeReportPublic(page, reportName);
// Verify the report appears in shared reports list
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Public')).toBeVisible();
expect(publicUrl).toContain('/shared-report#');
});
test('toggle report visibility from public to private', async ({ page }) => {
const projectName = 'Toggle Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Toggle Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '1h');
// Create a report
await createReport(page, reportName, { projectFilter: projectName });
// Make the report public
await makeReportPublic(page, reportName);
// Verify it's public
await expect(page.getByText('Public')).toBeVisible();
// Make it private again
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('switch', { name: 'Make report public' }).click();
await page.waitForResponse(
(response) => response.url().includes('/reports/') && response.status() === 200
);
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
// Verify it's now private
await expect(page.getByText('Private')).toBeVisible();
await expect(page.getByText('Public')).not.toBeVisible();
});
test('edit shared report name and description', async ({ page }) => {
const projectName = 'Edit Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Original Report ' + Math.floor(Math.random() * 10000);
const updatedName = 'Updated Report ' + Math.floor(Math.random() * 10000);
const description = 'This is an updated description';
// Create test data
await createTimeEntryWithProject(page, projectName, '1h');
// Create a report
await createReport(page, reportName, { projectFilter: projectName });
// Make the report public
await makeReportPublic(page, reportName);
// Edit the report
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Edit' }).click();
await page.getByLabel('Report Name').fill(updatedName);
await page.getByLabel('Description').fill(description);
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
// Verify the changes
await expect(page.getByText(updatedName)).toBeVisible();
await expect(page.getByText(reportName)).not.toBeVisible();
});
test('delete shared report', async ({ page }) => {
const projectName = 'Delete Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Delete Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '1h');
// Create a report
await createReport(page, reportName, { projectFilter: projectName });
// Make the report public
await makeReportPublic(page, reportName);
// Delete the report
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete Report' }).click();
await page.waitForLoadState('networkidle');
// Verify the report is deleted
await expect(page.getByText(reportName)).not.toBeVisible();
});

View File

@@ -7,9 +7,7 @@ async function goToTagsOverview(page: Page) {
}
// Create new project via modal
test('test that creating and deleting a new client via the modal works', async ({
page,
}) => {
test('test that creating and deleting a new client via the modal works', async ({ page }) => {
const newTagName = 'New Tag ' + Math.floor(1 + Math.random() * 10000);
await goToTagsOverview(page);
await page.getByRole('button', { name: 'Create Tag' }).click();
@@ -27,13 +25,9 @@ test('test that creating and deleting a new client via the modal works', async (
]);
await expect(page.getByTestId('tag_table')).toContainText(newTagName);
const moreButton = page.locator(
"[aria-label='Actions for Tag " + newTagName + "']"
);
const moreButton = page.locator("[aria-label='Actions for Tag " + newTagName + "']");
moreButton.click();
const deleteButton = page.locator(
"[aria-label='Delete Tag " + newTagName + "']"
);
const deleteButton = page.locator("[aria-label='Delete Tag " + newTagName + "']");
await Promise.all([
deleteButton.click(),

View File

@@ -7,11 +7,8 @@ async function goToProjectsOverview(page: Page) {
}
// Create new project via modal
test('test that creating and deleting a new tag in a new project works', async ({
page,
}) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
test('test that creating and deleting a new tag in a new project works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
@@ -29,9 +26,7 @@ test('test that creating and deleting a new tag in a new project works', async (
),
]);
await expect(page.getByTestId('project_table')).toContainText(
newProjectName
);
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
await page.getByText(newProjectName).click();
@@ -55,13 +50,9 @@ test('test that creating and deleting a new tag in a new project works', async (
await expect(page.getByTestId('task_table')).toContainText(newTaskName);
const taskMoreButton = page.locator(
"[aria-label='Actions for Task " + newTaskName + "']"
);
const taskMoreButton = page.locator("[aria-label='Actions for Task " + newTaskName + "']");
taskMoreButton.click();
const taskDeleteButton = page.locator(
"[aria-label='Delete Task " + newTaskName + "']"
);
const taskDeleteButton = page.locator("[aria-label='Delete Task " + newTaskName + "']");
await Promise.all([
taskDeleteButton.click(),
@@ -76,13 +67,9 @@ test('test that creating and deleting a new tag in a new project works', async (
await goToProjectsOverview(page);
const moreButton = page.locator(
"[aria-label='Actions for Project " + newProjectName + "']"
);
const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']");
moreButton.click();
const deleteButton = page.locator(
"[aria-label='Delete Project " + newProjectName + "']"
);
const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']");
await Promise.all([
deleteButton.click(),
@@ -93,14 +80,11 @@ test('test that creating and deleting a new tag in a new project works', async (
response.status() === 204
),
]);
await expect(page.getByTestId('project_table')).not.toContainText(
newProjectName
);
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
});
test('test that archiving and unarchiving tasks works', async ({ page }) => {
const newProjectName =
'New Project ' + Math.floor(1 + Math.random() * 10000);
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newTaskName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);

View File

@@ -25,9 +25,7 @@ async function createEmptyTimeEntry(page: Page) {
startOrStopTimerWithButton(page),
assertThatTimerIsStopped(page),
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
(response) => response.url().includes('/time-entries') && response.status() === 200
),
]);
}
@@ -38,9 +36,7 @@ test('test that starting and stopping an empty time entry shows a new time entry
await Promise.all([
goToTimeOverview(page),
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
(response) => response.url().includes('/time-entries') && response.status() === 200
),
]);
await page.waitForTimeout(100);
@@ -56,9 +52,7 @@ test('test that starting and stopping an empty time entry shows a new time entry
// Test that description update works
async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) {
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(
/bg-accent-300\/70/
);
await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass(/bg-accent-300\/70/);
}
test('test that updating a description of a time entry in the overview works on blur', async ({
@@ -71,17 +65,14 @@ test('test that updating a description of a time entry in the overview works on
await assertThatTimeEntryRowIsStopped(newTimeEntry);
const newDescription = Math.floor(Math.random() * 1000000).toString();
const descriptionElement = newTimeEntry.getByTestId(
'time_entry_description'
);
const descriptionElement = newTimeEntry.getByTestId('time_entry_description');
await descriptionElement.fill(newDescription);
await Promise.all([
descriptionElement.press('Tab'),
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
@@ -90,8 +81,7 @@ test('test that updating a description of a time entry in the overview works on
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
JSON.stringify((await response.json()).data.tags) === JSON.stringify([])
);
}),
]);
@@ -107,17 +97,14 @@ test('test that updating a description of a time entry in the overview works on
const newTimeEntry = timeEntryRows.first();
await assertThatTimeEntryRowIsStopped(newTimeEntry);
const newDescription = Math.floor(Math.random() * 1000000).toString();
const descriptionElement = newTimeEntry.getByTestId(
'time_entry_description'
);
const descriptionElement = newTimeEntry.getByTestId('time_entry_description');
await descriptionElement.fill(newDescription);
await Promise.all([
descriptionElement.press('Enter'),
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
@@ -126,16 +113,13 @@ test('test that updating a description of a time entry in the overview works on
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
JSON.stringify((await response.json()).data.tags) === JSON.stringify([])
);
}),
]);
});
test('test that adding a new tag to an existing time entry works', async ({
page,
}) => {
test('test that adding a new tag to an existing time entry works', async ({ page }) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
@@ -152,8 +136,7 @@ test('test that adding a new tag to an existing time entry works', async ({
page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.json()).data.name === newTagName
);
}),
@@ -163,8 +146,7 @@ test('test that adding a new tag to an existing time entry works', async ({
await page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
@@ -187,17 +169,14 @@ test('test that updating a the start of an existing time entry in the overview w
const newTimeEntry = timeEntryRows.first();
await assertThatTimeEntryRowIsStopped(newTimeEntry);
await page.waitForTimeout(1500);
const timeEntryRangeElement = newTimeEntry.getByTestId(
'time_entry_range_selector'
);
const timeEntryRangeElement = newTimeEntry.getByTestId('time_entry_range_selector');
await timeEntryRangeElement.click();
await page.getByTestId('time_entry_range_start').first().fill('1');
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.json()).data.id !== null &&
// TODO! Actually check the value
(await response.json()).data.start !== null &&
@@ -208,9 +187,7 @@ test('test that updating a the start of an existing time entry in the overview w
]);
});
test('test that updating a the duration in the overview works on blur', async ({
page,
}) => {
test('test that updating a the duration in the overview works on blur', async ({ page }) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
@@ -225,8 +202,7 @@ test('test that updating a the duration in the overview works on blur', async ({
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.json()).data.id !== null &&
// TODO! Actually check the value
(await response.json()).data.start !== null &&
@@ -240,9 +216,7 @@ test('test that updating a the duration in the overview works on blur', async ({
});
// Test that start stop button stops running timer
test('test that starting a time entry from the overview works', async ({
page,
}) => {
test('test that starting a time entry from the overview works', async ({ page }) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
@@ -255,8 +229,7 @@ test('test that starting a time entry from the overview works', async ({
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
@@ -272,8 +245,7 @@ test('test that starting a time entry from the overview works', async ({
page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null
@@ -284,9 +256,7 @@ test('test that starting a time entry from the overview works', async ({
]);
});
test('test that deleting a time entry from the overview works', async ({
page,
}) => {
test('test that deleting a time entry from the overview works', async ({ page }) => {
await goToTimeOverview(page);
const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
await createEmptyTimeEntry(page);
@@ -302,16 +272,12 @@ test('test that deleting a time entry from the overview works', async ({
await expect(timeEntryRows).toHaveCount(0);
});
test.skip('test that load more works when the end of page is reached', async ({
page,
}) => {
test.skip('test that load more works when the end of page is reached', async ({ page }) => {
// this test is flaky when you do not need to scroll
await Promise.all([
goToTimeOverview(page),
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.status() === 200
(response) => response.url().includes('/time-entries') && response.status() === 200
),
]);
@@ -322,18 +288,14 @@ test.skip('test that load more works when the end of page is reached', async ({
return (
response.status() === 200 &&
response.url().includes('before') &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
JSON.stringify((await response.json()).data) ===
JSON.stringify([])
(await response.headerValue('Content-Type')) === 'application/json' &&
JSON.stringify((await response.json()).data) === JSON.stringify([])
);
}),
]);
// assert that "All time entries are loaded!" is visible on page
await expect(page.locator('body')).toHaveText(
/All time entries are loaded!/
);
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
});
// TODO: Test that updating the time entry start / end times works while it is running

View File

@@ -24,22 +24,15 @@ test('test that starting and stopping a timer without description and project wo
assertThatTimerHasStarted(page),
]);
await page.waitForTimeout(1500);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerIsStopped(page);
});
test('test that starting and stopping a timer with a description works', async ({
page,
}) => {
test('test that starting and stopping a timer with a description works', async ({ page }) => {
await goToDashboard(page);
// TODO: Fix flakyness by disabling description input field until timer is loaded
await page.waitForTimeout(500);
await page
.getByTestId('time_entry_description')
.fill('New Time Entry Description');
await page.getByTestId('time_entry_description').fill('New Time Entry Description');
await Promise.all([
newTimeEntryResponse(page, {
description: 'New Time Entry Description',
@@ -62,47 +55,29 @@ test('test that starting the time entry starts the live timer and that it keeps
}) => {
await goToDashboard(page);
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(500);
const beforeTimerValue = await page
.getByTestId('time_entry_time')
.inputValue();
const beforeTimerValue = await page.getByTestId('time_entry_time').inputValue();
await page.waitForTimeout(2000);
const afterWaitTimeValue = await page
.getByTestId('time_entry_time')
.inputValue();
const afterWaitTimeValue = await page.getByTestId('time_entry_time').inputValue();
expect(afterWaitTimeValue).not.toEqual(beforeTimerValue);
await page.reload();
await page.waitForTimeout(500);
const afterReloadTimerValue = await page
.getByTestId('time_entry_time')
.inputValue();
const afterReloadTimerValue = await page.getByTestId('time_entry_time').inputValue();
await page.waitForTimeout(2000);
const afterReloadAfterWaitTimerValue = await page
.getByTestId('time_entry_time')
.inputValue();
const afterReloadAfterWaitTimerValue = await page.getByTestId('time_entry_time').inputValue();
expect(afterReloadTimerValue).not.toEqual(afterReloadAfterWaitTimerValue);
});
test('test that starting and updating the description while running works', async ({
page,
}) => {
test('test that starting and updating the description while running works', async ({ page }) => {
await goToDashboard(page);
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(500);
await page
.getByTestId('time_entry_description')
.fill('New Time Entry Description');
await page.getByTestId('time_entry_description').fill('New Time Entry Description');
await Promise.all([
newTimeEntryResponse(page, {
@@ -121,9 +96,7 @@ test('test that starting and updating the description while running works', asyn
await assertThatTimerIsStopped(page);
});
test('test that starting and updating the time while running works', async ({
page,
}) => {
test('test that starting and updating the time while running works', async ({ page }) => {
await goToDashboard(page);
const [createResponse] = await Promise.all([
newTimeEntryResponse(page),
@@ -138,19 +111,16 @@ test('test that starting and updating the time while running works', async ({
return (
response.url().includes('/time-entries') &&
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.start !==
(await createResponse.json()).data.start &&
(await response.json()).data.start !== (await createResponse.json()).data.start &&
(await response.json()).data.end === null &&
(await response.json()).data.project_id === null &&
(await response.json()).data.description === '' &&
(await response.json()).data.task_id === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify([])
JSON.stringify((await response.json()).data.tags) === JSON.stringify([])
);
}),
page.getByTestId('time_entry_time').press('Enter'),
@@ -158,16 +128,11 @@ test('test that starting and updating the time while running works', async ({
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20/);
await page.waitForTimeout(500);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerIsStopped(page);
});
test('test that entering a human readable time starts the timer on blur', async ({
page,
}) => {
test('test that entering a human readable time starts the timer on blur', async ({ page }) => {
await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('20min');
await Promise.all([
@@ -177,18 +142,13 @@ test('test that entering a human readable time starts the timer on blur', async
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:20:/);
await assertThatTimerHasStarted(page);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
});
test('test that entering a number in the time range starts the timer on blur', async ({
page,
}) => {
test('test that entering a number in the time range starts the timer on blur', async ({ page }) => {
await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('5');
await Promise.all([
@@ -198,10 +158,7 @@ test('test that entering a number in the time range starts the timer on blur', a
await expect(page.getByTestId('time_entry_time')).toHaveValue(/00:05:/);
await assertThatTimerHasStarted(page);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
@@ -219,10 +176,7 @@ test('test that entering a value with the format hh:mm in the time range starts
await expect(page.getByTestId('time_entry_time')).toHaveValue(/12:30:/);
await assertThatTimerHasStarted(page);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"].bg-accent-300/70'
);
@@ -239,9 +193,7 @@ test('test that entering a random value in the time range does not start the tim
);
});
test('test that entering a time starts the timer on enter', async ({
page,
}) => {
test('test that entering a time starts the timer on enter', async ({ page }) => {
await goToDashboard(page);
await page.getByTestId('time_entry_time').fill('20min');
await Promise.all([
@@ -249,10 +201,7 @@ test('test that entering a time starts the timer on enter', async ({
page.getByTestId('time_entry_time').press('Enter'),
]);
await assertThatTimerHasStarted(page);
await Promise.all([
stoppedTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerIsStopped(page);
});
@@ -273,15 +222,10 @@ test('test that adding a new tag works', async ({ page }) => {
await expect(page.getByRole('option', { name: newTagName })).toBeVisible();
});
test('test that adding a new tag when the timer is running', async ({
page,
}) => {
test('test that adding a new tag when the timer is running', async ({ page }) => {
const newTagName = 'New Tag' + Math.floor(Math.random() * 10000);
await goToDashboard(page);
await Promise.all([
newTimeEntryResponse(page),
startOrStopTimerWithButton(page),
]);
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
await assertThatTimerHasStarted(page);
await page.getByTestId('tag_dropdown').click();
await page.getByText('Create new tag').click();

View File

@@ -1,9 +1,7 @@
import { expect, Page } from '@playwright/test';
export async function startOrStopTimerWithButton(page: Page) {
await page
.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
.click();
await page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]').click();
}
export async function assertThatTimerHasStarted(page: Page) {
@@ -20,8 +18,7 @@ export function newTimeEntryResponse(
return (
response.url().includes('/time-entries') &&
response.status() === status &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end === null &&
@@ -29,30 +26,23 @@ export function newTimeEntryResponse(
(await response.json()).data.description === description &&
(await response.json()).data.task_id === null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify(tags)
JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)
);
});
}
export async function assertThatTimerIsStopped(page: Page) {
await expect(
page.locator(
'[data-testid="dashboard_timer"] [data-testid="timer_button"]'
)
page.locator('[data-testid="dashboard_timer"] [data-testid="timer_button"]')
).toHaveClass(/bg-accent-300\/70/);
}
export async function stoppedTimeEntryResponse(
page: Page,
{ description = '', tags = [] } = {}
) {
export async function stoppedTimeEntryResponse(page: Page, { description = '', tags = [] } = {}) {
return page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
response.url().includes('/time-entries/') &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.json()).data.id !== null &&
(await response.json()).data.start !== null &&
(await response.json()).data.end !== null &&
@@ -61,8 +51,7 @@ export async function stoppedTimeEntryResponse(
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
JSON.stringify((await response.json()).data.tags) ===
JSON.stringify(tags)
JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)
);
});
}

View File

@@ -14,4 +14,4 @@ export function formatCentsWithOrganizationDefaults(
currencySymbol,
'point-comma' as NumberFormat
);
}
}

View File

@@ -4,8 +4,7 @@ export function newTagResponse(page: Page, { name = '' } = {}) {
return page.waitForResponse(async (response) => {
return (
response.status() === 201 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&
(await response.headerValue('Content-Type')) === 'application/json' &&
(await response.json()).data.name === name
);
});

View File

@@ -3,7 +3,7 @@ import eslintConfigPrettier from 'eslint-config-prettier';
import eslintPluginVue from 'eslint-plugin-vue';
import globals from 'globals';
import typescriptEslint from 'typescript-eslint';
import unusedImports from "eslint-plugin-unused-imports";
import unusedImports from 'eslint-plugin-unused-imports';
export default typescriptEslint.config(
{ ignores: ['*.d.ts', '**/coverage', '**/dist'] },
@@ -23,18 +23,21 @@ export default typescriptEslint.config(
},
},
plugins: {
"unused-imports": unusedImports,
'unused-imports': unusedImports,
},
rules: {
"vue/multi-word-component-names": "off",
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": ["error", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_",
}],
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'error',
{
'vars': 'all',
'varsIgnorePattern': '^_',
'args': 'after-used',
'argsIgnorePattern': '^_',
},
],
},
},
eslintConfigPrettier

2
package-lock.json generated
View File

@@ -16,7 +16,7 @@
"@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^12.5.0",
"@vueuse/core": "^12.8.2",
"@vueuse/integrations": "^12.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -8,7 +8,9 @@
"lint:fix": "eslint --fix resources/js",
"type-check": "vue-tsc --noEmit",
"test:e2e": "rm -rf test-results/.auth && npx playwright test",
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api"
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api",
"format": "prettier --write './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'",
"format:check": "prettier --check './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -46,7 +48,7 @@
"@tanstack/vue-table": "^8.21.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.3.0",
"@vueuse/core": "^12.5.0",
"@vueuse/core": "^12.8.2",
"@vueuse/integrations": "^12.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -1,2 +1 @@
export const PLAYWRIGHT_BASE_URL =
process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';

View File

@@ -8,12 +8,8 @@ export const test = baseTest.extend<object, { workerStorageState: string }>({
// Perform authentication steps. Replace these actions with your own.
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
await page.getByLabel('Name').fill('John Doe');
await page
.getByLabel('Email')
.fill(`john+${Math.round(Math.random() * 1000000)}@doe.com`);
await page
.getByLabel('Password', { exact: true })
.fill('amazingpassword123');
await page.getByLabel('Email').fill(`john+${Math.round(Math.random() * 1000000)}@doe.com`);
await page.getByLabel('Password', { exact: true }).fill('amazingpassword123');
await page.getByLabel('Confirm Password').fill('amazingpassword123');
await page.getByLabel('I agree to the Terms of').click();
await page.getByRole('button', { name: 'Register' }).click();

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -160,30 +160,15 @@ body {
}
/* Inter Variable Font with browser compatibility considerations */
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-Regular.ttf');
font-weight: 400;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-Medium.ttf');
font-weight: 500;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-SemiBold.ttf');
font-weight: 600;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-Bold.ttf');
font-weight: 700;
}
@font-face {
font-family: 'Outfit';
src: url('/fonts/Outfit-ExtraBold.ttf');
font-weight: 800;
font-family: 'Inter';
src: url('/fonts/InterVariable.woff2') format('woff2'),
url('/fonts/InterVariable.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
@layer base {
@@ -205,7 +190,7 @@ body {
--destructive: 0 84.2% 60.2%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--theme-color-input-background);
--input: var(--color-border-tertiary);
--ring: var(--theme-color-ring);
--chart-1: var(--color-accent-400);
--chart-2: var(--color-accent-500);
@@ -232,7 +217,7 @@ body {
--destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--theme-color-input-background);
--input: var(--color-border-tertiary);
--ring: var(--theme-color-ring);
--chart-1: var(--color-accent-200);
--chart-2: var(--color-accent-300);

View File

@@ -14,8 +14,7 @@ import SectionTitle from './SectionTitle.vue';
</SectionTitle>
<div class="mt-5 md:mt-0 md:col-span-2">
<div
class="px-4 py-5 sm:p-6 bg-card-background shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6 bg-card-background shadow sm:rounded-lg">
<slot name="content" />
</div>
</div>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useTheme } from "@/utils/theme.js";
import { onMounted } from 'vue';
import { useTheme } from '@/utils/theme.js';
onMounted(async () => {
useTheme()
useTheme();
});
</script>

View File

@@ -24,9 +24,7 @@ watchEffect(async () => {
<template>
<div>
<div
v-if="show && message"
class="bg-secondary border-b border-border-secondary">
<div v-if="show && message" class="bg-secondary border-b border-border-secondary">
<div class="mx-auto py-1 px-3 sm:px-6 lg:px-8">
<div class="flex items-center justify-between flex-wrap">
<div class="w-0 flex-1 flex items-center min-w-0">

View File

@@ -1,10 +1,6 @@
<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import {
CheckBadgeIcon,
XMarkIcon,
XCircleIcon,
} from '@heroicons/vue/16/solid';
import { CheckBadgeIcon, XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import {
@@ -18,28 +14,20 @@ import { useSessionStorage } from '@vueuse/core';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { canManageBilling } from '@/utils/permissions';
const hideTrialBanner = useSessionStorage(
'showTrialBanner-' + getCurrentOrganizationId(),
false
);
const hideTrialBanner = useSessionStorage('showTrialBanner-' + getCurrentOrganizationId(), false);
const showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value);
const hideBlockedBanner = useSessionStorage(
'showBlockedBanner-' + getCurrentOrganizationId(),
false
);
const showBlockedBanner = computed(
() => isBlocked() && !hideBlockedBanner.value
);
const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value);
const hideFreeUpgradeBanner = useSessionStorage(
'showFreeUpgradeBanner-' + getCurrentOrganizationId(),
false
);
const showFreeUpgradeBanner = computed(
() =>
isFreePlan() &&
!isBlocked() &&
!hideFreeUpgradeBanner.value &&
!showBlackFridayBanner.value
isFreePlan() && !isBlocked() && !hideFreeUpgradeBanner.value && !showBlackFridayBanner.value
);
const hideBlackFridayBanner = useSessionStorage(
'hideBlackFridayBanner-' + getCurrentOrganizationId(),
@@ -62,10 +50,7 @@ const showBlackFridayBanner = computed(() => {
class="bg-tertiary text-xs lg:text-sm pb-1 pt-2 border-b border-border-secondary">
<MainContainer class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<svg
class="w-4 mr-1"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg">
<svg class="w-4 mr-1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<path
fill="#FF37AD"
d="M22.498 68.97a11.845 11.845 0 1 0 0-23.687c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844m181.393-10.04a11.845 11.845 0 1 0-.003-23.688c-6.471.098-11.665 5.373-11.665 11.845c.001 6.472 5.197 11.745 11.668 11.842" />
@@ -113,8 +98,7 @@ const showBlackFridayBanner = computed(() => {
</div>
</Link>
<button class="p-1" @click="hideBlackFridayBanner = true">
<XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
</div>
</MainContainer>
@@ -130,8 +114,8 @@ const showBlackFridayBanner = computed(() => {
Your trial expires in {{ daysLeftInTrial() }} days.
</span>
<span class="hidden md:inline">
To continue using all features & support the development
of solidtime, please upgrade your plan.
To continue using all features & support the development of solidtime,
please upgrade your plan.
</span>
</div>
</div>
@@ -143,8 +127,7 @@ const showBlackFridayBanner = computed(() => {
</div>
</Link>
<button class="p-1" @click="hideTrialBanner = true">
<XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
</div>
</MainContainer>
@@ -156,27 +139,22 @@ const showBlackFridayBanner = computed(() => {
<div class="flex items-center space-x-1.5">
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
<div class="flex-1 space-x-1">
<span class="font-medium">
Your organization is currently blocked.
</span>
<span class="font-medium"> Your organization is currently blocked. </span>
<span class="hidden md:inline">
Please upgrade to a premium plan or remove all users
except the owner to unblock your organization.
Please upgrade to a premium plan or remove all users except the owner to
unblock your organization.
</span>
</div>
</div>
<div class="flex items-center space-x-2">
<Link
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
<div
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>
<button class="p-1" @click="hideBlockedBanner = true">
<XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
</div>
</MainContainer>
@@ -188,27 +166,22 @@ const showBlackFridayBanner = computed(() => {
<div class="flex items-center space-x-1.5">
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
<div class="flex-1 space-x-1">
<span class="font-medium">
You are currently using the Free Plan.
</span>
<span class="font-medium"> You are currently using the Free Plan. </span>
<span class="hidden md:inline">
To unlock all premium features & support the development
of solidtime, please upgrade your plan.</span
To unlock all premium features & support the development of solidtime,
please upgrade your plan.</span
>
</div>
</div>
<div class="flex items-center space-x-2">
<Link
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
<div
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>
<button class="p-1" @click="hideFreeUpgradeBanner = true">
<XMarkIcon
class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
</div>
</MainContainer>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"></script>
<template>
<div class="rounded-lg border overflow-hidden border-card-border bg-card-background shadow-card">
<div
class="rounded-lg border overflow-hidden border-card-border bg-card-background shadow-card">
<slot></slot>
</div>
</template>

View File

@@ -1,9 +1,5 @@
<script setup lang="ts">
import {
ArchiveBoxIcon,
PencilSquareIcon,
TrashIcon,
} from '@heroicons/vue/20/solid';
import { ArchiveBoxIcon, PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid';
import type { Client } from '@/packages/api/src';
import { canDeleteClients, canUpdateClients } from '@/utils/permissions';
import {

View File

@@ -24,17 +24,10 @@ const createClient = ref(false);
class="grid min-w-full"
style="grid-template-columns: 1fr 150px 200px 80px">
<ClientTableHeading></ClientTableHeading>
<div
v-if="clients.length === 0"
class="col-span-3 py-24 text-center">
<UserCircleIcon
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
<h3 class="text-text-primary font-semibold">
No clients found
</h3>
<p v-if="canCreateClients()" class="pb-5">
Create your first client now!
</p>
<div v-if="clients.length === 0" class="col-span-3 py-24 text-center">
<UserCircleIcon class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
<h3 class="text-text-primary font-semibold">No clients found</h3>
<p v-if="canCreateClients()" class="pb-5">Create your first client now!</p>
<SecondaryButton
v-if="canCreateClients()"
:icon="PlusIcon as Component"

View File

@@ -20,9 +20,7 @@ function deleteClient() {
}
const projectCount = computed(() => {
return projects.value.filter(
(projects) => projects.client_id === props.client.id
).length;
return projects.value.filter((projects) => projects.client_id === props.client.id).length;
});
function archiveClient() {
@@ -37,9 +35,7 @@ const showEditModal = ref(false);
<template>
<TableRow>
<ClientEditModal
v-model:show="showEditModal"
:client="client"></ClientEditModal>
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>

View File

@@ -20,11 +20,8 @@ onMounted(async () => {
class="grid min-w-full"
style="grid-template-columns: 1fr 1fr 80px">
<InvitationTableHeading></InvitationTableHeading>
<template
v-for="invitation in invitations"
:key="invitation.id">
<InvitationTableRow
:invitation="invitation"></InvitationTableRow>
<template v-for="invitation in invitations" :key="invitation.id">
<InvitationTableRow :invitation="invitation"></InvitationTableRow>
</template>
</div>
</div>

View File

@@ -9,8 +9,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
Email
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div
class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span>
</div>
</TableHeading>

View File

@@ -38,15 +38,12 @@ async function resendInvitation() {
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.resendInvitationEmail(
undefined,
{
params: {
invitation: props.invitation.id,
organization: organizationId,
},
}
),
api.resendInvitationEmail(undefined, {
params: {
invitation: props.invitation.id,
organization: organizationId,
},
}),
'Invitation mail sent successfully',
'Error sending invitation mail'
);
@@ -65,9 +62,7 @@ async function resendInvitation() {
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<InvitationMoreOptionsDropdown
@delete="deleteInvitation"
@resend="resendInvitation" />
<InvitationMoreOptionsDropdown @delete="deleteInvitation" @resend="resendInvitation" />
</div>
</TableRow>
</template>

View File

@@ -42,8 +42,8 @@ defineEmits<{
>.
</p>
<p class="py-1 text-center font-semibold max-w-md mx-auto">
Do you want to update all existing time entries, where the member
billable rate applies as well?
Do you want to update all existing time entries, where the member billable rate applies
as well?
</p>
</BillableRateModal>
</template>

View File

@@ -35,12 +35,8 @@ useFocus(searchInput, { initialValue: true });
const filteredMembers = computed<Member[]>(() => {
return members.value.filter((member) => {
return (
member.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '') &&
!props.hiddenMembers.some(
(hiddenMember) => hiddenMember.member_id === member.id
) &&
member.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '') &&
!props.hiddenMembers.some((hiddenMember) => hiddenMember.member_id === member.id) &&
member.is_placeholder === false
);
});

View File

@@ -9,7 +9,7 @@ import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
import { useNotificationsStore } from '@/utils/notification';
import { getCurrentOrganizationId } from '@/utils/useUser';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import InputError from '@/packages/ui/src/Input/InputError.vue';
import { useMembersStore } from '@/utils/useMembers';
@@ -30,7 +30,7 @@ const deleteMutation = useMutation({
if (!organizationId) {
throw new Error('No organization ID found');
}
return api.removeMember(undefined, {
params: {
member: props.member.id,
@@ -44,7 +44,7 @@ const deleteMutation = useMutation({
onSuccess: () => {
close();
useMembersStore().fetchMembers();
}
},
});
const form = useForm({
@@ -70,77 +70,77 @@ const close = () => {
<template>
<Modal :show="show" max-width="md" @close="close">
<div class="p-6">
<h2 class="text-lg font-medium text-text-primary">
Delete Member
</h2>
<h2 class="text-lg font-medium text-text-primary">Delete Member</h2>
<div class="mt-4 text-sm text-text-secondary">
<p class="mb-4">
Are you sure you want to delete {{ member.name }}? This action cannot be undone.
</p>
<p class="mb-4">
This will permanently delete:
</p>
<p class="mb-4">This will permanently delete:</p>
<ul class="list-disc ml-6 mt-2">
<li>All time entries created by this member</li>
<li>Their project assignments</li>
<li>Their organization membership</li>
</ul>
<ul class="list-disc ml-6 mt-2">
<li>All time entries created by this member</li>
<li>Their project assignments</li>
<li>Their organization membership</li>
</ul>
<p class="pt-4">
<strong>Note:</strong> Deleting time entries will affect all reports and statistics.
If you want to keep the time entries but remove the member from your organization, you can convert them to a placeholder user instead. Placeholder users are not charged and their time entries remain intact for reporting purposes.
<strong>Note:</strong> Deleting time entries will affect all reports and
statistics. If you want to keep the time entries but remove the member from your
organization, you can convert them to a placeholder user instead. Placeholder
users are not charged and their time entries remain intact for reporting
purposes.
</p>
</div>
<form
class="mt-6" @submit="
(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}
">
class="mt-6"
@submit="
(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}
">
<div class="flex items-start">
<form.Field
name="confirmDelete"
:validators="{
onSubmit: ({value}) => {
if (!value) {
return 'You must confirm that you understand the consequences of this action';
}
return '';
<form.Field
name="confirmDelete"
:validators="{
onSubmit: ({ value }) => {
if (!value) {
return 'You must confirm that you understand the consequences of this action';
}
}"
>
return '';
},
}">
<template #default="{ field }">
<div class="flex flex-col">
<div class="flex items-center space-x-3 text-sm">
<Checkbox
:id="field.name"
:name="field.name"
:checked="field.state.value"
@update:checked="field.handleChange"
@blur="field.handleBlur"
/>
<InputLabel :for="field.name" class="font-medium text-text-primary">
I understand that this will permanently delete all data related to this member
</InputLabel>
</div>
<InputError class="pl-7 pt-2" :message="field.state.meta.errors[0]" />
<div class="flex items-center space-x-3 text-sm">
<Checkbox
:id="field.name"
:name="field.name"
:checked="field.state.value"
@update:checked="field.handleChange"
@blur="field.handleBlur" />
<InputLabel
:for="field.name"
class="font-medium text-text-primary">
I understand that this will permanently delete all data
related to this member
</InputLabel>
</div>
<InputError
class="pl-7 pt-2"
:message="field.state.meta.errors[0]" />
</div>
</template>
</form.Field>
</form.Field>
</div>
<div class="mt-6 flex justify-end space-x-3">
<SecondaryButton @click="close">Cancel</SecondaryButton>
<form.Subscribe>
<template #default="{ canSubmit, isSubmitting }">
<DangerButton
type="submit"
:disabled="!canSubmit"
>
{{ isSubmitting ? 'Deleting...' : 'Delete Member' }}
<DangerButton type="submit" :disabled="!canSubmit">
{{ isSubmitting ? 'Deleting...' : 'Delete Member' }}
</DangerButton>
</template>
</form.Subscribe>
@@ -148,4 +148,4 @@ class="mt-6" @submit="
</form>
</div>
</Modal>
</template>
</template>

View File

@@ -54,10 +54,7 @@ function saveWithChecks() {
showBillableRateModal.value = true;
}, 0);
show.value = false;
} else if (
memberBody.value.role === 'owner' &&
props.member.role !== 'owner'
) {
} else if (memberBody.value.role === 'owner' && props.member.role !== 'owner') {
show.value = false;
showOwnershipTransferConfirmModal.value = true;
} else {
@@ -96,10 +93,7 @@ const roleDescriptionTexts = {
};
const roleDescription = computed(() => {
if (
memberBody.value.role &&
memberBody.value.role in roleDescriptionTexts
) {
if (memberBody.value.role && memberBody.value.role in roleDescriptionTexts) {
return roleDescriptionTexts[memberBody.value.role];
}
return '';
@@ -143,22 +137,14 @@ const roleDescription = computed(() => {
<div>
<InputLabel for="billableType" value="Billable" />
<MemberBillableSelect
v-model="
billableRateSelect
"
v-model="billableRateSelect"
class="mt-2"
name="billableType"></MemberBillableSelect>
</div>
<div
v-if="billableRateSelect === 'custom-rate'"
class="flex-1">
<InputLabel
for="memberBillableRate"
value="Billable Rate" />
<div v-if="billableRateSelect === 'custom-rate'" class="flex-1">
<InputLabel for="memberBillableRate" value="Billable Rate" />
<BillableRateInput
v-model="
memberBody.billable_rate
"
v-model="memberBody.billable_rate"
focus
class="w-full"
:currency="getOrganizationCurrencyString()"

View File

@@ -11,10 +11,7 @@ import type { Role } from '@/types/jetstream';
import { Link, useForm } from '@inertiajs/vue3';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { filterRoles } from '@/utils/roles';
import {
isAllowedToPerformPremiumAction,
isBillingActivated,
} from '@/utils/billing';
import { isAllowedToPerformPremiumAction, isBillingActivated } from '@/utils/billing';
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
import { api } from '@/packages/api/src';
@@ -44,14 +41,10 @@ const { handleApiRequestNotifications } = useNotificationsStore();
async function submit() {
if (addTeamMemberForm.role === null || addTeamMemberForm.email === '') {
errors.value.email = z
.string()
.email()
.safeParse(addTeamMemberForm.email).success
errors.value.email = z.string().email().safeParse(addTeamMemberForm.email).success
? ''
: 'Please enter a valid email address';
errors.value.role =
addTeamMemberForm.role === null ? 'Please select a role' : '';
errors.value.role = addTeamMemberForm.role === null ? 'Please select a role' : '';
return;
}
@@ -100,21 +93,15 @@ useFocus(clientNameInput, { initialValue: true });
<UserGroupIcon class="w-12"></UserGroupIcon>
</div>
<div class="max-w-sm text-center mx-auto py-4 text-base">
<p class="py-1">
The Free plan is <strong>limited to one member</strong>
</p>
<p class="py-1">The Free plan is <strong>limited to one member</strong></p>
<p class="py-1">
To add new team members to your organization you,
<strong>please upgrade to a paid plan</strong>.
</p>
<Link
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
<PrimaryButton
v-if="
isBillingActivated() && canUpdateOrganization()
"
v-if="isBillingActivated() && canUpdateOrganization()"
type="button"
class="mt-6">
<CreditCardIcon class="w-5 h-5 me-2" />
@@ -154,8 +141,7 @@ useFocus(clientNameInput, { initialValue: true });
:class="{
'border-t border-card-border focus:border-none rounded-t-none':
i > 0,
'rounded-b-none':
i != Object.keys(availableRoles).length - 1,
'rounded-b-none': i != Object.keys(availableRoles).length - 1,
}"
@click="addTeamMemberForm.role = role.key">
<div
@@ -169,17 +155,13 @@ useFocus(clientNameInput, { initialValue: true });
<div
class="text-sm text-text-primary"
:class="{
'font-semibold':
addTeamMemberForm.role ==
role.key,
'font-semibold': addTeamMemberForm.role == role.key,
}">
{{ role.name }}
</div>
<svg
v-if="
addTeamMemberForm.role == role.key
"
v-if="addTeamMemberForm.role == role.key"
class="ms-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import {ref} from 'vue';
import {api, type Member} from '@/packages/api/src';
import { ref } from 'vue';
import { api, type Member } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import {useMutation} from '@tanstack/vue-query';
import {getCurrentOrganizationId} from "@/utils/useUser";
import {useNotificationsStore} from "@/utils/notification";
import {useMembersStore} from "@/utils/useMembers";
import { useMutation } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useMembersStore } from '@/utils/useMembers';
const {handleApiRequestNotifications} = useNotificationsStore();
const { handleApiRequestNotifications } = useNotificationsStore();
const show = defineModel('show', {default: false});
const show = defineModel('show', { default: false });
const saving = ref(false);
const props = defineProps<{
@@ -27,7 +27,7 @@ const turnToPlaceholderMutation = useMutation({
return await api.makePlaceholder(undefined, {
params: {
organization: organizationId,
member: props.member.id
member: props.member.id,
},
});
},
@@ -36,17 +36,15 @@ const turnToPlaceholderMutation = useMutation({
async function submit() {
saving.value = true;
await handleApiRequestNotifications(
() =>
turnToPlaceholderMutation.mutateAsync(),
() => turnToPlaceholderMutation.mutateAsync(),
'Deactivating the member was successful!',
'There was an error deactivating the user.',
() => {
show.value = false;
useMembersStore().fetchMembers()
useMembersStore().fetchMembers();
}
);
}
</script>
<template>
@@ -59,8 +57,9 @@ async function submit() {
<template #content>
<p>
Deactivating the user <strong>{{ member.name }} </strong> will remove the user's access to
the organization. You will not be billed for inactive users and all time entries will be preserved.
Deactivating the user <strong>{{ member.name }} </strong> will remove the user's
access to the organization. You will not be billed for inactive users and all time
entries will be preserved.
</p>
</template>
<template #footer>

View File

@@ -2,14 +2,14 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import {api, type Member} from '@/packages/api/src';
import { api, type Member } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import MemberCombobox from "@/Components/Common/Member/MemberCombobox.vue";
import {UserIcon, ArrowRightIcon} from "@heroicons/vue/24/solid";
import {Badge} from "@/packages/ui/src";
import MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue';
import { UserIcon, ArrowRightIcon } from '@heroicons/vue/24/solid';
import { Badge } from '@/packages/ui/src';
import { useMutation } from '@tanstack/vue-query';
import {getCurrentOrganizationId} from "@/utils/useUser";
import {useNotificationsStore} from "@/utils/notification";
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
const { handleApiRequestNotifications, addNotification } = useNotificationsStore();
const show = defineModel('show', { default: false });
@@ -27,40 +27,36 @@ const mergeMember = useMutation({
if (organizationId === null) {
throw new Error('No current organization id - create report');
}
return await api.mergeMember({
member_id: newMemberId,
}, {
params: {
organization: organizationId,
member: props.member.id
return await api.mergeMember(
{
member_id: newMemberId,
},
});
{
params: {
organization: organizationId,
member: props.member.id,
},
}
);
},
});
async function submit() {
const newMemberId = newMember.value;
if(newMemberId !== ''){
if (newMemberId !== '') {
saving.value = true;
await handleApiRequestNotifications(
() =>
mergeMember.mutateAsync(newMemberId),
() => mergeMember.mutateAsync(newMemberId),
'Members successfully merged!',
'There was an error merging the members.',
() => {
show.value = false;
}
);
} else {
addNotification('error', 'Please select a member to merge into.');
}
else{
addNotification(
'error',
'Please select a member to merge into.',
);
}
}
</script>
<template>
@@ -72,10 +68,14 @@ async function submit() {
</template>
<template #content>
<p>Merging the user <strong>{{ member.name }} </strong> into another one will transfer all time entries to the new user. <strong>This cannot be reverted!</strong></p>
<p>
Merging the user <strong>{{ member.name }} </strong> into another one will transfer
all time entries to the new user. <strong>This cannot be reverted!</strong>
</p>
<div class="py-5 flex flex-col md:flex-row gap-6 items-center">
<div class="flex-1">
<Badge class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
<Badge
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
<UserIcon class="relative z-10 w-4 text-text-secondary"></UserIcon>
<div class="flex-1 font-medium truncate">
{{ member.name }}
@@ -86,9 +86,7 @@ async function submit() {
<ArrowRightIcon class="relative z-10 w-4 text-muted"></ArrowRightIcon>
</div>
<div class="flex-1">
<MemberCombobox
v-model="newMember"
></MemberCombobox>
<MemberCombobox v-model="newMember"></MemberCombobox>
</div>
</div>
</template>

View File

@@ -1,7 +1,17 @@
<script setup lang="ts">
import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid';
import {
TrashIcon,
UserCircleIcon,
PencilSquareIcon,
ArrowDownOnSquareStackIcon,
} from '@heroicons/vue/20/solid';
import type { Member } from '@/packages/api/src';
import {canDeleteMembers, canMakeMembersPlaceholders, canMergeMembers, canUpdateMembers} from '@/utils/permissions';
import {
canDeleteMembers,
canMakeMembersPlaceholders,
canMergeMembers,
canUpdateMembers,
} from '@/utils/permissions';
import {
DropdownMenu,
DropdownMenuContent,

View File

@@ -26,8 +26,8 @@ const emit = defineEmits<{
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1">
<p class="py-1 text-center">
You are about to transfer the ownership of this
organization to {{ memberName }}.
You are about to transfer the ownership of this organization to
{{ memberName }}.
</p>
</div>
</div>

View File

@@ -22,9 +22,7 @@ function getNameFromItem(item: Role) {
}
function getNameForKey(key: string | undefined) {
const item = page.props.availableRoles.find(
(item) => getKeyFromItem(item) === key
);
const item = page.props.availableRoles.find((item) => getKeyFromItem(item) === key);
if (item) {
return getNameFromItem(item);
}

View File

@@ -10,12 +10,9 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Email</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Billable Rate
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div
class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span>
</div>
</TableHeading>

View File

@@ -86,13 +86,9 @@ const userHasValidMailAddress = computed(() => {
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<CheckCircleIcon
v-if="member.is_placeholder === false"
class="w-5"></CheckCircleIcon>
<CheckCircleIcon v-if="member.is_placeholder === false" class="w-5"></CheckCircleIcon>
<span v-if="member.is_placeholder === false">Active</span>
<UserCircleIcon
v-if="member.is_placeholder === true"
class="w-5"></UserCircleIcon>
<UserCircleIcon v-if="member.is_placeholder === true" class="w-5"></UserCircleIcon>
<span v-if="member.is_placeholder === true">Inactive</span>
</div>
<div
@@ -116,12 +112,8 @@ const userHasValidMailAddress = computed(() => {
showMakeMemberPlaceholderModal = true
"></MemberMoreOptionsDropdown>
</div>
<MemberEditModal
v-model:show="showEditMemberModal"
:member="member"></MemberEditModal>
<MemberMergeModal
v-model:show="showMergeMemberModal"
:member="member"></MemberMergeModal>
<MemberEditModal v-model:show="showEditMemberModal" :member="member"></MemberEditModal>
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
<MemberMakePlaceholderModal
v-model:show="showMakeMemberPlaceholderModal"
:member="member"></MemberMakePlaceholderModal>

View File

@@ -41,8 +41,8 @@ defineEmits<{
>.
</p>
<p class="py-0.5 text-center font-semibold">
Do you want to update all existing time entries, where the
organization billable rate applies as well?
Do you want to update all existing time entries, where the organization billable rate
applies as well?
</p>
</BillableRateModal>
</template>

View File

@@ -1,41 +1,39 @@
<script setup lang="ts">
import ProjectBadge from "@/packages/ui/src/Project/ProjectBadge.vue";
import { computed, nextTick, ref, watch } from "vue";
import { useProjectsStore } from "@/utils/useProjects";
import Dropdown from "@/packages/ui/src/Input/Dropdown.vue";
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport
} from "radix-vue";
import { PlusCircleIcon } from "@heroicons/vue/20/solid";
import { storeToRefs } from "pinia";
import { api } from "@/packages/api/src";
import { usePage } from "@inertiajs/vue3";
import { getRandomColor } from "@/packages/ui/src/utils/color";
import type { Project } from "@/packages/api/src";
import ProjectDropdownItem from "@/packages/ui/src/Project/ProjectDropdownItem.vue";
import { UseFocusTrap } from "@vueuse/integrations/useFocusTrap/component";
ComboboxViewport,
} from 'radix-vue';
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import { storeToRefs } from 'pinia';
import { api } from '@/packages/api/src';
import { usePage } from '@inertiajs/vue3';
import { getRandomColor } from '@/packages/ui/src/utils/color';
import type { Project } from '@/packages/api/src';
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
const searchValue = ref("");
const searchValue = ref('');
const searchInput = ref<HTMLElement | null>(null);
const model = defineModel<string | null>({
default: null
default: null,
});
const open = ref(false);
const projectsStore = useProjectsStore();
const emit = defineEmits(["update:modelValue", "changed"]);
const emit = defineEmits(['update:modelValue', 'changed']);
const { projects } = storeToRefs(projectsStore);
const projectDropdownTrigger = ref<HTMLElement | null>(null);
const shownProjects = computed(() => {
return projects.value.filter((project) => {
return project.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || "");
return project.name.toLowerCase().includes(searchValue.value?.toLowerCase()?.trim() || '');
});
});
@@ -44,7 +42,7 @@ withDefaults(
border?: boolean;
}>(),
{
border: true
border: true,
}
);
@@ -62,13 +60,13 @@ async function addProjectIfNoneExists() {
{
name: searchValue.value,
color: getRandomColor(),
is_billable: false
is_billable: false,
},
{ params: { organization: page.props.auth.user.current_team_id } }
);
projects.value.unshift(response.data);
model.value = response.data.id;
searchValue.value = "";
searchValue.value = '';
open.value = false;
}
}
@@ -95,16 +93,16 @@ function isProjectSelected(project: Project) {
}
const selectedProjectName = computed(() => {
return currentProject.value?.name || "No Project";
return currentProject.value?.name || 'No Project';
});
const selectedProjectColor = computed(() => {
return currentProject.value?.color || "var(--theme-color-icon-default)";
return currentProject.value?.color || 'var(--theme-color-icon-default)';
});
function updateValue(project: Project) {
model.value = project.id;
emit("changed");
emit('changed');
}
</script>
@@ -122,16 +120,13 @@ function updateValue(project: Project) {
</template>
<template #content>
<UseFocusTrap
v-if="open"
:options="{ immediate: true, allowOutsideClick: true }">
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<ComboboxRoot
v-model:search-term="searchValue"
:open="open"
:model-value="currentProject"
class="relative"
@update:model-value="updateValue"
>
@update:model-value="updateValue">
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
@@ -155,19 +150,12 @@ function updateValue(project: Project) {
:name="project.name"></ProjectDropdownItem>
</ComboboxItem>
<div
v-if="
searchValue.length > 0 &&
shownProjects.length === 0
"
v-if="searchValue.length > 0 && shownProjects.length === 0"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span
>Add "{{ searchValue }}" as a new
Project</span
>
<PlusCircleIcon class="w-5 flex-shrink-0"></PlusCircleIcon>
<span>Add "{{ searchValue }}" as a new Project</span>
</div>
</div>
</ComboboxViewport>

View File

@@ -3,11 +3,7 @@ import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, ref } from 'vue';
import type {
CreateClientBody,
CreateProjectBody,
Project,
} from '@/packages/api/src';
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useProjectsStore } from '@/utils/useProjects';
import { useFocus } from '@vueuse/core';
@@ -64,9 +60,7 @@ useFocus(projectNameInput, { initialValue: true });
const currentClientName = computed(() => {
if (project.value.client_id) {
return clients.value.find(
(client) => client.id === project.value.client_id
)?.name;
return clients.value.find((client) => client.id === project.value.client_id)?.name;
}
return 'No Client';
});
@@ -87,8 +81,7 @@ async function submitBillableRate() {
</template>
<template #content>
<div
class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-5">
<div class="sm:flex items-center space-y-2 sm:space-y-0 sm:space-x-5">
<div class="flex-1 flex items-center">
<div class="text-center">
<InputLabel for="color" value="Color" />
@@ -122,8 +115,7 @@ async function submitBillableRate() {
class="bg-input-background cursor-pointer hover:bg-tertiary"
size="xlarge">
<div class="flex items-center space-x-2">
<UserCircleIcon
class="w-5 text-icon-default"></UserCircleIcon>
<UserCircleIcon class="w-5 text-icon-default"></UserCircleIcon>
<span class="whitespace-nowrap">
{{ currentClientName }}
</span>
@@ -137,9 +129,7 @@ async function submitBillableRate() {
<div>
<ProjectEditBillableSection
v-model:is-billable="project.is_billable"
v-model:billable-rate="
project.billable_rate
"
v-model:billable-rate="project.billable_rate"
:currency="getOrganizationCurrencyString()"
@submit="submit"></ProjectEditBillableSection>
</div>

View File

@@ -1,9 +1,5 @@
<script setup lang="ts">
import {
TrashIcon,
PencilSquareIcon,
ArchiveBoxIcon,
} from '@heroicons/vue/20/solid';
import { TrashIcon, PencilSquareIcon, ArchiveBoxIcon } from '@heroicons/vue/20/solid';
import type { Project } from '@/packages/api/src';
import { canDeleteProjects, canUpdateProjects } from '@/utils/permissions';
import {

View File

@@ -7,12 +7,7 @@ import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import { canCreateProjects } from '@/utils/permissions';
import type {
CreateProjectBody,
Project,
Client,
CreateClientBody,
} from '@/packages/api/src';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
@@ -24,15 +19,11 @@ const props = defineProps<{
}>();
const showCreateProjectModal = ref(false);
async function createProject(
project: CreateProjectBody
): Promise<Project | undefined> {
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
async function createClient(
client: CreateClientBody
): Promise<Client | undefined> {
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
return await useClientsStore().createClient(client);
}
const { clients } = storeToRefs(useClientsStore());
@@ -52,19 +43,11 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
:enable-estimated-time="isAllowedToPerformPremiumAction"></ProjectCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div
data-testid="project_table"
class="grid min-w-full"
:style="gridTemplate">
<div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
<ProjectTableHeading
:show-billable-rate="
props.showBillableRate
"></ProjectTableHeading>
<div
v-if="projects.length === 0"
class="col-span-5 py-24 text-center">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
:show-billable-rate="props.showBillableRate"></ProjectTableHeading>
<div v-if="projects.length === 0" class="col-span-5 py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">
{{
canCreateProjects()

View File

@@ -12,15 +12,9 @@ defineProps<{
Name
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Client</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Total Time
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Progress
</div>
<div
v-if="showBillableRate"
class="px-3 py-1.5 text-left font-semibold text-text-primary">
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
<div v-if="showBillableRate" class="px-3 py-1.5 text-left font-semibold text-text-primary">
Billable Rate
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>

View File

@@ -26,14 +26,11 @@ const props = defineProps<{
}>();
const client = computed(() => {
return clients.value.find(
(client) => client.id === props.project.client_id
);
return clients.value.find((client) => client.id === props.project.client_id);
});
const projectTasksCount = computed(() => {
return tasks.value.filter((task) => task.project_id === props.project.id)
.length;
return tasks.value.filter((task) => task.project_id === props.project.id).length;
});
function deleteProject() {
@@ -67,7 +64,6 @@ const billableRateInfo = computed(() => {
});
const showEditProjectModal = ref(false);
</script>
<template>
@@ -86,15 +82,10 @@ const showEditProjectModal = ref(false);
<span class="overflow-ellipsis overflow-hidden">
{{ project.name }}
</span>
<span class="text-text-secondary">
{{ projectTasksCount }} Tasks
</span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div>
<div
class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div
v-if="project.client_id"
class="overflow-ellipsis overflow-hidden">
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }}
</div>
<div v-else>No client</div>
@@ -111,10 +102,8 @@ const showEditProjectModal = ref(false);
</div>
<div v-else>--</div>
</div>
<div
class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeBadge
v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress
v-else-if="project.estimated_time"
:estimated="project.estimated_time"

View File

@@ -42,8 +42,8 @@ defineEmits<{
>.
</p>
<p class="py-1 text-center font-semibold max-w-md mx-auto">
Do you want to update all existing time entries, where the project
member billable rate applies as well?
Do you want to update all existing time entries, where the project member billable rate
applies as well?
</p>
</BillableRateModal>
</template>

View File

@@ -2,10 +2,7 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import type {
CreateProjectMemberBody,
ProjectMember,
} from '@/packages/api/src';
import type { CreateProjectMemberBody, ProjectMember } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useProjectMembersStore } from '@/utils/useProjectMembers';
@@ -57,9 +54,7 @@ useFocus(projectNameInput, { initialValue: true });
</div>
<div class="col-span-3 sm:col-span-1 flex-1">
<BillableRateInput
v-model="
projectMember.billable_rate
"
v-model="projectMember.billable_rate"
name="billable_rate"
:currency="getOrganizationCurrencyString()"></BillableRateInput>
</div>

View File

@@ -2,10 +2,7 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref, watch } from 'vue';
import type {
ProjectMember,
UpdateProjectMemberBody,
} from '@/packages/api/src';
import type { ProjectMember, UpdateProjectMemberBody } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useProjectMembersStore } from '@/utils/useProjectMembers';
@@ -29,10 +26,7 @@ const projectMemberBody = ref<UpdateProjectMemberBody>({
});
const showBillableRateModal = ref(false);
async function submit() {
if (
props.projectMember.billable_rate !==
projectMemberBody.value.billable_rate
) {
if (props.projectMember.billable_rate !== projectMemberBody.value.billable_rate) {
// make sure that the alert modal is not immediately submitted when user presses enter
setTimeout(() => {
showBillableRateModal.value = true;
@@ -84,20 +78,14 @@ useFocus(projectNameInput, { initialValue: true });
@close="showBillableRateModal = false"
@submit="submitBillableRate"></ProjectMemberBillableRateModal>
<div class="grid grid-cols-3 items-center space-x-4">
<div
class="col-span-3 sm:col-span-2 space-x-2 flex items-center">
<div class="col-span-3 sm:col-span-2 space-x-2 flex items-center">
<UserIcon class="w-4 text-text-secondary"></UserIcon>
<span>{{ props.name }}</span>
</div>
<div class="col-span-3 sm:col-span-1 flex-1">
<InputLabel
for="billable_rate"
class="mb-2"
value="Billable Rate"></InputLabel>
<InputLabel for="billable_rate" class="mb-2" value="Billable Rate"></InputLabel>
<BillableRateInput
v-model="
projectMemberBody.billable_rate
"
v-model="projectMemberBody.billable_rate"
:currency="getOrganizationCurrencyString()"
name="billable_rate"
@keydown.enter="submit"></BillableRateInput>

View File

@@ -22,9 +22,7 @@ const props = defineProps<{
const { members } = storeToRefs(useMembersStore());
const currentMember = computed(() => {
return members.value.find(
(member) => member.id === props.projectMember.user_id
);
return members.value.find((member) => member.id === props.projectMember.user_id);
});
</script>

View File

@@ -28,24 +28,16 @@ const createProjectMember = ref(false);
class="grid min-w-full"
style="grid-template-columns: 1fr 150px 150px 80px">
<ProjectMemberTableHeading></ProjectMemberTableHeading>
<div
v-if="projectMembers.length === 0"
class="col-span-5 py-24 text-center">
<UserGroupIcon
class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
<div v-if="projectMembers.length === 0" class="col-span-5 py-24 text-center">
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
<h3 class="text-text-primary font-semibold">No project members</h3>
<p class="pb-5">Add the first project member!</p>
<SecondaryButton
:icon="PlusIcon"
@click="createProjectMember = true"
<SecondaryButton :icon="PlusIcon" @click="createProjectMember = true"
>Add a new Project Member
</SecondaryButton>
</div>
<template
v-for="projectMember in projectMembers"
:key="projectMember.id">
<ProjectMemberTableRow
:project-member="projectMember"></ProjectMemberTableRow>
<template v-for="projectMember in projectMembers" :key="projectMember.id">
<ProjectMemberTableRow :project-member="projectMember"></ProjectMemberTableRow>
</template>
</div>
</div>

View File

@@ -8,9 +8,7 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Billable Rate
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>

View File

@@ -31,9 +31,7 @@ function editProjectMember() {
const { members } = storeToRefs(useMembersStore());
const member = computed(() => {
return members.value.find(
(member) => member.id === props.projectMember.member_id
);
return members.value.find((member) => member.id === props.projectMember.member_id);
});
const showEditModal = ref(false);
</script>

View File

@@ -5,10 +5,7 @@ import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';
import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue';
import type {
CreateReportBody,
CreateReportBodyProperties,
} from '@/packages/api/src';
import type { CreateReportBody, CreateReportBodyProperties } from '@/packages/api/src';
import { useMutation } from '@tanstack/vue-query';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
@@ -80,10 +77,7 @@ async function submit() {
<div class="items-center space-y-4 w-full">
<div class="w-full">
<InputLabel for="name" value="Name" />
<TextInput
id="name"
v-model="report.name"
class="mt-1.5 w-full"></TextInput>
<TextInput id="name" v-model="report.name" class="mt-1.5 w-full"></TextInput>
</div>
<div>
<InputLabel for="description" value="Description" />
@@ -95,19 +89,13 @@ async function submit() {
<InputLabel value="Visibility" />
<div class="flex items-center space-x-12">
<div class="flex items-center space-x-3 px-2 py-3">
<Checkbox
id="is_public"
v-model:checked="report.is_public"></Checkbox>
<Checkbox id="is_public" v-model:checked="report.is_public"></Checkbox>
<InputLabel for="is_public" value="Public" />
</div>
<div
v-if="report.is_public"
class="flex items-center space-x-4">
<div v-if="report.is_public" class="flex items-center space-x-4">
<div>
<InputLabel for="public_until" value="Expires at" />
<div class="text-text-tertiary font-medium">
(optional)
</div>
<div class="text-text-tertiary font-medium">(optional)</div>
</div>
<DatePicker id="public_until"></DatePicker>
</div>

View File

@@ -94,10 +94,7 @@ async function submit() {
<div class="items-center space-y-4 w-full">
<div class="w-full">
<InputLabel for="name" value="Name" />
<TextInput
id="name"
v-model="report.name"
class="mt-1.5 w-full"></TextInput>
<TextInput id="name" v-model="report.name" class="mt-1.5 w-full"></TextInput>
</div>
<div>
<InputLabel for="description" value="Description" />
@@ -109,14 +106,10 @@ async function submit() {
<InputLabel value="Visibility" />
<div class="flex items-center space-x-12">
<div class="flex items-center space-x-2 px-2 py-3">
<Checkbox
id="is_public"
v-model:checked="report.is_public"></Checkbox>
<Checkbox id="is_public" v-model:checked="report.is_public"></Checkbox>
<InputLabel for="is_public" value="Public" />
</div>
<div
v-if="report.is_public"
class="flex items-center space-x-4">
<div v-if="report.is_public" class="flex items-center space-x-4">
<InputLabel for="public_until" value="Expires at" />
<DatePicker id="public_until"></DatePicker>
</div>

View File

@@ -31,13 +31,9 @@ function onSaveReportClick() {
v-model:show="showCreateReportModal"
:properties="reportProperties"></ReportCreateModal>
<UpgradeModal v-model:show="showPremiumModal">
<strong>Sharable Reports</strong> is only available in solidtime
Professional.
<strong>Sharable Reports</strong> is only available in solidtime Professional.
</UpgradeModal>
<SecondaryButton
v-if="canCreateReports()"
:icon="SaveIcon"
@click="onSaveReportClick"
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick"
>Save Report</SecondaryButton
>
</template>

View File

@@ -21,25 +21,15 @@ const gridTemplate = computed(() => {
<template>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div
data-testid="report_table"
class="grid min-w-full"
:style="gridTemplate">
<div data-testid="report_table" class="grid min-w-full" :style="gridTemplate">
<ReportTableHeading></ReportTableHeading>
<div
v-if="reports.length === 0"
class="col-span-5 py-24 text-center">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">
No shared reports found
</h3>
<div v-if="reports.length === 0" class="col-span-5 py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">No shared reports found</h3>
<p v-if="canCreateProjects()" class="pb-5">
Go to the overview to create a report
</p>
<SecondaryButton
:icon="PlusIcon"
@click="router.visit(route('reporting'))"
<SecondaryButton :icon="PlusIcon" @click="router.visit(route('reporting'))"
>Go to overview
</SecondaryButton>
</div>

View File

@@ -8,15 +8,9 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Description
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Visibility
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Public URL
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Description</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Visibility</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Public URL</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -57,9 +57,7 @@ async function deleteReport() {
</script>
<template>
<ReportEditModal
v-model:show="showEditReportModal"
:original-report="report"></ReportEditModal>
<ReportEditModal v-model:show="showEditReportModal" :original-report="report"></ReportEditModal>
<TableRow>
<div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
@@ -75,14 +73,9 @@ async function deleteReport() {
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ report.is_public ? 'Public' : 'Private' }}
</div>
<div
class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<div
v-if="report.shareable_link"
class="space-x-2 flex items-center">
<SecondaryButton
v-if="isSupported"
@click="copy(report.shareable_link)">
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<div v-if="report.shareable_link" class="space-x-2 flex items-center">
<SecondaryButton v-if="isSupported" @click="copy(report.shareable_link)">
<span v-if="!copied">Copy URL</span>
<span v-else>Copied!</span>
</SecondaryButton>

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