Compare commits

..

41 Commits

Author SHA1 Message Date
Constantin Graf
f3ec660243 Add password check to users.destroy and organizations.destroy 2026-06-17 16:16:54 +02:00
Constantin Graf
37496a7547 Add option to enable email verification locally for testing 2026-06-17 15:20:36 +02:00
Constantin Graf
8c71a43191 Add comments to events about legacy events 2026-06-17 15:20:35 +02:00
Constantin Graf
f4107a9a49 Fixed middlwares after jetstream removal 2026-06-17 15:20:35 +02:00
Gregor Vostrak
4afcb24dc9 add comprehensive 2 factor authentication e2e tests 2026-06-17 15:20:35 +02:00
Gregor Vostrak
f9271664f0 add back destroy other browser sessions endpoint (jetstream migration) 2026-06-17 15:20:35 +02:00
Gregor Vostrak
5dcd8197dc update e2e test setup to use user settings api endpoint 2026-06-17 15:20:35 +02:00
Gregor Vostrak
e8c8edb4d5 change redirects and references to new organization routes 2026-06-17 15:20:35 +02:00
Gregor Vostrak
96ecc5335a add currency to organization update endpoint 2026-06-17 15:20:35 +02:00
Gregor Vostrak
c8024de452 replace hardcoded inertia props with organization delete/update perms 2026-06-17 15:20:35 +02:00
Gregor Vostrak
eef2cdaec5 remove unused inertia organization page props 2026-06-17 15:20:35 +02:00
Gregor Vostrak
ed037d40a1 fix inertia backend role data structure after jetstream migration 2026-06-17 15:20:35 +02:00
Gregor Vostrak
6c0041d249 call api for organization create/update/delete and switch 2026-06-17 15:20:35 +02:00
Constantin Graf
e8f632f330 Fixed current organization after normal registration 2026-06-17 15:20:35 +02:00
Constantin Graf
a865469d57 Added tests 2026-06-17 15:20:35 +02:00
Gregor Vostrak
b18b4ef2ce add switch current organization endpoint 2026-06-17 15:20:35 +02:00
Constantin Graf
cb42daecbf Removed Laravel Jetstream 2026-06-17 15:20:35 +02:00
Gregor Vostrak
42f9efd570 remove jetstream inertia properties; remove unused ApiTokenManager; 2026-06-17 15:20:35 +02:00
Gregor Vostrak
4349b0dfb1 move banners on login and register cards into the cards 2026-06-17 15:20:35 +02:00
Gregor Vostrak
dba214f424 add pending email cancel button 2026-06-17 15:20:35 +02:00
Constantin Graf
acb1a07ca0 Replaces all Jetstream model trait functions and relations 2026-06-17 15:20:35 +02:00
Constantin Graf
1f4a957258 Add reset pending email endpoint to user controller 2026-06-17 15:20:35 +02:00
Gregor Vostrak
10a66fa065 move user delete to api endpoint 2026-06-17 15:20:35 +02:00
Gregor Vostrak
b2a5b7a8c1 use api routes for profile information updates 2026-06-17 15:20:35 +02:00
Gregor Vostrak
161cd258f3 show null billable rate as empty not as 0 to avoid confusion 2026-06-17 15:20:35 +02:00
Gregor Vostrak
d6ed3fb3fc fix e2e selectors to adapt to reka-ui change; 2026-06-17 15:20:35 +02:00
Gregor Vostrak
7b6cdf1d45 add pending email to UserResource and update openapi client 2026-06-17 15:20:35 +02:00
Gregor Vostrak
19e1edca31 update ui package dependencies; update lucide imports 2026-06-17 15:20:35 +02:00
Gregor Vostrak
0335c355cc add user endpoint tests for idempotence email update, unauthenticated
update and invalid email
2026-06-17 15:20:35 +02:00
Gregor Vostrak
d645588aed update npm dependencies 2026-06-17 15:20:35 +02:00
Gregor Vostrak
dc35afdae8 add profile page e2e tests 2026-06-17 15:20:35 +02:00
Gregor Vostrak
12b4633e0d update email address change info to use session based banners 2026-06-17 15:20:35 +02:00
Gregor Vostrak
002c8e66a0 add 1MB photo upload limit 2026-06-17 15:20:34 +02:00
Gregor Vostrak
15fe8beecc add photo delete logic to user update endpoint 2026-06-17 15:20:34 +02:00
Constantin Graf
15b5f4b586 Updated composer dependencies 2026-06-17 15:20:34 +02:00
Constantin Graf
390acf89e1 Add more tests 2026-06-17 15:20:34 +02:00
Constantin Graf
a54d914f13 Add migration to lower case the user emails 2026-06-17 15:20:34 +02:00
Constantin Graf
bde3ec7a75 Migrate permission away from Jetstream; Moved update user to REST API 2026-06-17 15:20:34 +02:00
Gregor Vostrak
8637437808 rephrase logged out user invite accept message to clarify that the
invite was accepted
2026-06-17 15:20:34 +02:00
Gregor Vostrak
0f6e0feeae add banners for invitation accept 2026-06-17 15:20:34 +02:00
Constantin Graf
08eba2d90b Updated invitation flow, Moved jetstream function to REST endpoints; Lower case email 2026-06-17 15:20:34 +02:00
60 changed files with 436 additions and 1743 deletions

View File

@@ -46,9 +46,9 @@ jobs:
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
id: previoustag id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v2" uses: "WyriHaximus/github-action-get-previous-tag@v1"
with: with:
pattern: "v*[0-9].*[0-9].*[0-9]" prefix: "v"
- name: "Get version" - name: "Get version"
id: release-version id: release-version
@@ -124,27 +124,27 @@ jobs:
- name: "Docker meta" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }} password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Build and push by digest" - name: "Build and push by digest"
id: build id: build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
@@ -163,7 +163,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}" touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest" - name: "Upload digest"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: digests-${{ env.PLATFORM_PAIR }} name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
@@ -177,25 +177,25 @@ jobs:
- build - build
steps: steps:
- name: "Download digests" - name: "Download digests"
uses: actions/download-artifact@v8 uses: actions/download-artifact@v6
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-* pattern: digests-*
merge-multiple: true merge-multiple: true
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }} password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Docker meta" - name: "Docker meta"
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}

View File

@@ -33,9 +33,9 @@ jobs:
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
id: previoustag id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v2" uses: "WyriHaximus/github-action-get-previous-tag@v1"
with: with:
pattern: "v*[0-9].*[0-9].*[0-9]" prefix: "v"
- name: "Get version" - name: "Get version"
id: version id: version
@@ -160,7 +160,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: rg.fr-par.scw.cloud/solidtime registry: rg.fr-par.scw.cloud/solidtime
username: nologin username: nologin
@@ -168,7 +168,7 @@ jobs:
- name: "Docker meta" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: rg.fr-par.scw.cloud/solidtime/solidtime images: rg.fr-par.scw.cloud/solidtime/solidtime
tags: | tags: |
@@ -179,13 +179,13 @@ jobs:
type=sha,format=long type=sha,format=long
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Build and push" - name: "Build and push"
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
build-args: | build-args: |

View File

@@ -47,9 +47,9 @@ jobs:
- name: "Get Previous tag (normal push)" - name: "Get Previous tag (normal push)"
id: previoustag id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }} if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v2" uses: "WyriHaximus/github-action-get-previous-tag@v1"
with: with:
pattern: "v*[0-9].*[0-9].*[0-9]" prefix: "v"
- name: "Get version" - name: "Get version"
id: release-version id: release-version
@@ -109,34 +109,34 @@ jobs:
- name: "Docker meta" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKERHUB_REPO }} ${{ env.DOCKERHUB_REPO }}
${{ env.GHCR_REPO }} ${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry" - name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up QEMU" - name: "Set up QEMU"
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Build and push by digest" - name: "Build and push by digest"
id: build id: build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
@@ -155,7 +155,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}" touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest" - name: "Upload digest"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: digests-${{ env.PLATFORM_PAIR }} name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
@@ -169,31 +169,31 @@ jobs:
- build - build
steps: steps:
- name: "Download digests" - name: "Download digests"
uses: actions/download-artifact@v8 uses: actions/download-artifact@v6
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-* pattern: digests-*
merge-multiple: true merge-multiple: true
- name: "Login to Docker Hub" - name: "Login to Docker Hub"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR" - name: "Login to GHCR"
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: "Docker meta" - name: "Docker meta"
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.DOCKERHUB_REPO }} ${{ env.DOCKERHUB_REPO }}

View File

@@ -52,7 +52,7 @@ jobs:
run: php artisan scramble:export --path=build/api-docs.json run: php artisan scramble:export --path=build/api-docs.json
- name: "Upload API docs to GitHub" - name: "Upload API docs to GitHub"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: api-docs.json name: api-docs.json
path: build/api-docs.json path: build/api-docs.json

View File

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

View File

@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v7.0.0 uses: codecov/codecov-action@v5.5.1
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -86,7 +86,7 @@ jobs:
MAILPIT_BASE_URL: 'http://localhost:8025' MAILPIT_BASE_URL: 'http://localhost:8025'
- name: "Upload blob report" - name: "Upload blob report"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: blob-report-${{ matrix.shardIndex }} name: blob-report-${{ matrix.shardIndex }}
@@ -102,7 +102,7 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'
@@ -110,7 +110,7 @@ jobs:
run: npm ci run: npm ci
- name: "Download blob reports" - name: "Download blob reports"
uses: actions/download-artifact@v8 uses: actions/download-artifact@v4
with: with:
path: all-blob-reports path: all-blob-reports
pattern: blob-report-* pattern: blob-report-*
@@ -120,7 +120,7 @@ jobs:
run: npx playwright merge-reports --reporter html ./all-blob-reports run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: "Upload merged HTML report" - name: "Upload merged HTML report"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: playwright-report name: playwright-report
path: playwright-report/ path: playwright-report/

View File

@@ -43,8 +43,7 @@ class ClientController extends Controller
$clientsQuery = Client::query() $clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc');
->orderBy('id');
if (! $canViewAllClients) { if (! $canViewAllClients) {
$clientsQuery->visibleByEmployee($user); $clientsQuery->visibleByEmployee($user);

View File

@@ -42,7 +42,6 @@ class InvitationController extends Controller
$invitations = $organization->organizationInvitations() $invitations = $organization->organizationInvitations()
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return InvitationCollection::make($invitations); return InvitationCollection::make($invitations);

View File

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

View File

@@ -62,7 +62,6 @@ class ProjectController extends Controller
$projects = $projectsQuery $projects = $projectsQuery
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;

View File

@@ -49,7 +49,6 @@ class ProjectMemberController extends Controller
$projectMembers = ProjectMember::query() $projectMembers = ProjectMember::query()
->whereBelongsTo($project, 'project') ->whereBelongsTo($project, 'project')
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return new ProjectMemberCollection($projectMembers); return new ProjectMemberCollection($projectMembers);

View File

@@ -47,7 +47,6 @@ class ReportController extends Controller
$reports = Report::query() $reports = Report::query()
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));

View File

@@ -42,7 +42,6 @@ class TagController extends Controller
$tags = Tag::query() $tags = Tag::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return new TagCollection($tags); return new TagCollection($tags);

View File

@@ -84,7 +84,6 @@ class TaskController extends Controller
$tasks = $query $tasks = $query
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return new TaskCollection($tasks); return new TaskCollection($tasks);

View File

@@ -194,8 +194,7 @@ class TimeEntryController extends Controller
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->select($select) ->select($select)
->orderBy('time_entries.start', 'desc') ->orderBy('start', 'desc');
->orderBy('time_entries.id');
$filter = new TimeEntryFilter($timeEntriesQuery); $filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start')); $filter->addStartFilter($request->input('start'));

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers; namespace App\Service\Import\Importers;
use Exception; use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use League\Csv\Exception as CsvException; use League\Csv\Exception as CsvException;
use League\Csv\Reader; use League\Csv\Reader;
@@ -25,7 +24,6 @@ class ClockifyProjectsImporter extends DefaultImporter
$header = $reader->getHeader(); $header = $reader->getHeader();
$this->validateHeader($header); $this->validateHeader($header);
$billableRateKey = $this->getBillableRateKey($header); $billableRateKey = $this->getBillableRateKey($header);
$tasksKey = $this->getTasksKey($header);
$records = $reader->getRecords(); $records = $reader->getRecords();
foreach ($records as $record) { foreach ($records as $record) {
$clientId = null; $clientId = null;
@@ -46,12 +44,11 @@ class ClockifyProjectsImporter extends DefaultImporter
'is_billable' => $record['Billability'] === 'Yes', 'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null, 'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
]); ]);
} }
if ($tasksKey !== null && $record[$tasksKey] !== '') { if ($record['Task'] !== '') {
$tasks = explode(', ', $record[$tasksKey]); $tasks = explode(', ', $record['Task']);
foreach ($tasks as $task) { foreach ($tasks as $task) {
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task, 'name' => $task,
@@ -84,33 +81,13 @@ class ClockifyProjectsImporter extends DefaultImporter
'Status', 'Status',
'Visibility', 'Visibility',
'Billability', 'Billability',
'Task',
]; ];
foreach ($requiredFields as $requiredField) { foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) { if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField); throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
} }
} }
// Clockify names the tasks column "Task", "Tasks" or "Activities" depending on the export; accept any.
if ($this->getTasksKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Tasks');
}
}
/**
* Clockify names the tasks column differently depending on the export
* version: "Task" (older), "Tasks" (newer) or "Activities".
*
* @param array<string> $header
*/
private function getTasksKey(array $header): ?string
{
foreach (['Tasks', 'Task', 'Activities'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
} }
/** /**

View File

@@ -54,7 +54,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$reader->setEscape(''); $reader->setEscape('');
$header = $reader->getHeader(); $header = $reader->getHeader();
$this->validateHeader($header); $this->validateHeader($header);
$taskKey = $this->getTaskKey($header);
$records = $reader->getRecords(); $records = $reader->getRecords();
foreach ($records as $record) { foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([ $userId = $this->userImportHelper->getKey([
@@ -97,9 +96,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]); ]);
} }
$taskId = null; $taskId = null;
if ($taskKey !== null && $record[$taskKey] !== '') { if ($record['Task'] !== '') {
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey], 'name' => $record['Task'],
'project_id' => $projectId, 'project_id' => $projectId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
]); ]);
@@ -117,12 +116,10 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Time entry description is too long'); throw new ImportException('Time entry description is too long');
} }
$timeEntry->description = $record['Description']; $timeEntry->description = $record['Description'];
if (isset($record['Billable'])) { if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
if (! in_array($record['Billable'], ['Yes', 'No'], true)) { throw new ImportException('Invalid billable value');
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['Billable'] === 'Yes';
} }
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']); $timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true; $timeEntry->is_imported = true;
@@ -217,10 +214,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'Project', 'Project',
'Client', 'Client',
'Description', 'Description',
'Task',
'User', 'User',
'Group', 'Group',
'Email', 'Email',
'Tags', 'Tags',
'Billable',
'Start Date', 'Start Date',
'Start Time', 'Start Time',
'End Date', 'End Date',
@@ -231,26 +230,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Invalid CSV header, missing field: '.$requiredField); throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
} }
} }
// Clockify names the task column "Task" or "Activity" depending on the export; accept either.
if ($this->getTaskKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Task');
}
}
/**
* Clockify names the task column "Task" or "Activity" depending on the export version.
*
* @param array<string> $header
*/
private function getTaskKey(array $header): ?string
{
foreach (['Task', 'Activity'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
} }
#[\Override] #[\Override]

View File

@@ -123,7 +123,6 @@ class TogglDataImporter extends DefaultImporter
} }
foreach ($projects as $project) { foreach ($projects as $project) {
$projectExternalId = $this->guardExternalIdentifier($project->id);
$clientId = null; $clientId = null;
if ($project->client_id !== null) { if ($project->client_id !== null) {
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id); $clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
@@ -147,16 +146,16 @@ class TogglDataImporter extends DefaultImporter
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null, 'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
], (string) $project->id); ], (string) $project->id);
if (! file_exists($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'))) { if (! file_exists($temporaryDirectory->path('projects_users/'.$project->id.'.json'))) {
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" missing in ZIP'); throw new ImportException('File "projects_users/'.$project->id.'.json" missing in ZIP');
} }
$projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json')); $projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$project->id.'.json'));
if ($projectMembersFileContent === false) { if ($projectMembersFileContent === false) {
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" can not be opened'); throw new ImportException('File "projects_users/'.$project->id.'.json" can not be opened');
} }
$projectMembers = json_decode($projectMembersFileContent); $projectMembers = json_decode($projectMembersFileContent);
if ($projectMembers === null) { if ($projectMembers === null) {
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" is empty'); throw new ImportException('File "projects_users/'.$project->id.'.json" is empty');
} }
foreach ($projectMembers as $projectMember) { foreach ($projectMembers as $projectMember) {
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id); $userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
@@ -171,7 +170,6 @@ class TogglDataImporter extends DefaultImporter
} }
$projectIds = $this->projectImportHelper->getExternalIds(); $projectIds = $this->projectImportHelper->getExternalIds();
foreach ($projectIds as $projectIdExternal) { foreach ($projectIds as $projectIdExternal) {
$projectIdExternal = $this->guardExternalIdentifier($projectIdExternal);
if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) { if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) {
continue; continue;
} }
@@ -211,30 +209,6 @@ class TogglDataImporter extends DefaultImporter
} }
} }
/**
* Ensure an externally-sourced identifier can be safely used inside a
* filesystem path. The identifiers originate from the untrusted uploaded
* ZIP, and Spatie's TemporaryDirectory::path() auto-creates any missing
* parent directory of the resolved path, so an unfiltered "../" sequence
* would escape the import sandbox and create/probe arbitrary paths on the
* host (CWE-22). Toggl identifiers are numeric, so restricting them to a
* conservative allow-list rejects traversal without affecting real data.
*
* @throws ImportException
*/
private function guardExternalIdentifier(mixed $id): string
{
if (! is_string($id) && ! is_int($id)) {
throw new ImportException('Invalid identifier in import data');
}
$id = (string) $id;
if (preg_match('/^[A-Za-z0-9_-]+$/', $id) !== 1) {
throw new ImportException('Invalid identifier in import data');
}
return $id;
}
#[Override] #[Override]
public function getName(): string public function getName(): string
{ {

View File

@@ -246,7 +246,7 @@ test('test that sorting clients by name and status works', async ({ page, ctx })
test('test that sorting clients by project count works', async ({ page, ctx }) => { test('test that sorting clients by project count works', async ({ page, ctx }) => {
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' }); const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
await createClientViaApi(ctx, { name: 'NoProjects Client' }); const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
// Create projects for the first client // Create projects for the first client
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id }); await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
@@ -374,119 +374,3 @@ test.describe('Employee Clients Restrictions', () => {
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 }); await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
}); });
}); });
// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────
test.describe('Clients Pagination', () => {
test.describe.configure({ timeout: 30000 });
test('test that client table paginates when there are more than 15 clients', async ({
page,
ctx,
}) => {
// Create 17 clients with zero-padded names so alphabetical sort is predictable.
// Page size is 15 → page 1 shows indices 0014, page 2 shows 1516.
const seed = Math.floor(Math.random() * 100000);
const prefix = `PaginationClient ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
// Default sort is name asc; first 15 clients (0014) on page 1.
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
// Client 15 should be on page 2, not visible on page 1.
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Exactly 15 data rows mounted on page 1.
await expect(page.getByRole('row')).toHaveCount(15);
// Navigation to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByText(prefix + '00')).not.toBeVisible();
// Page 2 contains the remaining 2 clients.
await expect(page.getByRole('row')).toHaveCount(2);
// Back to page 1 via Previous Page.
await page.getByRole('button', { name: 'Previous Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// First / Last page jumps.
await page.getByRole('button', { name: 'Last Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await page.getByRole('button', { name: 'First Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Direct page-number button navigation + selected state.
await page.getByRole('button', { name: 'Page 2' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
'aria-current',
'page'
);
});
test('test that client pagination is not shown when there are 15 or fewer clients', async ({
page,
ctx,
}) => {
await Promise.all(
Array.from({ length: 10 }, (_, i) =>
createClientViaApi(ctx, {
name: `FewClient ${Math.floor(Math.random() * 100000)} ${i}`,
})
)
);
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
await expect(page.getByTestId('client_table')).toBeVisible();
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
});
test('test that changing the sort resets client pagination to page 1', async ({
page,
ctx,
}) => {
const seed = Math.floor(Math.random() * 100000);
const prefix = `SortPagClient ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToClientsOverview(page);
await clearClientTableState(page);
await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
// Go to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Sort by name descending.
const table = page.getByTestId('client_table');
const nameHeader = table.getByText('Name').first();
await nameHeader.click();
// Pagination reset to page 1; desc order → 16, 15 visible, 00 on page 2.
await expect(page.getByText(prefix + '16')).toBeVisible();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByText(prefix + '00')).not.toBeVisible();
});
});

View File

@@ -117,43 +117,6 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx })
await expect(page.getByText(newProjectName)).toBeVisible(); await expect(page.getByText(newProjectName)).toBeVisible();
}); });
test('test that the client can be changed in the edit project modal', async ({ page, ctx }) => {
const projectName = 'Edit Client Project ' + Math.floor(1 + Math.random() * 100000);
const clientName = 'Assigned Client ' + Math.floor(1 + Math.random() * 100000);
await createProjectViaApi(ctx, { name: projectName });
const client = await createClientViaApi(ctx, { name: clientName });
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });
// Open the project's Edit modal.
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').first().click();
await expect(page.getByRole('dialog')).toBeVisible();
// Open the client dropdown (currently "No Client"), confirm it focuses, and pick the client.
await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click();
const clientSearch = page.getByPlaceholder('Search for a client...');
await expect(clientSearch).toBeFocused();
await clientSearch.fill(clientName);
await page.getByRole('option', { name: clientName }).click();
// The trigger updates to the chosen client.
await expect(page.getByRole('dialog').getByRole('button', { name: clientName })).toBeVisible();
// Saving persists the client assignment.
await Promise.all([
page.getByRole('button', { name: 'Update Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.client_id === client.id
),
]);
});
test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => { test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000); const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000); const newBillableRate = Math.round(Math.random() * 10000);
@@ -1091,119 +1054,3 @@ test.describe('Employee Billable Rate Visibility', () => {
await expect(projectRow).toContainText('200'); await expect(projectRow).toContainText('200');
}); });
}); });
// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────
test.describe('Projects Pagination', () => {
test.describe.configure({ timeout: 30000 });
test('test that project table paginates when there are more than 15 projects', async ({
page,
ctx,
}) => {
// Create 17 projects with zero-padded names so alphabetical sort is predictable.
// Page size is 15 → page 1 shows indices 0014, page 2 shows 1516.
const seed = Math.floor(Math.random() * 100000);
const prefix = `PaginationProj ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Default sort is name asc; first 15 projects (0014) should be on page 1.
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
// Project 15 should be on page 2, not visible on page 1.
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Exactly 15 data rows should be mounted on page 1.
await expect(page.getByRole('row')).toHaveCount(15);
// Go to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByText(prefix + '00')).not.toBeVisible();
// Page 2 contains the remaining 2 projects (15, 16).
await expect(page.getByRole('row')).toHaveCount(2);
// Return to page 1 via Previous Page.
await page.getByRole('button', { name: 'Previous Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Jump to last page then back to first page.
await page.getByRole('button', { name: 'Last Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await page.getByRole('button', { name: 'First Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Direct page-number button navigation.
await page.getByRole('button', { name: 'Page 2' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Page 2 button should be marked as selected.
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
'aria-current',
'page'
);
});
test('test that project pagination is not shown when there are 15 or fewer projects', async ({
page,
ctx,
}) => {
await Promise.all(
Array.from({ length: 10 }, (_, i) =>
createProjectViaApi(ctx, {
name: `FewProj ${Math.floor(Math.random() * 100000)} ${i}`,
})
)
);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible();
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
});
test('test that changing the sort resets pagination to page 1', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const prefix = `SortPagProj ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
// Go to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Sort by name descending: header click toggles asc → desc.
const nameHeader = page
.locator('[data-testid="project_table"] .select-none', { hasText: 'Name' })
.first();
await nameHeader.click();
// After sorting, pagination resets to page 1; desc order → 16, 15, ... 02 visible.
await expect(page.getByText(prefix + '16')).toBeVisible();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Index 00 should now be on page 2 (last in desc order).
await expect(page.getByText(prefix + '00')).not.toBeVisible();
});
});

View File

@@ -717,108 +717,3 @@ test('test that keyboard navigation works in multiselect dropdown', async ({ pag
page.getByRole('button', { name: 'Projects' }).first().getByText('1') page.getByRole('button', { name: 'Projects' }).first().getByText('1')
).toBeVisible(); ).toBeVisible();
}); });
// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────
test.describe('Reporting Detailed Pagination', () => {
test('test that detailed reporting paginates when there are more than 15 time entries', async ({
page,
ctx,
}) => {
// The detailed report paginates server-side with a page limit of 15.
// Create 17 time entries on a single project so we get exactly 2 pages.
const seed = Math.floor(Math.random() * 100000);
const projectName = `ReportPagProj ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
const descriptions = Array.from(
{ length: 17 },
(_, i) => `ReportPagEntry ${String(i).padStart(2, '0')} ${seed}`
);
await Promise.all(
descriptions.map((description) =>
createTimeEntryViaApi(ctx, {
description,
duration: '30min',
projectId: project.id,
})
)
);
await goToReportingDetailed(page);
await expect(page.getByText(descriptions[0]!).first()).toBeVisible({
timeout: 10000,
});
// Pagination nav should be rendered.
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
// Collect which descriptions are currently visible on page 1.
const visiblePage1 = new Set<string>();
for (const description of descriptions) {
if ((await page.getByText(description).count()) > 0) {
visiblePage1.add(description);
}
}
// The page limit is 15 → exactly 15 entries visible on page 1.
expect(visiblePage1.size).toBe(15);
// Go to page 2 and wait for the server fetch.
await Promise.all([
page.getByRole('button', { name: 'Next Page' }).click(),
waitForDetailedReportingUpdate(page),
]);
const visiblePage2 = new Set<string>();
for (const description of descriptions) {
if ((await page.getByText(description).count()) > 0) {
visiblePage2.add(description);
}
}
// Page 2 should hold the remaining 2 entries, disjoint from page 1.
expect(visiblePage2.size).toBe(2);
for (const description of visiblePage2) {
expect(visiblePage1.has(description)).toBe(false);
}
// Across both pages, all 17 entries should have been visible.
expect(visiblePage1.size + visiblePage2.size).toBe(17);
// Page 2 button is selected.
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
'aria-current',
'page'
);
// Previous page returns to page 1.
await Promise.all([
page.getByRole('button', { name: 'Previous Page' }).click(),
waitForDetailedReportingUpdate(page),
]);
expect((await page.getByText(descriptions[0]!).count()) > 0).toBe(true);
});
test('test that reporting pagination is not shown when there are 15 or fewer time entries', async ({
page,
ctx,
}) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `FewEntriesProj ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
await Promise.all(
Array.from({ length: 5 }, (_, i) =>
createTimeEntryViaApi(ctx, {
description: `FewEntries ${i} ${seed}`,
duration: '30min',
projectId: project.id,
})
)
);
await goToReportingDetailed(page);
await expect(page.getByText(`FewEntries 0 ${seed}`).first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
});
});

View File

@@ -96,37 +96,6 @@ test('test that project multiselect search filters the option list', async ({ pa
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
}); });
test('test that the project filter virtualizes a long list (renders only a window)', async ({
page,
ctx,
}) => {
// Create many projects so the dropdown must virtualize rather than render all of them.
const projectNames = Array.from(
{ length: 80 },
(_, i) => `VirtProj ${String(i).padStart(2, '0')}`
);
await Promise.all(projectNames.map((name) => createProjectViaApi(ctx, { name })));
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await page.getByRole('button', { name: 'Projects' }).first().click();
// Only a small window of options is mounted, far fewer than the 80+ projects that exist.
await expect(page.getByRole('option').first()).toBeVisible();
const renderedCount = await page.getByRole('option').count();
expect(renderedCount).toBeGreaterThan(0);
expect(renderedCount).toBeLessThan(60);
// Virtualization must not drop options: searching narrows the list to the one deep match.
// Wait for the filtered count to settle to 1 before asserting — checking the option while
// the virtualizer is still re-rendering can transiently match a stale row (Firefox CI flake).
await page.getByPlaceholder('Search for a Project...').fill('VirtProj 79');
await expect(page.getByRole('option')).toHaveCount(1);
await expect(page.getByRole('option')).toContainText('VirtProj 79');
await page.keyboard.press('Escape');
});
test('test that selecting multiple projects shows correct badge count', async ({ page, ctx }) => { test('test that selecting multiple projects shows correct badge count', async ({ page, ctx }) => {
const project1Name = 'MultiProj1 ' + Math.floor(Math.random() * 10000); const project1Name = 'MultiProj1 ' + Math.floor(Math.random() * 10000);
const project2Name = 'MultiProj2 ' + Math.floor(Math.random() * 10000); const project2Name = 'MultiProj2 ' + Math.floor(Math.random() * 10000);

View File

@@ -152,49 +152,6 @@ test('test that editing a task name works', async ({ page, ctx }) => {
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName); await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
}); });
test('test that the project can be searched and changed in the create task modal', async ({
page,
ctx,
}) => {
const sourceProject = 'Source Project ' + Math.floor(1 + Math.random() * 100000);
const targetProject = 'Target Project ' + Math.floor(1 + Math.random() * 100000);
await createProjectViaApi(ctx, { name: sourceProject });
const target = await createProjectViaApi(ctx, { name: targetProject });
await goToProjectsOverview(page);
await page.getByText(sourceProject).first().click();
await page.getByRole('button', { name: 'Create Task' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The project dropdown is pre-filled with the source project; open it.
await page.getByRole('dialog').getByRole('button', { name: sourceProject }).click();
// Opening the dropdown focuses the search input; searching narrows it to the target project.
const projectSearch = page.getByPlaceholder('Search for a project...');
await expect(projectSearch).toBeFocused();
await projectSearch.fill('Target Project');
await page.getByRole('option', { name: targetProject }).click();
// Selecting closes the dropdown and updates the trigger to the chosen project.
await expect(
page.getByRole('dialog').getByRole('button', { name: targetProject })
).toBeVisible();
// The new selection is what gets used when the task is created.
const taskName = 'Switched Task ' + Math.floor(1 + Math.random() * 100000);
await page.getByPlaceholder('Task Name').fill(taskName);
await Promise.all([
page.getByRole('dialog').getByRole('button', { name: 'Create Task' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/tasks') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.project_id === target.id
),
]);
});
test('test that creating a project with an existing client works', async ({ page, ctx }) => { test('test that creating a project with an existing client works', async ({ page, ctx }) => {
const clientName = 'Existing Client ' + Math.floor(1 + Math.random() * 10000); const clientName = 'Existing Client ' + Math.floor(1 + Math.random() * 10000);
const projectName = 'Project With Client ' + Math.floor(1 + Math.random() * 10000); const projectName = 'Project With Client ' + Math.floor(1 + Math.random() * 10000);

View File

@@ -9,14 +9,7 @@ import {
} from './utils/currentTimeEntry'; } from './utils/currentTimeEntry';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { newTagResponse } from './utils/tags'; import { newTagResponse } from './utils/tags';
import { import { createProjectViaApi, updateOrganizationCurrencyViaWeb } from './utils/api';
createProjectViaApi,
createTaskViaApi,
createClientViaApi,
archiveProjectViaApi,
markTaskDoneViaApi,
updateOrganizationCurrencyViaWeb,
} from './utils/api';
// Date picker button name patterns for different date formats // Date picker button name patterns for different date formats
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/; const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
@@ -448,236 +441,3 @@ test('test that adding a project and tag before starting timer works', async ({
]); ]);
await assertThatTimerIsStopped(page); await assertThatTimerIsStopped(page);
}); });
// ──────────────────────────────────────────────────
// Project / Task selector dropdown
// Regression coverage for the virtualized + lookup-map refactor of
// TimeTrackerProjectTaskDropdown. The dropdown only (re)filters on open and on search
// change, so we wait for the dashboard prefetch to settle before opening it.
// ──────────────────────────────────────────────────
test.describe('Project Task Dropdown', () => {
test.describe.configure({ timeout: 60_000 });
test('test that a project far down a long list can be found via search and selected', async ({
page,
ctx,
}) => {
// Seed enough projects that the target sits outside the initially rendered window.
const seed = Math.floor(Math.random() * 100000);
const prefix = `VirtProj ${seed} `;
await Promise.all(
Array.from({ length: 30 }, (_, i) =>
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
const target = prefix + '27';
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByTestId('client_dropdown_search').fill(target);
await page.getByRole('option').filter({ hasText: target }).click();
// The trigger now reflects the selected project.
await expect(page.getByRole('button', { name: target })).toBeVisible();
});
test('test that expanding a project and selecting a task works', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `ExpandProj ${seed}`;
const taskName = `ExpandTask ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
const projectOption = page.getByRole('option').filter({ hasText: projectName });
await expect(projectOption).toBeVisible();
// Expand the project's tasks via the "N Tasks" button, then select the task.
await projectOption.getByText(/Tasks/).click();
await page.getByText(taskName, { exact: true }).click();
// Scoped to the trigger button: the closing dropdown also contains the name while animating out.
await expect(
page.getByRole('button', { name: `${projectName} ${taskName}` })
).toBeVisible();
});
test('test that keyboard navigation selects a project', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `KbProj ${seed}`;
await createProjectViaApi(ctx, { name: projectName });
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
const search = page.getByTestId('client_dropdown_search');
// On open the search is focused and "No Project" is highlighted.
await expect(search).toBeFocused();
// Arrow down from "No Project" to the project, then select it with Enter.
await search.press('ArrowDown');
await search.press('Enter');
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
});
test('test that search filters the dropdown by project and client name', async ({
page,
ctx,
}) => {
const seed = Math.floor(Math.random() * 100000);
const clientName = `FilterClient ${seed}`;
const alphaProject = `AlphaProj ${seed}`;
const betaProject = `BetaProj ${seed}`;
const client = await createClientViaApi(ctx, { name: clientName });
await createProjectViaApi(ctx, { name: alphaProject, client_id: client.id });
await createProjectViaApi(ctx, { name: betaProject });
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
const search = page.getByTestId('client_dropdown_search');
const alphaOption = page.getByRole('option').filter({ hasText: alphaProject });
const betaOption = page.getByRole('option').filter({ hasText: betaProject });
// Both projects are visible before filtering.
await expect(alphaOption).toBeVisible();
await expect(betaOption).toBeVisible();
// Project-name search shows only the matching project.
await search.fill('AlphaProj');
await expect(alphaOption).toBeVisible();
await expect(betaOption).not.toBeVisible();
// Client-name search shows the project that belongs to that client.
await search.fill(clientName);
await expect(alphaOption).toBeVisible();
await expect(betaOption).not.toBeVisible();
});
test("test that searching by task name surfaces the task's project", async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const projectWithTask = `TaskSearchProj ${seed}`;
const taskName = `Findable Task ${seed}`;
const unrelatedProject = `Unrelated Proj ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectWithTask });
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
await createProjectViaApi(ctx, { name: unrelatedProject });
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByTestId('client_dropdown_search').fill(taskName);
// The project owning the task is shown (with the task), the unrelated project is not.
await expect(page.getByRole('option').filter({ hasText: projectWithTask })).toBeVisible();
await expect(page.getByText(taskName, { exact: true })).toBeVisible();
await expect(
page.getByRole('option').filter({ hasText: unrelatedProject })
).not.toBeVisible();
});
test('test that archived projects are hidden from the dropdown', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const activeProject = `ActiveProj ${seed}`;
const archivedProject = `ArchivedProj ${seed}`;
await createProjectViaApi(ctx, { name: activeProject });
const toArchive = await createProjectViaApi(ctx, { name: archivedProject });
await archiveProjectViaApi(ctx, toArchive);
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
// Wait for the list to load, then confirm the archived project is filtered out.
await expect(page.getByRole('option').filter({ hasText: activeProject })).toBeVisible();
await expect(
page.getByRole('option').filter({ hasText: archivedProject })
).not.toBeVisible();
});
test('test that done tasks are hidden when expanding a project', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `DoneTaskProj ${seed}`;
const activeTask = `Active Task ${seed}`;
const doneTask = `Done Task ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
await createTaskViaApi(ctx, { name: activeTask, project_id: project.id });
const taskToFinish = await createTaskViaApi(ctx, {
name: doneTask,
project_id: project.id,
});
await markTaskDoneViaApi(ctx, taskToFinish);
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
const projectOption = page.getByRole('option').filter({ hasText: projectName });
await expect(projectOption).toBeVisible();
await projectOption.getByText(/Tasks/).click();
// Only the active task shows; the done task is filtered out.
await expect(page.getByText(activeTask, { exact: true })).toBeVisible();
await expect(page.getByText(doneTask, { exact: true })).not.toBeVisible();
});
test('test that keyboard navigation can expand a project and select a task', async ({
page,
ctx,
}) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `KbTaskProj ${seed}`;
const taskName = `KbTask ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
const search = page.getByTestId('client_dropdown_search');
await expect(search).toBeFocused();
// No Project is highlighted on open: down to the project, right to expand its tasks,
// down to the task, Enter to select it.
await search.press('ArrowDown');
await search.press('ArrowRight');
await search.press('ArrowDown');
await search.press('Enter');
// Scoped to the trigger button: the closing dropdown also contains the name while animating out.
await expect(
page.getByRole('button', { name: `${projectName} ${taskName}` })
).toBeVisible();
});
test('test that pressing space selects the highlighted project', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `SpaceProj ${seed}`;
await createProjectViaApi(ctx, { name: projectName });
await goToDashboard(page);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'No Project' }).click();
const search = page.getByTestId('client_dropdown_search');
await expect(search).toBeFocused();
// Arrow down from "No Project" to the project, then the space shortcut selects it.
await search.press('ArrowDown');
await search.press('Space');
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
});
});

View File

@@ -373,20 +373,6 @@ export async function createTaskViaApi(
return body.data as { id: string; name: string; project_id: string }; return body.data as { id: string; name: string; project_id: string };
} }
export async function markTaskDoneViaApi(ctx: TestContext, task: { id: string; name: string }) {
const response = await ctx.request.put(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks/${task.id}`,
{
data: {
name: task.name,
is_done: true,
},
}
);
expect(response.status()).toBe(200);
return (await response.json()).data;
}
export async function createTagViaApi(ctx: TestContext, data: { name: string }) { export async function createTagViaApi(ctx: TestContext, data: { name: string }) {
const response = await ctx.request.post( const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tags`, `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tags`,

View File

@@ -5,8 +5,7 @@ declare(strict_types=1);
return [ return [
'clockify_time_entries' => [ 'clockify_time_entries' => [
'name' => 'Clockify Time Entries', 'name' => 'Clockify Time Entries',
'description' => '<strong>Important:</strong> If you also want to import your projects use the "Clockify Projects" importer before this one, since that export contains more details such as billable status, billable rates and estimated time.<br><br>'. 'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
'1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
'2. In the same preferences page change the language of Clockfiy to English.<br>'. '2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'. '3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '. '4. Now select the date range that you want to export in the right top. '.
@@ -62,8 +61,7 @@ return [
], ],
'harvest_time_entries' => [ 'harvest_time_entries' => [
'name' => 'Harvest Time Entries', 'name' => 'Harvest Time Entries',
'description' => '<strong>Important:</strong> If you also want to import your projects use the "Harvest Projects" importer before this one, since that export contains more details such as billable status and estimated time.<br><br>'. 'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
'1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
'<br>3. Now click on "Export all time" '. '<br>3. Now click on "Export all time" '.
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.', '<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
], ],

23
package-lock.json generated
View File

@@ -20,7 +20,6 @@
"@tanstack/vue-query": "^5.100.10", "@tanstack/vue-query": "^5.100.10",
"@tanstack/vue-query-devtools": "^5.91.0", "@tanstack/vue-query-devtools": "^5.91.0",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@tanstack/vue-virtual": "^3.13.24",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",
@@ -5465,6 +5464,17 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-vue-next": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
"deprecated": "Package deprecated. Please use @lucide/vue instead.",
"license": "ISC",
"peer": true,
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -8399,7 +8409,7 @@
"version": "0.0.6", "version": "0.0.6",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"vite-plugin-dts": "^4.5.4" "vite-plugin-dts": "^4.0.3"
}, },
"peerDependencies": { "peerDependencies": {
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",
@@ -8414,17 +8424,15 @@
"version": "0.0.21", "version": "0.0.21",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"@types/chroma-js": "^3.1.2", "@types/chroma-js": "^3.1.0",
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",
"vite-plugin-dts": "^4.5.4", "vite-plugin-dts": "^4.0.3",
"zod": "^3.25.76" "zod": "^3.23.8"
}, },
"peerDependencies": { "peerDependencies": {
"@floating-ui/vue": "^1.1.4", "@floating-ui/vue": "^1.1.4",
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@internationalized/date": "^3.0.0", "@internationalized/date": "^3.0.0",
"@lucide/vue": ">=1.0.0",
"@tanstack/vue-virtual": "^3.13.24",
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0", "@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
"@vueuse/core": "^12.5.0 || ^14.0.0", "@vueuse/core": "^12.5.0 || ^14.0.0",
"@vueuse/integrations": "^12.5.0 || ^14.0.0", "@vueuse/integrations": "^12.5.0 || ^14.0.0",
@@ -8433,6 +8441,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"focus-trap": "^7.0.0 || ^8.0.0", "focus-trap": "^7.0.0 || ^8.0.0",
"lucide-vue-next": ">=0.453.0",
"parse-duration": "^2.0.1", "parse-duration": "^2.0.1",
"radix-vue": "^1.9.0", "radix-vue": "^1.9.0",
"reka-ui": "^2.2.0", "reka-ui": "^2.2.0",

View File

@@ -64,7 +64,6 @@
"@tanstack/vue-query": "^5.100.10", "@tanstack/vue-query": "^5.100.10",
"@tanstack/vue-query-devtools": "^5.91.0", "@tanstack/vue-query-devtools": "^5.91.0",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@tanstack/vue-virtual": "^3.13.24",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",

View File

@@ -2,12 +2,11 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { UserCircleIcon } from '@heroicons/vue/24/solid'; import { UserCircleIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid'; import { PlusIcon } from '@heroicons/vue/16/solid';
import { type Component, computed, ref, watch } from 'vue'; import { type Component, computed, ref } from 'vue';
import { type Client } from '@/packages/api/src'; import { type Client } from '@/packages/api/src';
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue'; import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'; import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue'; import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
import Pagination from '@/Components/Common/Pagination.vue';
import { canCreateClients } from '@/utils/permissions'; import { canCreateClients } from '@/utils/permissions';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { import {
@@ -44,14 +43,11 @@ const projectCountMap = computed(() => {
return map; return map;
}); });
// Name is always the secondary sort so rows with equal values render
// alphabetically instead of in API (created_at) order.
const sorting = computed<SortingState>(() => [ const sorting = computed<SortingState>(() => [
{ {
id: props.sortColumn, id: props.sortColumn,
desc: props.sortDirection === 'desc', desc: props.sortDirection === 'desc',
}, },
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
]); ]);
const columns = computed(() => [ const columns = computed(() => [
@@ -104,19 +100,6 @@ const table = useVueTable({
const sortedClients = computed(() => { const sortedClients = computed(() => {
return table.getRowModel().rows.map((row) => row.original); return table.getRowModel().rows.map((row) => row.original);
}); });
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
const PAGE_SIZE = 15;
const currentPage = ref(1);
watch([() => props.sortColumn, () => props.sortDirection, () => props.clients], () => {
currentPage.value = 1;
});
const paginatedClients = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE;
return sortedClients.value.slice(start, start + PAGE_SIZE);
});
</script> </script>
<template> <template>
@@ -143,14 +126,10 @@ const paginatedClients = computed(() => {
>Create your First Client >Create your First Client
</SecondaryButton> </SecondaryButton>
</div> </div>
<template v-for="client in paginatedClients" :key="client.id"> <template v-for="client in sortedClients" :key="client.id">
<ClientTableRow :client="client"></ClientTableRow> <ClientTableRow :client="client"></ClientTableRow>
</template> </template>
</div> </div>
</div> </div>
</div> </div>
<Pagination
v-model:page="currentPage"
:total="sortedClients.length"
:items-per-page="PAGE_SIZE"></Pagination>
</template> </template>

View File

@@ -89,17 +89,11 @@ function selectMember(member: Member) {
</Button> </Button>
</template> </template>
<template #content> <template #content>
<!-- kept open so the list stays visible during the popover close animation -->
<ComboboxRoot <ComboboxRoot
v-model:search-term="searchValue" v-model:search-term="searchValue"
:open="true" v-model:open="open"
class="relative" class="relative"
:filter-function="(val: string[]) => val" :filter-function="(val: string[]) => val">
@update:open="
(value: boolean) => {
if (!value) open = false;
}
">
<ComboboxAnchor> <ComboboxAnchor>
<ComboboxInput <ComboboxInput
ref="searchInput" ref="searchInput"

View File

@@ -1,104 +0,0 @@
<script setup lang="ts">
import {
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot,
} from 'radix-vue';
import {
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
EllipsisHorizontalIcon,
} from '@heroicons/vue/20/solid';
import { buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { computed, watch } from 'vue';
const page = defineModel<number>('page', { default: 1 });
const props = withDefaults(
defineProps<{
total: number;
itemsPerPage?: number;
siblingCount?: number;
showEdges?: boolean;
}>(),
{
itemsPerPage: 15,
siblingCount: 1,
showEdges: true,
}
);
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.itemsPerPage)));
watch(page, (value) => {
if (value > pageCount.value) {
page.value = pageCount.value;
}
});
watch(pageCount, (value) => {
if (page.value > value) {
page.value = value;
}
});
// The shared buttonVariants ghost/outline hover is `bg-white/5`, which is invisible in light
// mode. Override it with a theme-aware hover that shows in both light and dark mode.
const hoverClass = 'hover:bg-black/5 dark:hover:bg-white/5';
const navButtonClass = cn(buttonVariants({ variant: 'ghost', size: 'icon' }), hoverClass);
function pageButtonClass(isActive: boolean): string {
return cn(
buttonVariants({ variant: isActive ? 'outline' : 'ghost', size: 'icon' }),
hoverClass
);
}
</script>
<template>
<PaginationRoot
v-if="pageCount > 1"
v-model:page="page"
:total="props.total"
:items-per-page="props.itemsPerPage"
:sibling-count="props.siblingCount"
:show-edges="props.showEdges"
class="mx-auto flex w-full justify-center py-8">
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<PaginationFirst :class="navButtonClass">
<ChevronDoubleLeftIcon class="size-4" />
</PaginationFirst>
<PaginationPrev :class="navButtonClass">
<ChevronLeftIcon class="size-4" />
</PaginationPrev>
<template v-for="(item, index) in items" :key="index">
<PaginationListItem
v-if="item.type === 'page'"
:value="item.value"
:class="pageButtonClass(item.value === page)">
{{ item.value }}
</PaginationListItem>
<PaginationEllipsis
v-else
:index="index"
class="flex size-9 items-center justify-center text-text-tertiary">
<EllipsisHorizontalIcon class="size-4" />
</PaginationEllipsis>
</template>
<PaginationNext :class="navButtonClass">
<ChevronRightIcon class="size-4" />
</PaginationNext>
<PaginationLast :class="navButtonClass">
<ChevronDoubleRightIcon class="size-4" />
</PaginationLast>
</PaginationList>
</PaginationRoot>
</template>

View File

@@ -9,10 +9,10 @@ import {
ComboboxItem, ComboboxItem,
ComboboxRoot, ComboboxRoot,
ComboboxViewport, ComboboxViewport,
ComboboxVirtualizer, } from 'radix-vue';
} from 'reka-ui';
import { Check, Plus } from '@lucide/vue'; import { Check, Plus } from '@lucide/vue';
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src'; import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue'; import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
@@ -37,16 +37,7 @@ const emit = defineEmits(['update:modelValue', 'changed']);
const activeClients = computed(() => clients.value.filter((c) => !c.is_archived)); const activeClients = computed(() => clients.value.filter((c) => !c.is_archived));
// Pinned on open so rows don't re-sort while interacting; the project list itself stays reactive. const sortedProjects = ref<Project[]>([]);
const pinnedProjectId = ref<string | null>(null);
const sortedProjects = computed(() => {
return [...projects.value].sort((a, b) => {
const aPinned = pinnedProjectId.value === a.id ? 0 : 1;
const bPinned = pinnedProjectId.value === b.id ? 0 : 1;
return aPinned - bPinned;
});
});
const shownProjects = computed(() => { const shownProjects = computed(() => {
return sortedProjects.value.filter((project) => { return sortedProjects.value.filter((project) => {
@@ -74,7 +65,9 @@ watch(open, (isOpen) => {
searchInput.value?.$el?.focus(); searchInput.value?.$el?.focus();
}); });
pinnedProjectId.value = model.value; sortedProjects.value = [...projects.value].sort((iteratingProject) => {
return model.value === iteratingProject.id ? -1 : 1;
});
} }
}); });
@@ -110,51 +103,40 @@ function updateValue(project: Project) {
</template> </template>
<template #content> <template #content>
<!-- kept open so the list stays visible during the popover close animation --> <UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<div>
<ComboboxRoot <ComboboxRoot
:open="true" v-model:search-term="searchValue"
v-model:open="open"
:model-value="currentProject" :model-value="currentProject"
class="relative" class="relative"
:ignore-filter="true" @update:model-value="updateValue">
@update:model-value="updateValue"
@update:open="
(value: boolean) => {
if (!value) open = false;
}
">
<ComboboxAnchor> <ComboboxAnchor>
<ComboboxInput <ComboboxInput
ref="searchInput" ref="searchInput"
v-model="searchValue"
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full" class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
placeholder="Search for a project..." /> placeholder="Search for a project..." />
</ComboboxAnchor> </ComboboxAnchor>
<ComboboxContent> <ComboboxContent>
<ComboboxViewport <ComboboxViewport
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1"> class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
<ComboboxVirtualizer <ComboboxItem
v-slot="{ option: project }" v-for="project in shownProjects"
:options="shownProjects" :key="project.id"
:estimate-size="32" :value="project"
:text-content="(p: Project) => p.name"> class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
<ComboboxItem :data-project-id="project.id">
:value="project" <span class="flex items-center gap-2">
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
:data-project-id="project.id">
<span class="flex min-w-0 flex-1 items-center gap-2">
<span
:style="{ backgroundColor: project.color }"
class="w-3 h-3 rounded-full shrink-0"></span>
<span class="truncate">{{ project.name }}</span>
</span>
<span <span
v-if="isProjectSelected(project)" :style="{ backgroundColor: project.color }"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> class="w-3 h-3 rounded-full shrink-0"></span>
<Check class="h-4 w-4" /> <span>{{ project.name }}</span>
</span> </span>
</ComboboxItem> <span
</ComboboxVirtualizer> v-if="isProjectSelected(project)"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check class="h-4 w-4" />
</span>
</ComboboxItem>
</ComboboxViewport> </ComboboxViewport>
<div <div
v-if="canCreateProjects()" v-if="canCreateProjects()"
@@ -168,7 +150,7 @@ function updateValue(project: Project) {
</div> </div>
</ComboboxContent> </ComboboxContent>
</ComboboxRoot> </ComboboxRoot>
</div> </UseFocusTrap>
</template> </template>
</Dropdown> </Dropdown>
<ProjectCreateModal <ProjectCreateModal

View File

@@ -2,11 +2,10 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid'; import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid'; import { PlusIcon } from '@heroicons/vue/16/solid';
import { computed, ref, watch } from 'vue'; import { computed, ref } from 'vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue'; import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue'; import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue'; import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import Pagination from '@/Components/Common/Pagination.vue';
export type SortColumn = export type SortColumn =
| 'name' | 'name'
@@ -57,15 +56,12 @@ const clientNameMap = computed(() => {
return map; return map;
}); });
// Convert sort props to TanStack Table format. // Convert sort props to TanStack Table format
// Name is always the secondary sort so rows with equal values render
// alphabetically instead of in API (created_at) order.
const sorting = computed<SortingState>(() => [ const sorting = computed<SortingState>(() => [
{ {
id: props.sortColumn, id: props.sortColumn,
desc: props.sortDirection === 'desc', desc: props.sortDirection === 'desc',
}, },
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
]); ]);
// Define column accessors for sorting. // Define column accessors for sorting.
@@ -147,19 +143,6 @@ const sortedProjects = computed(() => {
return table.getRowModel().rows.map((row) => row.original); return table.getRowModel().rows.map((row) => row.original);
}); });
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
const PAGE_SIZE = 15;
const currentPage = ref(1);
watch([() => props.sortColumn, () => props.sortDirection, () => props.projects], () => {
currentPage.value = 1;
});
const paginatedProjects = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE;
return sortedProjects.value.slice(start, start + PAGE_SIZE);
});
const showCreateProjectModal = ref(false); const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> { async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
@@ -216,7 +199,7 @@ const gridTemplate = computed(() => {
>Create your First Project >Create your First Project
</SecondaryButton> </SecondaryButton>
</div> </div>
<template v-for="project in paginatedProjects" :key="project.id"> <template v-for="project in sortedProjects" :key="project.id">
<ProjectTableRow <ProjectTableRow
:show-billable-rate="props.showBillableRate" :show-billable-rate="props.showBillableRate"
:project="project"></ProjectTableRow> :project="project"></ProjectTableRow>
@@ -224,8 +207,4 @@ const gridTemplate = computed(() => {
</div> </div>
</div> </div>
</div> </div>
<Pagination
v-model:page="currentPage"
:total="sortedProjects.length"
:items-per-page="PAGE_SIZE"></Pagination>
</template> </template>

View File

@@ -4,12 +4,15 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import { import {
ChartBarIcon, ChartBarIcon,
ChevronLeftIcon,
ChevronDoubleLeftIcon,
ChevronRightIcon,
ChevronDoubleRightIcon,
ClockIcon, ClockIcon,
EllipsisVerticalIcon, EllipsisVerticalIcon,
ArrowDownTrayIcon, ArrowDownTrayIcon,
LockClosedIcon, LockClosedIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import Pagination from '@/Components/Common/Pagination.vue';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -40,6 +43,16 @@ import { useClientsQuery } from '@/utils/useClientsQuery';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { useMembersQuery } from '@/utils/useMembersQuery'; import { useMembersQuery } from '@/utils/useMembersQuery';
import {
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot,
} from 'radix-vue';
import { useQueryClient } from '@tanstack/vue-query'; import { useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser'; import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue'; import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
@@ -397,6 +410,62 @@ async function downloadExport(format: ExportFormat) {
</div> </div>
</div> </div>
<Pagination v-model:page="currentPage" :total="totalPages" :items-per-page="pageLimit" /> <PaginationRoot
v-model:page="currentPage"
:total="totalPages"
:items-per-page="pageLimit"
class="flex justify-center items-center py-8"
:sibling-count="1"
show-edges>
<PaginationList v-slot="{ items }" class="flex items-center space-x-1 relative">
<div class="pr-2 flex items-center space-x-1 border-r border-border-primary mr-1">
<PaginationFirst class="navigation-item">
<ChevronDoubleLeftIcon class="w-4"> </ChevronDoubleLeftIcon>
</PaginationFirst>
<PaginationPrev class="mr-4 navigation-item">
<ChevronLeftIcon class="w-4 text-text-tertiary hover:text-text-primary">
</ChevronLeftIcon>
</PaginationPrev>
</div>
<template v-for="(page, index) in items">
<PaginationListItem
v-if="page.type === 'page'"
:key="index"
class="pagination-item"
:value="page.value">
{{ page.value }}
</PaginationListItem>
<PaginationEllipsis
v-else
:key="page.type"
:index="index"
class="PaginationEllipsis">
<div class="px-2">&#8230;</div>
</PaginationEllipsis>
</template>
<div class="!ml-2 pl-2 flex items-center space-x-1 border-l border-border-primary">
<PaginationNext class="navigation-item">
<ChevronRightIcon
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronRightIcon>
</PaginationNext>
<PaginationLast class="navigation-item">
<ChevronDoubleRightIcon
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronDoubleRightIcon>
</PaginationLast>
</div>
</PaginationList>
</PaginationRoot>
</AppLayout> </AppLayout>
</template> </template>
<style lang="postcss">
.navigation-item {
@apply bg-quaternary h-8 w-8 flex items-center justify-center rounded border border-border-primary text-text-tertiary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;
}
.pagination-item {
@apply bg-secondary h-8 w-8 flex items-center justify-center rounded border border-border-tertiary text-text-secondary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;
}
.pagination-item[data-selected] {
@apply text-text-primary bg-accent-300/10 border border-accent-300/20 rounded-md font-medium hover:bg-accent-300/20 active:bg-accent-300/20 outline-0 focus-visible:ring-2 focus:ring-ring transition ease-in-out duration-150;
}
</style>

View File

@@ -43,17 +43,17 @@ onMounted(async () => {
<template v-else> <template v-else>
<UpdateTeamNameForm :team="team" :permissions="permissions" /> <UpdateTeamNameForm :team="team" :permissions="permissions" />
<SectionBorder v-if="permissions.canUpdateTeam" /> <SectionBorder />
<OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" /> <OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" />
<SectionBorder />
<SectionBorder v-if="permissions.canUpdateTeam" />
<OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" /> <OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" />
<SectionBorder />
<SectionBorder v-if="permissions.canUpdateTeam" />
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" /> <OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
<SectionBorder />
<template v-if="permissions.canDeleteTeam"> <template v-if="permissions.canDeleteTeam">
<SectionBorder />
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" /> <DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
</template> </template>
</template> </template>

View File

@@ -57,7 +57,6 @@
"@floating-ui/vue": "^1.1.4", "@floating-ui/vue": "^1.1.4",
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0", "@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
"@tanstack/vue-virtual": "^3.13.24",
"@vueuse/core": "^12.5.0 || ^14.0.0", "@vueuse/core": "^12.5.0 || ^14.0.0",
"@vueuse/integrations": "^12.5.0 || ^14.0.0", "@vueuse/integrations": "^12.5.0 || ^14.0.0",
"focus-trap": "^7.0.0 || ^8.0.0", "focus-trap": "^7.0.0 || ^8.0.0",

View File

@@ -8,8 +8,8 @@ import {
ComboboxItem, ComboboxItem,
ComboboxRoot, ComboboxRoot,
ComboboxViewport, ComboboxViewport,
ComboboxVirtualizer, } from 'radix-vue';
} from 'reka-ui'; import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { Check, Plus } from '@lucide/vue'; import { Check, Plus } from '@lucide/vue';
@@ -26,6 +26,10 @@ const searchInput = ref<HTMLElement | null>(null);
const open = ref(false); const open = ref(false);
const searchValue = ref(''); const searchValue = ref('');
function isClientSelected(id: string) {
return model.value === id;
}
watch(open, (isOpen) => { watch(open, (isOpen) => {
if (isOpen) { if (isOpen) {
nextTick(() => { nextTick(() => {
@@ -54,23 +58,15 @@ async function addClientIfNoneExists() {
} }
} }
const NO_CLIENT: { id: string | null; name: string } = { id: null, name: 'No Client' };
const currentClient = computed(() => { const currentClient = computed(() => {
return props.clients.find((client) => client.id === model.value) ?? NO_CLIENT; return (
props.clients.find((client) => client.id === model.value) ?? {
id: null,
name: 'No Client',
}
);
}); });
type ClientRow = Client | typeof NO_CLIENT;
// Fold the "No Client" entry in as the first row so the whole list virtualizes through one
// ComboboxVirtualizer. NO_CLIENT is a shared constant so currentClient and the row reference
// the same object and single-select highlighting still matches.
const clientRows = computed<ClientRow[]>(() => [NO_CLIENT, ...filteredClients.value]);
function clientRowName(row: ClientRow) {
return row.name;
}
const emit = defineEmits(['update:modelValue', 'changed']); const emit = defineEmits(['update:modelValue', 'changed']);
function updateValue(client: { id: string | null; name: string }) { function updateValue(client: { id: string | null; name: string }) {
@@ -85,56 +81,56 @@ function updateValue(client: { id: string | null; name: string }) {
<slot name="trigger"></slot> <slot name="trigger"></slot>
</template> </template>
<template #content> <template #content>
<div> <UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<ComboboxRoot <ComboboxRoot
:open="true" v-model:search-term="searchValue"
v-model:open="open"
:model-value="currentClient" :model-value="currentClient"
class="relative" class="relative"
:ignore-filter="true" @update:model-value="updateValue">
@update:model-value="updateValue"
@update:open="
(value: boolean) => {
if (!value) open = false;
}
">
<ComboboxAnchor> <ComboboxAnchor>
<ComboboxInput <ComboboxInput
ref="searchInput" ref="searchInput"
v-model="searchValue"
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full" class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
placeholder="Search for a client..." /> placeholder="Search for a client..." />
</ComboboxAnchor> </ComboboxAnchor>
<ComboboxContent> <ComboboxContent>
<ComboboxViewport <ComboboxViewport
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1"> class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
<ComboboxVirtualizer <ComboboxItem
v-slot="{ option: row }" :value="{ id: null, name: 'No Client' }"
:options="clientRows" class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground">
:estimate-size="32" <span>No Client</span>
:text-content="clientRowName"> <span
<ComboboxItem v-if="model === null"
:value="row" class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground" <Check class="h-4 w-4" />
:data-client-id="row.id"> </span>
<span class="min-w-0 flex-1 truncate">{{ row.name }}</span> </ComboboxItem>
<span <ComboboxItem
v-if="model === row.id" v-for="client in filteredClients"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> :key="client.id"
<Check class="h-4 w-4" /> :value="client"
</span> class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
</ComboboxItem> :data-client-id="client.id">
</ComboboxVirtualizer> <span>{{ client.name }}</span>
<span
v-if="isClientSelected(client.id)"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check class="h-4 w-4" />
</span>
</ComboboxItem>
<div
v-if="searchValue.length > 0 && filteredClients.length === 0"
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
@click="addClientIfNoneExists">
<Plus class="h-4 w-4 shrink-0" />
<span>Add "{{ searchValue }}" as a new Client</span>
</div>
</ComboboxViewport> </ComboboxViewport>
<div
v-if="searchValue.length > 0 && filteredClients.length === 0"
class="flex items-center gap-2 rounded-sm mx-1 px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
@click="addClientIfNoneExists">
<Plus class="h-4 w-4 shrink-0" />
<span>Add "{{ searchValue }}" as a new Client</span>
</div>
</ComboboxContent> </ComboboxContent>
</ComboboxRoot> </ComboboxRoot>
</div> </UseFocusTrap>
</template> </template>
</Dropdown> </Dropdown>
</template> </template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts" generic="T"> <script setup lang="ts" generic="T">
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { computed, ref, watch } from 'vue'; import { computed, type Ref, ref, watch } from 'vue';
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue'; import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
import { import {
ComboboxAnchor, ComboboxAnchor,
@@ -9,16 +9,10 @@ import {
ComboboxItem, ComboboxItem,
ComboboxRoot, ComboboxRoot,
ComboboxViewport, ComboboxViewport,
ComboboxVirtualizer, } from 'radix-vue';
} from 'reka-ui';
const NONE_ID = 'none'; const NONE_ID = 'none';
// height of one row (px-2 py-1.5 text-sm → 12px padding + 20px line box).
// Rows are uniform single-line, so a fixed size is exact enough for the virtualizer and avoids
// any per-row DOM measurement.
const ROW_HEIGHT = 32;
const model = defineModel<string[]>({ const model = defineModel<string[]>({
default: [], default: [],
}); });
@@ -33,25 +27,20 @@ const props = defineProps<{
const open = ref(false); const open = ref(false);
const searchValue = ref(''); const searchValue = ref('');
// Pinned on open so rows don't re-sort while toggling; the item list itself stays reactive. const sortedItems = ref<T[]>([]) as Ref<T[]>;
const pinnedSelection = ref<Set<string>>(new Set());
watch(open, (isOpen) => { watch(open, (isOpen) => {
if (isOpen) { if (isOpen) {
searchValue.value = ''; searchValue.value = '';
pinnedSelection.value = new Set(model.value); sortedItems.value = [...props.items].sort((a, b) => {
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
if (aSelected !== bSelected) return aSelected - bSelected;
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
});
} }
}); });
const sortedItems = computed(() => {
return [...props.items].sort((a, b) => {
const aSelected = pinnedSelection.value.has(props.getKeyFromItem(a)) ? 0 : 1;
const bSelected = pinnedSelection.value.has(props.getKeyFromItem(b)) ? 0 : 1;
if (aSelected !== bSelected) return aSelected - bSelected;
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
});
});
const filteredItems = computed(() => { const filteredItems = computed(() => {
const search = searchValue.value.toLowerCase().trim(); const search = searchValue.value.toLowerCase().trim();
if (!search) return sortedItems.value; if (!search) return sortedItems.value;
@@ -67,23 +56,6 @@ const showNoItem = computed(() => {
return props.noItemLabel.toLowerCase().includes(search); return props.noItemLabel.toLowerCase().includes(search);
}); });
// A single flat list for the virtualizer. The optional "no item" entry is folded in as the
// first row so the whole list (including it) is virtualized through one ComboboxVirtualizer.
type Row = { kind: 'none' } | { kind: 'item'; item: T };
const rows = computed<Row[]>(() => {
const itemRows = filteredItems.value.map((item): Row => ({ kind: 'item', item }));
return showNoItem.value ? [{ kind: 'none' }, ...itemRows] : itemRows;
});
function keyForRow(row: Row): string {
return row.kind === 'none' ? NONE_ID : props.getKeyFromItem(row.item);
}
function nameForRow(row: Row): string {
return row.kind === 'none' ? (props.noItemLabel ?? '') : props.getNameForItem(row.item);
}
function toggleItem(id: string) { function toggleItem(id: string) {
if (model.value.includes(id)) { if (model.value.includes(id)) {
model.value = model.value.filter((itemId) => itemId !== id); model.value = model.value.filter((itemId) => itemId !== id);
@@ -102,44 +74,46 @@ const emit = defineEmits(['update:modelValue', 'changed', 'submit']);
<slot name="trigger"></slot> <slot name="trigger"></slot>
</template> </template>
<template #content> <template #content>
<!-- kept open so the list stays visible during the popover close animation -->
<ComboboxRoot <ComboboxRoot
:open="true" v-model:search-term="searchValue"
v-model:open="open"
class="p-2" class="p-2"
:ignore-filter="true" :filter-function="(val: string[]) => val">
@update:open="
(value: boolean) => {
if (!value) open = false;
}
">
<ComboboxAnchor> <ComboboxAnchor>
<ComboboxInput <ComboboxInput
v-model="searchValue"
class="w-full h-8 rounded-md border border-input-border bg-input-background px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none" class="w-full h-8 rounded-md border border-input-border bg-input-background px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
:placeholder="searchPlaceholder" /> :placeholder="searchPlaceholder" />
</ComboboxAnchor> </ComboboxAnchor>
<ComboboxContent <ComboboxContent
:dismiss-able="false" :dismiss-able="false"
position="inline" position="inline"
class="mt-2 min-w-60 max-w-80"> class="mt-2 min-w-60 max-w-80 max-h-60 overflow-y-auto">
<ComboboxViewport class="max-h-60 overflow-y-auto"> <ComboboxViewport>
<ComboboxVirtualizer <ComboboxItem
v-slot="{ option }" v-if="showNoItem"
:options="rows" :value="NONE_ID"
:estimate-size="ROW_HEIGHT" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
:text-content="nameForRow"> @select.prevent="toggleItem(NONE_ID)">
<ComboboxItem <Checkbox
:value="keyForRow(option)" :checked="model.includes(NONE_ID)"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default" aria-hidden="true"
@select.prevent="toggleItem(keyForRow(option))"> :tabindex="-1"
<Checkbox class="pointer-events-none" />
:checked="model.includes(keyForRow(option))" <span class="truncate">{{ noItemLabel }}</span>
aria-hidden="true" </ComboboxItem>
:tabindex="-1" <ComboboxItem
class="pointer-events-none" /> v-for="item in filteredItems"
<span class="truncate">{{ nameForRow(option) }}</span> :key="getKeyFromItem(item)"
</ComboboxItem> :value="getKeyFromItem(item)"
</ComboboxVirtualizer> class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
@select.prevent="toggleItem(getKeyFromItem(item))">
<Checkbox
:checked="model.includes(getKeyFromItem(item))"
aria-hidden="true"
:tabindex="-1"
class="pointer-events-none" />
<span class="truncate">{{ getNameForItem(item) }}</span>
</ComboboxItem>
</ComboboxViewport> </ComboboxViewport>
</ComboboxContent> </ComboboxContent>
</ComboboxRoot> </ComboboxRoot>

View File

@@ -9,9 +9,9 @@ defineProps<{
<template> <template>
<div <div
class="flex justify-between items-center w-full text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"> class="flex justify-between items-center w-full text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<div class="flex space-x-3 items-center px-3 py-1.5 min-w-0"> <div class="flex space-x-3 items-center px-3 py-1.5">
<div :style="{ backgroundColor: color }" class="w-3 h-3 rounded-full shrink-0"></div> <div :style="{ backgroundColor: color }" class="w-3 h-3 rounded-full"></div>
<span class="truncate">{{ name }}</span> <span>{{ name }}</span>
</div> </div>
<slot name="actions"></slot> <slot name="actions"></slot>
</div> </div>

View File

@@ -37,24 +37,19 @@ const model = defineModel<string[]>({
const open = ref(false); const open = ref(false);
const searchValue = ref(''); const searchValue = ref('');
// Pinned on open so rows don't re-sort while toggling; the tag list itself stays reactive. const sortedTags = ref<Tag[]>([]);
const pinnedSelection = ref<Set<string>>(new Set());
watch(open, (isOpen) => { watch(open, (isOpen) => {
if (isOpen) { if (isOpen) {
searchValue.value = ''; searchValue.value = '';
pinnedSelection.value = new Set(model.value); sortedTags.value = [...props.tags].sort((a, b) => {
const aSelected = model.value.includes(a.id) ? 0 : 1;
const bSelected = model.value.includes(b.id) ? 0 : 1;
return aSelected - bSelected;
});
} }
}); });
const sortedTags = computed(() => {
return [...props.tags].sort((a, b) => {
const aSelected = pinnedSelection.value.has(a.id) ? 0 : 1;
const bSelected = pinnedSelection.value.has(b.id) ? 0 : 1;
return aSelected - bSelected;
});
});
const filteredTags = computed(() => { const filteredTags = computed(() => {
const search = searchValue.value.toLowerCase().trim(); const search = searchValue.value.toLowerCase().trim();
if (!search) return sortedTags.value; if (!search) return sortedTags.value;

View File

@@ -87,32 +87,4 @@ describe('TimeTrackerProjectTaskDropdown', () => {
expect(wrapper.emitted('changed')?.at(-1)).toEqual(['', null]); expect(wrapper.emitted('changed')?.at(-1)).toEqual(['', null]);
}); });
it("keeps a project's tasks visible when the search term matches the project name", async () => {
const project = {
id: 'p-dummy',
name: 'dummy',
color: '#fff',
client_id: null,
is_archived: false,
} as unknown as Project;
const tasks = [
{ id: 't-1', name: 'design', project_id: 'p-dummy', is_done: false },
{ id: 't-2', name: 'build', project_id: 'p-dummy', is_done: false },
] as unknown as Task[];
const wrapper = mountDropdown({ projects: [project], tasks });
await nextTick();
await nextTick();
const searchInput = wrapper.find('[data-testid="client_dropdown_search"]');
await searchInput.setValue('dummy');
await nextTick();
// project itself shows up
expect(wrapper.find('[data-project-id="p-dummy"]').exists()).toBe(true);
// and its tasks are still available even though they don't match "dummy":
// the task expander keeps showing all of the project's tasks
expect(wrapper.text()).toContain('2 Tasks');
});
}); });

View File

@@ -2,7 +2,6 @@
import { ChevronRightIcon, ChevronDownIcon } from '@heroicons/vue/16/solid'; import { ChevronRightIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue'; import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { useVirtualizer } from '@tanstack/vue-virtual';
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue'; import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
import type { import type {
CreateClientBody, CreateClientBody,
@@ -30,6 +29,7 @@ const project = defineModel<string | null>('project', {
const searchInput = ref<HTMLInputElement | null>(null); const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false); const open = ref(false);
const dropdownViewport = ref<HTMLElement | null>(null); const dropdownViewport = ref<HTMLElement | null>(null);
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
const searchValue = ref(''); const searchValue = ref('');
@@ -85,119 +85,72 @@ const filteredProjects = computed<ProjectWithTasks[]>(() => {
return filteredResults.value.map((client) => client.projects).flat(); return filteredResults.value.map((client) => client.projects).flat();
}); });
type FlatRow =
| { kind: 'client'; key: string; name: string }
| { kind: 'project'; key: string; project: ProjectWithTasks }
| { kind: 'task'; key: string; task: Task };
// Flatten the grouped client → project → task tree into a single ordered list so it can be
// virtualized: only the rows currently inside the viewport are mounted, which keeps the
// dropdown responsive even with thousands of projects/tasks.
const flatRows = computed<FlatRow[]>(() => {
const rows: FlatRow[] = [];
for (const client of filteredResults.value) {
// The "No Project" group renders its project inline without a client header.
if (client.id !== 'no_project_no_client') {
rows.push({ kind: 'client', key: 'client-' + client.id, name: client.name });
}
for (const projectWithTasks of client.projects) {
rows.push({
kind: 'project',
key: 'project-' + projectWithTasks.id,
project: projectWithTasks,
});
if (projectWithTasks.expanded) {
for (const taskItem of projectWithTasks.tasks) {
rows.push({ kind: 'task', key: 'task-' + taskItem.id, task: taskItem });
}
}
}
}
return rows;
});
const ROW_HEIGHT = { client: 28, project: 36, task: 32 } as const;
const rowVirtualizer = useVirtualizer(
computed(() => ({
count: flatRows.value.length,
getScrollElement: () => dropdownViewport.value,
estimateSize: (index: number) => {
const row = flatRows.value[index];
if (row?.kind === 'client') return ROW_HEIGHT.client;
if (row?.kind === 'task') return ROW_HEIGHT.task;
return ROW_HEIGHT.project;
},
getItemKey: (index: number) => flatRows.value[index]?.key ?? index,
overscan: 12,
}))
);
const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
const visibleRows = computed(() =>
rowVirtualizer.value.getVirtualItems().map((virtualRow) => ({
virtualRow,
row: flatRows.value[virtualRow.index]!,
}))
);
// Lookup maps so filtering is O(projects + tasks + clients) instead of
// O(projects × (tasks + clients)). They are rebuilt only when the underlying task/client
// props change, not on every keystroke.
const tasksByProject = computed(() => {
const map = new Map<string, Task[]>();
for (const taskItem of props.tasks) {
const list = map.get(taskItem.project_id);
if (list) {
list.push(taskItem);
} else {
map.set(taskItem.project_id, [taskItem]);
}
}
return map;
});
const clientsById = computed(() => {
const map = new Map<string, Client>();
for (const clientItem of props.clients) {
map.set(clientItem.id, clientItem);
}
return map;
});
function addProjectToFilterObject( function addProjectToFilterObject(
tempFilteredClients: ClientsWithProjectsWithTasks, tempFilteredClients: ClientsWithProjectsWithTasks,
groupIndexByKey: Map<string, number>,
project: Project, project: Project,
filteredTasks: Task[], filteredTasks: Task[],
expanded = false expanded = false
) { ) {
const client = project.client_id ? clientsById.value.get(project.client_id) : undefined; // check if client already exists in filter array
const groupKey = client ? client.id : 'no_client'; const projectClientIndex = tempFilteredClients.findIndex(
const newProject: ProjectWithTasks = { ...project, expanded, tasks: filteredTasks }; (client) => client.id === project.client_id
);
// O(1) group lookup instead of scanning the accumulating array for every project. const client = props.clients.find((client) => client.id === project.client_id);
const existingIndex = groupIndexByKey.get(groupKey);
if (existingIndex !== undefined) {
tempFilteredClients[existingIndex]!.projects.push(newProject);
return;
}
groupIndexByKey.set(groupKey, tempFilteredClients.length); if (projectClientIndex !== -1) {
if (client) { // client already exists in filter array
tempFilteredClients.push({ ...client, projects: [newProject] }); tempFilteredClients[projectClientIndex]!.projects.push({
} else { ...project,
tempFilteredClients.push({ expanded: expanded,
id: 'no_client', tasks: filteredTasks,
name: 'No Client',
color: 'var(--theme-color-icon-default)',
created_at: '',
updated_at: '',
value: '',
is_archived: false,
projects: [newProject],
}); });
} else if (client) {
// project has client but is not already in filter array
// client is not yet in filter array
tempFilteredClients.push({
...client,
projects: [
{
...project,
expanded: expanded,
tasks: filteredTasks,
},
],
});
} else {
// project has no client
const customNoClientId = 'no_client';
const noClientIndex = tempFilteredClients.findIndex(
(client) => client.id === customNoClientId
);
if (noClientIndex !== -1) {
// no client group already exists in filter array
tempFilteredClients[noClientIndex]!.projects.push({
...project,
expanded: expanded,
tasks: filteredTasks,
});
} else {
// no client group is not yet in filter array
tempFilteredClients.push({
id: customNoClientId,
name: 'No Client',
color: 'var(--theme-color-icon-default)',
created_at: '',
updated_at: '',
value: '',
is_archived: false,
projects: [
{
...project,
expanded: expanded,
tasks: filteredTasks,
},
],
});
}
} }
} }
@@ -233,50 +186,39 @@ function updateFilteredResults() {
}); });
} }
const searchTerm = searchValue.value?.toLowerCase()?.trim() || '';
const groupIndexByKey = new Map<string, number>();
for (const filterProject of props.projects) { for (const filterProject of props.projects) {
const projectNameIncludesSearchTerm = filterProject.name.toLowerCase().includes(searchTerm); const projectNameIncludesSearchTerm = filterProject.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
const clientName = filterProject.client_id const clientNameIncludesSearchTerm = props.clients
? clientsById.value.get(filterProject.client_id)?.name .find((client) => client.id === filterProject.client_id)
: undefined; ?.name.toLowerCase()
const clientNameIncludesSearchTerm = clientName?.toLowerCase().includes(searchTerm); .includes(searchValue.value?.toLowerCase()?.trim() || '');
const projectTasks = tasksByProject.value.get(filterProject.id) ?? []; // check if one of the project tasks
const projectTasks = props.tasks.filter((task) => {
// tasks that should be selectable regardless of the search term return task.project_id === filterProject.id;
// (open tasks, plus the currently selected one even if it's done)
const availableTasks = projectTasks.filter((filterTask) => {
return !filterTask.is_done || filterTask.id === task.value;
}); });
const filteredTasks = availableTasks.filter((filterTask) => { const filteredTasks = projectTasks.filter((filterTask) => {
return filterTask.name.toLowerCase().includes(searchTerm); return (
filterTask.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '') &&
(!filterTask.is_done || filterTask.id === task.value)
);
}); });
if ( if (
(projectNameIncludesSearchTerm || clientNameIncludesSearchTerm) && (projectNameIncludesSearchTerm || clientNameIncludesSearchTerm) &&
(!filterProject.is_archived || project.value === filterProject.id) (!filterProject.is_archived || project.value === filterProject.id)
) { ) {
// search term matches project (or client) name: show all the tasks // search term matches project name
addProjectToFilterObject( addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, false);
tempFilteredClients,
groupIndexByKey,
filterProject,
availableTasks,
false
);
} else if (filteredTasks.length > 0 && !filterProject.is_archived) { } else if (filteredTasks.length > 0 && !filterProject.is_archived) {
// search term matches task name // search term matches task name
addProjectToFilterObject( addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, true);
tempFilteredClients,
groupIndexByKey,
filterProject,
filteredTasks,
true
);
} }
} }
@@ -468,18 +410,24 @@ function moveHighlightDown() {
const highlightedItemId = ref<string | null>(null); const highlightedItemId = ref<string | null>(null);
watch(highlightedItemId, () => { watch(highlightedItemId, () => {
if (highlightedItemId.value === null) { const highlightedItem = dropdownViewport.value?.querySelector(
return; `[data-project-id="${highlightedItemId.value}"]`
}
// The highlighted row may be virtualized out of the DOM, so scroll by index
// through the virtualizer instead of querying for the element.
const index = flatRows.value.findIndex(
(row) =>
(row.kind === 'project' && row.project.id === highlightedItemId.value) ||
(row.kind === 'task' && row.task.id === highlightedItemId.value)
); );
if (index !== -1) { if (highlightedItem) {
rowVirtualizer.value.scrollToIndex(index, { align: 'auto' }); highlightedItem.scrollIntoView({
block: 'nearest',
inline: 'nearest',
});
} else {
const highlightedTask = dropdownViewport.value?.querySelector(
`[data-task-id="${highlightedItemId.value}"]`
);
if (highlightedTask) {
highlightedTask.scrollIntoView({
block: 'nearest',
inline: 'nearest',
});
}
} }
}); });
@@ -594,7 +542,7 @@ const showCreateProject = ref(false);
</slot> </slot>
</template> </template>
<template #content> <template #content>
<div> <UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<input <input
ref="searchInput" ref="searchInput"
:value="searchValue" :value="searchValue"
@@ -610,63 +558,62 @@ const showCreateProject = ref(false);
@keydown.left.prevent="collapseProject" /> @keydown.left.prevent="collapseProject" />
<div <div
ref="dropdownViewport" ref="dropdownViewport"
class="w-[400px] max-w-[calc(100vw-2rem)] max-h-[350px] overflow-y-scroll relative" class="min-w-[350px] max-h-[350px] overflow-y-scroll relative"
@mousemove="mouseEnterHighlightActivated = true"> @mousemove="mouseEnterHighlightActivated = true">
<div :style="{ height: `${totalSize}px`, width: '100%', position: 'relative' }"> <template v-for="client in filteredResults" :key="client.id">
<div <div
v-for="{ virtualRow, row } in visibleRows" v-if="client.id !== 'no_project_no_client'"
:key="row.key" class="w-full pb-1 pt-2 px-2 text-text-tertiary text-xs font-semibold flex space-x-1 items-center">
class="absolute left-0 top-0 w-full" <span>
:style="{ transform: `translateY(${virtualRow.start}px)` }"> {{ client.name }}
</span>
</div>
<template
v-for="projectWithTasks in client.projects"
:key="projectWithTasks.id">
<div <div
v-if="row.kind === 'client'"
class="w-full pb-1 pt-2 px-2 text-text-tertiary text-xs font-semibold flex space-x-1 items-center">
<span class="truncate">{{ row.name }}</span>
</div>
<div
v-else-if="row.kind === 'project'"
role="option" role="option"
class="px-1 py-0.5 cursor-default" class="px-1 py-0.5 cursor-default"
:value="row.project.id" :value="projectWithTasks.id"
:data-project-id="row.project.id" :data-project-id="projectWithTasks.id"
@click="selectProject(row.project.id)"> @click="selectProject(projectWithTasks.id)">
<div <div
class="rounded-lg" class="rounded-lg"
:class="{ :class="{
'bg-card-background-active': 'bg-card-background-active':
row.project.id === highlightedItemId, projectWithTasks.id === highlightedItemId,
}"> }">
<ProjectDropdownItem <ProjectDropdownItem
class="hover:!bg-transparent" class="hover:!bg-transparent"
:selected="isProjectSelected(row.project)" :selected="isProjectSelected(projectWithTasks)"
:name="row.project.name" :name="projectWithTasks.name"
:color="row.project.color" :color="projectWithTasks.color"
@mouseenter="setHighlightItemId(row.project.id)"> @mouseenter="setHighlightItemId(projectWithTasks.id)">
<template #actions> <template #actions>
<button <button
v-if="row.project.tasks.length > 0" v-if="projectWithTasks.tasks.length > 0"
tabindex="-1" tabindex="-1"
class="px-2 py-0.5 mr-2 relative transition items-center rounded flex space-x-0.5 text-xs shrink-0" class="px-2 py-0.5 mr-2 relative transition items-center rounded flex space-x-0.5 text-xs"
:class="{ :class="{
'bg-white/5 text-text-secondary': 'bg-white/5 text-text-secondary':
row.project.expanded, projectWithTasks.expanded,
'hover:bg-white/5 hover:text-text-secondary text-text-tertiary': 'hover:bg-white/5 hover:text-text-secondary text-text-tertiary':
!row.project.expanded, !projectWithTasks.expanded,
}" }"
@click.prevent.stop=" @click.prevent.stop="
() => { () => {
row.project.expanded = projectWithTasks.expanded =
!row.project.expanded; !projectWithTasks.expanded;
searchInput?.focus(); searchInput?.focus();
} }
"> ">
<span class="whitespace-nowrap" <span
>{{ row.project.tasks.length }} Tasks</span >{{ projectWithTasks.tasks.length }} Tasks</span
> >
<ChevronDownIcon <ChevronDownIcon
:class="{ :class="{
'transform rotate-180': 'transform rotate-180':
row.project.expanded, projectWithTasks.expanded,
}" }"
class="w-4"></ChevronDownIcon> class="w-4"></ChevronDownIcon>
</button> </button>
@@ -674,23 +621,23 @@ const showCreateProject = ref(false);
</ProjectDropdownItem> </ProjectDropdownItem>
</div> </div>
</div> </div>
<div <div v-if="projectWithTasks.expanded" class="bg-quaternary">
v-else-if="row.kind === 'task'" <div
:data-task-id="row.task.id" v-for="task in projectWithTasks.tasks"
class="flex items-center space-x-2 w-full px-5 py-1.5 text-start text-xs font-semibold leading-5 text-text-primary focus:outline-none transition duration-150 ease-in-out" :key="task.id"
:class=" :data-task-id="task.id"
row.task.id === highlightedItemId :class="{
? 'bg-card-background-active' 'bg-card-background-active': task.id === highlightedItemId,
: 'bg-quaternary' }"
" class="flex items-center space-x-2 w-full px-5 py-1.5 text-start text-xs font-semibold leading-5 text-text-primary focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="selectTask(row.task.id)" @click="selectTask(task.id)"
@mouseenter="setHighlightItemId(row.task.id)"> @mouseenter="setHighlightItemId(task.id)">
<MinusIcon <MinusIcon class="w-3 h-3 text-text-quaternary"></MinusIcon>
class="w-3 h-3 text-text-quaternary shrink-0"></MinusIcon> <span>{{ task.name }}</span>
<span class="min-w-0 truncate">{{ row.task.name }}</span> </div>
</div> </div>
</div> </template>
</div> </template>
</div> </div>
<div v-if="canCreateProject" class="hover:bg-card-background-active rounded-b-lg"> <div v-if="canCreateProject" class="hover:bg-card-background-active rounded-b-lg">
<button <button
@@ -704,7 +651,7 @@ const showCreateProject = ref(false);
<span>Create new Project</span> <span>Create new Project</span>
</button> </button>
</div> </div>
</div> </UseFocusTrap>
</template> </template>
</Dropdown> </Dropdown>
<ProjectCreateModal <ProjectCreateModal

View File

@@ -199,7 +199,7 @@ body {
--muted-foreground: var(--color-text-tertiary); --muted-foreground: var(--color-text-tertiary);
--accent: var(--color-bg-tertiary); --accent: var(--color-bg-tertiary);
--accent-foreground: var(--color-text-primary); --accent-foreground: var(--color-text-primary);
--destructive: 0 72% 60%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary); --destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary); --border: var(--color-border-primary);
--input: var(--color-border-tertiary); --input: var(--color-border-tertiary);

View File

@@ -18,13 +18,3 @@ window.getTimezoneSetting = vi.fn(() => 'UTC');
window.getWeekStartSetting = vi.fn(() => 'monday'); window.getWeekStartSetting = vi.fn(() => 'monday');
window.getNumberFormat = vi.fn(() => 'point'); window.getNumberFormat = vi.fn(() => 'point');
window.getIntervalFormat = vi.fn(() => 'hours-minutes'); window.getIntervalFormat = vi.fn(() => 'hours-minutes');
// happy-dom has no layout engine, so every element reports offsetWidth/offsetHeight of 0.
// TanStack Virtual (used by the project/task dropdown) measures via those properties, so
// without a size it renders zero rows. Give elements a usable box so virtualized components
// render their rows in component tests.
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 400 });
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
get: () => 350,
});

View File

@@ -1,4 +1,4 @@
import { useQuery, keepPreviousData } from '@tanstack/vue-query'; import { useQuery } from '@tanstack/vue-query';
import { api, type TimeEntryResponse } from '@/packages/api/src'; import { api, type TimeEntryResponse } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { computed, type Ref, type ComputedRef, unref } from 'vue'; import { computed, type Ref, type ComputedRef, unref } from 'vue';
@@ -21,9 +21,6 @@ export function useTimeEntriesReportQuery(
}, },
queries: { ...unref(filterParams) }, queries: { ...unref(filterParams) },
}), }),
// Keep the previous page's data (incl. meta.total) while the next page loads, so
// pagination doesn't transiently see total=1 and clamp the page back to 1.
placeholderData: keepPreviousData,
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds
}); });
} }

View File

@@ -1,3 +0,0 @@
"Project","Client","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
"Active Project","Big Company","Active","Public","Yes","Task 1, Task 2","0.00","100.00","","","","","","","","","0.00","0.00","100.01","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
"Archived Project","","Archived","Public","Yes","","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
1 Project Client Status Visibility Billability Tasks Tracked (h) Estimated (h) Remaining (h) Overage (h) Tracked (USD) Estimated (USD) Remaining (USD) Overage (USD) Progress(%) Recurring estimate Billable (h) Non-billable (h) Billable Rate (USD) Amount (USD) Cost Rate (USD) Expenses (USD) Billable expenses (USD) Non-billable expenses (USD) Additional fields Project members Project manager Note
2 Active Project Big Company Active Public Yes Task 1, Task 2 0.00 100.00 0.00 0.00 100.01 0.00 0.00 0.00 0.00 Constantin Graf
3 Archived Project Archived Public Yes 0.00 0.00 0.00 0.00 0.00 0.00 0.00 Constantin Graf

View File

@@ -1,2 +0,0 @@
"Project","Client","Status","Visibility","Billability","Activities","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
"Project With Activities","","Active","Public","Yes","Activity A, Activity B","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","","",""
1 Project Client Status Visibility Billability Activities Tracked (h) Estimated (h) Remaining (h) Overage (h) Tracked (USD) Estimated (USD) Remaining (USD) Overage (USD) Progress(%) Recurring estimate Billable (h) Non-billable (h) Billable Rate (USD) Amount (USD) Cost Rate (USD) Expenses (USD) Billable expenses (USD) Non-billable expenses (USD) Additional fields Project members Project manager Note
2 Project With Activities Active Public Yes Activity A, Activity B 0.00 0.00 0.00 0.00 0.00 0.00 0.00

View File

@@ -1,3 +0,0 @@
"Project","Client","Description","Task","User","Group","Email","Tags","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
1 Project Client Description Task User Group Email Tags Start Date Start Time End Date End Time Duration (h) Duration (decimal) Billable Rate (USD) Billable Amount (USD)
2 Project without Client Peter Tester peter.test@email.test Development, Backend 03/04/2024 10:23:52 AM 03/04/2024 10:23:52 AM 00:00:00 0.00 0.00 0.00
3 Project for Big Company Big Company Working hard Task 1 Peter Tester peter.test@email.test 03/04/2024 10:23 AM 03/04/2024 11:23:01 AM 01:00:01 0.00 0.00 0.00

View File

@@ -1,3 +0,0 @@
"Project","Client","Description","Activity","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","No","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","Yes","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
1 Project Client Description Activity User Group Email Tags Billable Start Date Start Time End Date End Time Duration (h) Duration (decimal) Billable Rate (USD) Billable Amount (USD)
2 Project without Client Peter Tester peter.test@email.test Development, Backend No 03/04/2024 10:23:52 AM 03/04/2024 10:23:52 AM 00:00:00 0.00 0.00 0.00
3 Project for Big Company Big Company Working hard Task 1 Peter Tester peter.test@email.test Yes 03/04/2024 10:23 AM 03/04/2024 11:23:01 AM 01:00:01 0.00 0.00 0.00

View File

@@ -45,7 +45,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Assert // Assert
$response->assertStatus(200); $response->assertStatus(200);
$response->assertJsonCount(4, 'data'); $response->assertJsonCount(4, 'data');
$clients = Client::query()->orderBy('created_at', 'desc')->orderBy('id')->get(); $clients = Client::query()->orderBy('created_at', 'desc')->get();
$response->assertJson(fn (AssertableJson $json) => $json $response->assertJson(fn (AssertableJson $json) => $json
->has('data') ->has('data')
->has('links') ->has('links')
@@ -84,12 +84,9 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
->has('links') ->has('links')
->has('meta') ->has('meta')
->count('data', 2) ->count('data', 2)
->where('data.0.id', $clients->get(0)->getKey())
->where('data.1.id', $clients->get(1)->getKey())
); );
// Both clients share the same created_at, so their relative order is not defined.
$this->assertEqualsCanonicalizing([
$clients->get(0)->getKey(),
$clients->get(1)->getKey(),
], $response->json('data.*.id'));
} }
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void

View File

@@ -13,8 +13,6 @@ use App\Models\ProjectMember;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\BillableRateService; use App\Service\BillableRateService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Testing\Fluent\AssertableJson; use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Passport\Passport; use Laravel\Passport\Passport;
use Mockery\MockInterface; use Mockery\MockInterface;
@@ -83,49 +81,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$this->assertSame([$projectNewest->getKey(), $projectMiddle->getKey(), $projectOldest->getKey()], $ids); $this->assertSame([$projectNewest->getKey(), $projectMiddle->getKey(), $projectOldest->getKey()], $ids);
} }
public function test_index_endpoint_pagination_returns_every_project_exactly_once_when_they_share_created_at(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:view',
'projects:view:all',
]);
config(['app.pagination_per_page_default' => 15]);
// Bulk import: 300 projects that all share the exact same created_at.
$sharedCreatedAt = now()->subDay()->startOfSecond();
$rows = [];
for ($i = 0; $i < 300; $i++) {
$rows[] = [
'id' => (string) Str::uuid(),
'name' => 'Project '.$i,
'color' => '#000000',
'is_billable' => false,
'is_public' => false,
'organization_id' => $data->organization->getKey(),
'created_at' => $sharedCreatedAt,
'updated_at' => $sharedCreatedAt,
];
}
DB::table('projects')->insert($rows);
Passport::actingAs($data->user);
// Act - walk every page like resources/js/utils/fetchAllPages.ts does.
$orgId = $data->organization->getKey();
$first = $this->getJson(route('api.v1.projects.index', [$orgId]).'?page=1');
$this->assertResponseCode($first, 200);
$lastPage = $first->json('meta.last_page');
$collected = collect($first->json('data.*.id'));
for ($page = 2; $page <= $lastPage; $page++) {
$response = $this->getJson(route('api.v1.projects.index', [$orgId]).'?page='.$page);
$this->assertResponseCode($response, 200);
$collected = $collected->concat($response->json('data.*.id'));
}
// Assert - every project appears exactly once, none duplicated or missing.
$this->assertEqualsCanonicalizing(array_column($rows, 'id'), $collected->all(), 'Some projects were duplicated or missing across pages');
}
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_projects(): void public function test_index_endpoint_without_filter_archived_returns_only_non_archived_projects(): void
{ {
// Arrange // Arrange

View File

@@ -51,7 +51,7 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
// Assert // Assert
$response->assertStatus(200); $response->assertStatus(200);
$response->assertJsonCount(4, 'data'); $response->assertJsonCount(4, 'data');
$reports = Report::query()->orderBy('created_at', 'desc')->orderBy('id')->get(); $reports = Report::query()->orderBy('created_at', 'desc')->get();
$response->assertJson(fn (AssertableJson $json) => $json $response->assertJson(fn (AssertableJson $json) => $json
->has('data') ->has('data')
->has('links') ->has('links')

View File

@@ -44,7 +44,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract
// Assert // Assert
$response->assertStatus(200); $response->assertStatus(200);
$response->assertJsonCount(4, 'data'); $response->assertJsonCount(4, 'data');
$tags = Tag::query()->orderBy('created_at', 'desc')->orderBy('id')->get(); $tags = Tag::query()->orderBy('created_at', 'desc')->get();
$response->assertJson(fn (AssertableJson $json) => $json $response->assertJson(fn (AssertableJson $json) => $json
->has('data') ->has('data')
->has('links') ->has('links')

View File

@@ -23,7 +23,6 @@ use App\Models\User;
use App\Service\TimeEntryFilter; use App\Service\TimeEntryFilter;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -392,59 +391,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
); );
} }
public function test_index_endpoint_pagination_returns_every_time_entry_exactly_once_with_rounding(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
// Bulk import: 300 time entries that all share the exact same start.
$sharedStart = Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07');
$rows = [];
for ($i = 0; $i < 300; $i++) {
$rows[] = [
'id' => (string) Str::uuid(),
'description' => 'Entry '.$i,
'start' => $sharedStart,
'end' => $sharedStart,
'billable' => false,
'is_imported' => true,
'user_id' => $data->member->user_id,
'member_id' => $data->member->getKey(),
'organization_id' => $data->organization->getKey(),
'created_at' => $sharedStart,
'updated_at' => $sharedStart,
];
}
DB::table('time_entries')->insert($rows);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act - walk every page like the client does (limit/offset), with rounding enabled.
$orgId = $data->organization->getKey();
$limit = 15;
$collected = collect();
$offset = 0;
do {
$response = $this->getJson(route('api.v1.time-entries.index', [
$orgId,
'member_id' => $data->member->getKey(),
'rounding_type' => TimeEntryRoundingType::Nearest,
'rounding_minutes' => 6,
'limit' => $limit,
'offset' => $offset,
]));
$this->assertResponseCode($response, 200);
$ids = $response->json('data.*.id');
$collected = $collected->concat($ids);
$offset += $limit;
} while (count($ids) === $limit);
// Assert - every time entry appears exactly once, none duplicated or missing.
$this->assertEqualsCanonicalizing(array_column($rows, 'id'), $collected->all(), 'Some time entries were duplicated or missing across pages');
}
public function test_index_endpoint_can_round_up(): void public function test_index_endpoint_can_round_up(): void
{ {
// Arrange // Arrange

View File

@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers; namespace Tests\Unit\Service\Import\Importers;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Service\Import\Importers\ClockifyProjectsImporter; use App\Service\Import\Importers\ClockifyProjectsImporter;
use App\Service\Import\Importers\DefaultImporter; use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
@@ -52,68 +50,4 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
// Assert // Assert
$this->checkTestScenarioProjectsOnlyAfterImport(); $this->checkTestScenarioProjectsOnlyAfterImport();
} }
public function test_import_sets_archived_at_based_on_status_column(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
$this->assertNull($activeProject->archived_at);
$this->assertFalse($activeProject->is_archived);
$archivedProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Archived Project')->firstOrFail();
$this->assertNotNull($archivedProject->archived_at);
$this->assertTrue($archivedProject->is_archived);
}
public function test_import_supports_renamed_tasks_column(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Newer Clockify exports rename the "Task" column to "Tasks".
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
$this->assertEqualsCanonicalizing(
['Task 1', 'Task 2'],
Task::query()->where('project_id', $activeProject->id)->pluck('name')->all(),
);
}
public function test_import_supports_activities_column_alias_for_tasks(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Some Clockify exports name the tasks column "Activities".
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_3.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project With Activities')->firstOrFail();
$this->assertEqualsCanonicalizing(
['Activity A', 'Activity B'],
Task::query()->where('project_id', $project->id)->pluck('name')->all(),
);
}
} }

View File

@@ -41,30 +41,6 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(1, $report->clientsCreated); $this->assertSame(1, $report->clientsCreated);
} }
public function test_import_of_test_file_without_billable_works_and_defaults_to_non_billable(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_4.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(false, true);
$this->checkTimeEntries($testScenario, false, true);
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(2, $report->tagsCreated);
$this->assertSame(1, $report->tasksCreated);
$this->assertSame(1, $report->usersCreated);
$this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated);
}
public function test_import_of_test_with_special_characters_description_succeeds(): void public function test_import_of_test_with_special_characters_description_succeeds(): void
{ {
// Arrange // Arrange
@@ -117,25 +93,6 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->clientsCreated); $this->assertSame(0, $report->clientsCreated);
} }
public function test_import_supports_activity_column_alias_for_task(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
// Some Clockify exports name the task column "Activity".
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_5.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(1, $report->tasksCreated);
}
public function test_import_fails_if_month_in_date_is_bigger_than_12(): void public function test_import_fails_if_month_in_date_is_bigger_than_12(): void
{ {
// Arrange // Arrange

View File

@@ -26,7 +26,7 @@ class ImporterTestAbstract extends TestCase
/** /**
* @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} * @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag}
*/ */
protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false, bool $billableDefault = false): object protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false): object
{ {
$users = User::all(); $users = User::all();
$this->assertCount(2, $users); $this->assertCount(2, $users);
@@ -80,12 +80,12 @@ class ImporterTestAbstract extends TestCase
$this->assertSame('#ef5350', $project1->color); $this->assertSame('#ef5350', $project1->color);
$this->assertSame(null, $project1->billable_rate); $this->assertSame(null, $project1->billable_rate);
// Project for Big Company // Project for Big Company
$this->assertSame(! $billableDefault, $project2->is_billable); $this->assertSame(true, $project2->is_billable);
$this->assertSame(false, $project2->is_public); $this->assertSame(false, $project2->is_public);
$this->assertSame('#ec407a', $project2->color); $this->assertSame('#ec407a', $project2->color);
$this->assertSame(10001, $project2->billable_rate); $this->assertSame(10001, $project2->billable_rate);
// Project (Archived) // Project (Archived)
$this->assertSame(! $billableDefault, $project3->is_billable); $this->assertSame(true, $project3->is_billable);
$this->assertSame(true, $project3->is_public); $this->assertSame(true, $project3->is_public);
$this->assertSame('#6a407f', $project3->color); $this->assertSame('#6a407f', $project3->color);
$this->assertSame(null, $project3->billable_rate); $this->assertSame(null, $project3->billable_rate);
@@ -176,7 +176,7 @@ class ImporterTestAbstract extends TestCase
/** /**
* @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario * @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario
*/ */
protected function checkTimeEntries(object $testScenario, bool $secondRun = false, bool $billableDefault = false): void protected function checkTimeEntries(object $testScenario, bool $secondRun = false): void
{ {
$timeEntries = TimeEntry::all(); $timeEntries = TimeEntry::all();
if ($secondRun) { if ($secondRun) {
@@ -197,7 +197,7 @@ class ImporterTestAbstract extends TestCase
$this->assertSame('Working hard', $timeEntry2->description); $this->assertSame('Working hard', $timeEntry2->description);
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString()); $this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString()); $this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
$this->assertSame(! $billableDefault, $timeEntry2->billable); $this->assertTrue($timeEntry2->billable);
$this->assertTrue($timeEntry2->is_imported); $this->assertTrue($timeEntry2->is_imported);
$this->assertSame([], $timeEntry2->tags); $this->assertSame([], $timeEntry2->tags);
} }

View File

@@ -11,8 +11,6 @@ use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\TogglDataImporter; use App\Service\Import\Importers\TogglDataImporter;
use Exception; use Exception;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ZipArchive;
#[CoversClass(TogglDataImporter::class)] #[CoversClass(TogglDataImporter::class)]
#[CoversClass(ImportException::class)] #[CoversClass(ImportException::class)]
@@ -90,82 +88,6 @@ class TogglDataImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->clientsCreated); $this->assertSame(0, $report->clientsCreated);
} }
public function test_import_with_path_traversal_in_project_id_is_rejected_without_touching_the_filesystem(): void
{
// Arrange
$organization = Organization::factory()->create();
$importer = new TogglDataImporter;
$importer->init($organization);
$markerDir = sys_get_temp_dir().'/solidtime_path_traversal_'.uniqid();
$this->assertDirectoryDoesNotExist($markerDir);
// Enough "../" to reach the filesystem root from any temp location, then
// back down into the attacker-chosen marker directory. The importer
// appends ".json", so the parent directory Spatie's TemporaryDirectory
// would auto-create for the resolved path is exactly $markerDir.
$traversalId = str_repeat('../', 40).ltrim($markerDir, '/').'/probe';
$data = file_get_contents($this->buildTogglZipWithProjectId($traversalId));
// Act
try {
$importer->importData($data, 'Europe/Vienna');
$this->fail('Expected ImportException was not thrown');
} catch (ImportException $e) {
// Rejected by the identifier guard, not by a downstream
// "missing in ZIP" error (which would mean the sink was reached
// and the directory had already been created).
$this->assertSame('Invalid identifier in import data', $e->getMessage());
}
// Assert: no directory was created outside the import sandbox.
$this->assertDirectoryDoesNotExist($markerDir);
}
public function test_import_with_valid_numeric_project_id_is_accepted(): void
{
// Arrange
$organization = Organization::factory()->create();
$importer = new TogglDataImporter;
$importer->init($organization);
// A legitimate Toggl numeric id must still pass the guard. The
// projects_users file is intentionally absent, so the importer fails
// with the ordinary "missing in ZIP" error rather than the guard error.
$data = file_get_contents($this->buildTogglZipWithProjectId(402));
// Act
try {
$importer->importData($data, 'Europe/Vienna');
$this->fail('Expected ImportException was not thrown');
} catch (ImportException $e) {
// Assert: the numeric id passed the guard and reached the ZIP
// content check (proving valid data is not rejected).
$this->assertSame('File "projects_users/402.json" missing in ZIP', $e->getMessage());
}
}
private function buildTogglZipWithProjectId(mixed $projectId): string
{
$tempDir = TemporaryDirectory::make();
$zipPath = $tempDir->path('traversal.zip');
$zip = new ZipArchive;
$zip->open($zipPath, ZipArchive::CREATE);
$zip->addFromString('clients.json', '[]');
$zip->addFromString('tags.json', '[]');
$zip->addFromString('workspace_users.json', '[]');
$zip->addFromString('projects.json', (string) json_encode([[
'id' => $projectId,
'client_id' => null,
'color' => '#ff0000',
'billable' => false,
'is_private' => false,
'rate' => null,
'name' => 'Traversal',
]]));
$zip->close();
return $zipPath;
}
public function test_import_of_user_with_unknown_timezone_will_be_mapped_to_utc(): void public function test_import_of_user_with_unknown_timezone_will_be_mapped_to_utc(): void
{ {
// Arrange // Arrange

View File

@@ -14,7 +14,6 @@ async function getConfig() {
const additionalPlugins = await collectModulePlugins('extensions'); const additionalPlugins = await collectModulePlugins('extensions');
return defineConfig({ return defineConfig({
base: './',
build: { build: {
sourcemap: true, // Source map generation must be turned on sourcemap: true, // Source map generation must be turned on
}, },