Compare commits

..

3 Commits

Author SHA1 Message Date
Gregor Vostrak
2da0146651 keep tasks visible when search term matches project or client name 2026-06-30 13:23:17 +02:00
Gregor Vostrak
1f7679145f add virtualizer to ProjectTaskDropdown component to handle bigger
project lists
2026-06-29 15:56:56 +02:00
Gregor Vostrak
7d9db18063 add pagination to client and project table 2026-06-28 17:39:54 +02:00
85 changed files with 239 additions and 1930 deletions

View File

@@ -1,54 +0,0 @@
.git
**/.git
.gitmodules
**/.gitmodules
.github
.DS_Store
.fleet
.idea
.vscode
*.log
npm-debug.log
yarn-error.log
k8s
docs
e2e
tests
docker-compose.yml
docker/local
.phpunit.cache
.phpunit.result.cache
coverage
test-results
playwright-report
blob-report
playwright/.cache
openapi.json
playwright
playwright.config.ts
vitest.config.ts
phpunit.xml
phpstan.neon
pint.json
eslint.config.mjs
tsconfig.json
jsconfig.json
postcss.config.js
tailwind.config.js
node_modules
extensions/*/node_modules
Homestead.json
Homestead.yaml
auth.json
.env.backup
.rnd
_ide_helper.php
.phpstorm.meta.php
storage/logs/*
storage/*.key

23
.github/VOUCHED.td vendored
View File

@@ -1,23 +0,0 @@
# Vouched contributors for solidtime.
#
# One handle per line, without the leading @, sorted alphabetically.
# Prefix a handle with - to denounce them, optionally followed by a reason.
# Format reference: https://github.com/mitchellh/vouch
#
# Maintainers do not need to edit this file by hand. Comment "vouch @user",
# "unvouch @user" or "denounce @user <reason>" on any issue, pull request or
# discussion and the vouch workflows will update this file.
#
# Collaborators with write access and bots are always allowed and do not need
# an entry here.
#
# Seeded 2026-07-25 from the authors of every merged pull request.
agross
candideu
KasparRosin
korridor
Onatcer
ShrootBuck
smileBeda
utlark

View File

@@ -8,8 +8,6 @@ on:
pull_request:
paths:
- '.github/workflows/build-onpremise.yml'
- '.dockerignore'
- 'extensions/manifest.json'
- 'docker/prod/**'
workflow_dispatch:
@@ -37,7 +35,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -93,23 +91,14 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}

View File

@@ -8,8 +8,6 @@ on:
pull_request:
paths:
- '.github/workflows/build-private.yml'
- '.dockerignore'
- 'extensions/manifest.json'
- 'docker/prod/**'
workflow_dispatch:
permissions:
@@ -24,7 +22,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -70,27 +68,14 @@ jobs:
run: cat .env
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
repository: solidtime-io/extension-billing
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
@@ -108,10 +93,9 @@ jobs:
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
repository: solidtime-io/extension-services
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
@@ -127,10 +111,9 @@ jobs:
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}

View File

@@ -8,7 +8,6 @@ on:
pull_request:
paths:
- '.github/workflows/build-public.yml'
- '.dockerignore'
- 'docker/prod/**'
workflow_dispatch:
@@ -37,7 +36,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -93,7 +92,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
@@ -48,7 +48,7 @@ jobs:
- name: "Run composer install"
run: composer install -n --prefer-dist
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Check code style"
uses: aglipanci/laravel-pint-action@2.6

View File

@@ -35,10 +35,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup node"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -99,10 +99,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Checkout code"
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: "Setup node"
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: '20.x'

View File

@@ -1,75 +0,0 @@
name: Vouch (check PR)
on:
pull_request_target:
types: [opened, reopened, synchronize]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
if: >-
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/recheck'))
steps:
# Pull requests of 50 changed lines or fewer skip the vouch requirement.
- name: "Measure diff size"
id: size
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number || github.event.issue.number }}
# Changes to these files do not count towards the 50-line limit.
# One extended regex per line, matched against the whole repo-relative
# path, so use a leading .* to match a file in any directory.
IGNORED: |
package-lock\.json
composer\.lock
tests/.*
e2e/.*
.*\.(test|spec)\.(ts|js|vue)
run: |
set -euo pipefail
# An empty list yields "^()$", which matches no filename. grep exits
# 1 on an empty list, so swallow that rather than fail the step.
join() { { grep -vE '^[[:space:]]*$' || true; } | paste -sd'|' -; }
ignored="^($(join <<<"$IGNORED"))$"
total=$(gh api --paginate "repos/$REPO/pulls/$PR/files" \
--jq '.[] | [.filename, .additions + .deletions] | @tsv' |
awk -F'\t' -v ignored="$ignored" '
$1 ~ ignored { next }
{ n += $2 }
END { print n+0 }')
echo "total=$total" >> "$GITHUB_OUTPUT"
echo "Countable diff size: $total line(s)"
- name: "Small patch (denounced users still blocked)"
if: fromJSON(steps.size.outputs.total) <= 50
uses: mitchellh/vouch/action/check-pr@v1.5.0
with:
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
auto-close: true
require-vouch: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Full vouch required"
if: fromJSON(steps.size.outputs.total) > 50
uses: mitchellh/vouch/action/check-pr@v1.5.0
with:
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
auto-close: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,33 +0,0 @@
name: Vouch (manage by discussion)
# Same commands as vouch-manage-by-issue.yml, but for discussion comments.
on:
discussion_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
discussions: write
jobs:
manage:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Apply vouch command"
uses: mitchellh/vouch/action/manage-by-discussion@v1.5.0
with:
discussion-number: ${{ github.event.discussion.number }}
comment-node-id: ${{ github.event.comment.node_id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,35 +0,0 @@
name: Vouch (manage by issue)
# Maintainers comment "vouch @user", "unvouch @user" or "denounce @user <reason>"
# on any issue or pull request, and this workflow updates .github/VOUCHED.td.
on:
issue_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
issues: write
pull-requests: write
jobs:
manage:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Apply vouch command"
uses: mitchellh/vouch/action/manage-by-issue@v1.5.0
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -26,10 +26,9 @@ yarn-error.log
/blob-report/
/playwright/.cache/
/coverage
/extensions/*
/extensions
!/extensions/.gitkeep
!/extensions/extensions_autoload.php
!/extensions/manifest.json
/auth.json
/modules_statuses.json
/k8s

View File

@@ -12,22 +12,6 @@ In order to keep the issues of the repository clean we decided to only use them
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
### Vouched contributors
Pull requests from authors who are not vouched are closed automatically. This lets us keep up with the volume of AI slop pull requests without a maintainer having to triage every one of them by hand.
Your pull request is not affected if any of the following applies:
- You have write access to this repository.
- Someone with write access has vouched for you. The list lives in [.github/VOUCHED.td](.github/VOUCHED.td).
- Your pull request changes 50 lines or fewer. Test files and lockfiles do not count towards that number, so a small fix that comes with tests still qualifies.
To get vouched, open an issue or discussion before you start and explain how you intend to implement the change. We will discuss the approach with you, and only once we have agreed on the implementation does a maintainer comment `vouch @your-handle`, which puts you on the list from then on.
Being vouched only stops your pull requests from being closed automatically. [Only work on approved issues](#only-work-on-approved-issues) still applies to every pull request you send.
Contributors who abuse this are denounced, and their pull requests are closed regardless of size.
### Contributor License Agreement
You'll also notice that weve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Dont worry - the process is quick and only takes a few clicks.

View File

@@ -39,8 +39,6 @@ Please open an issue or start a discussion and wait for approval before submitti
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
To keep that manageable, pull requests from authors who are not vouched are closed automatically, unless they change 50 lines or fewer. To get vouched, open an issue or discussion first and explain how you intend to implement the change. Once we have agreed on the approach, we vouch for you. See [Vouched contributors](./CONTRIBUTING.md#vouched-contributors).
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TagMatchType: string
{
use LaravelEnumHelper;
case Contains = 'contains';
case NotContains = 'not_contains';
}

View File

@@ -66,7 +66,7 @@ class UserResource extends Resource
->ignore($record?->getKey()),
])
->rule([
'email:rfc,strict',
'email',
])
->maxLength(255),
Forms\Components\Toggle::make('is_placeholder')

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,6 @@ class ProjectController extends Controller
$projects = $projectsQuery
->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
$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()
->whereBelongsTo($project, 'project')
->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
return new ProjectMemberCollection($projectMembers);

View File

@@ -59,7 +59,7 @@ class ReportController extends Controller
$filter->addBillable($properties->billable);
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType);
$filter->addTagIdsFilter($properties->tagIds?->toArray());
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
$filter->addClientIdsFilter($properties->clientIds?->toArray());
$timeEntriesQuery = $filter->get();

View File

@@ -47,7 +47,6 @@ class ReportController extends Controller
$reports = Report::query()
->orderBy('created_at', 'desc')
->orderBy('id')
->whereBelongsTo($organization, 'organization')
->paginate(config('app.pagination_per_page_default'));
@@ -97,7 +96,6 @@ class ReportController extends Controller
$properties->setClientIds($request->input('properties.client_ids', null));
$properties->setProjectIds($request->input('properties.project_ids', null));
$properties->setTagIds($request->input('properties.tag_ids', null));
$properties->setTagMatchType($request->getPropertyTagMatchType());
$properties->setTaskIds($request->input('properties.task_ids', null));
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
$timezone = $user->timezone;

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ class TimeEntryController extends Controller
$query = TimeEntry::query()
->where('organization_id', $organization->getKey())
->where('member_id', $member->getKey())
->where('user_id', $member->user_id)
->when($exclude !== null, function (Builder $q) use ($exclude): void {
$q->where('id', '!=', $exclude->getKey());
})
@@ -107,8 +107,8 @@ class TimeEntryController extends Controller
/**
* Get time entries in organization
*
* If you only need time entries for a specific user, you can filter by `member_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
* If you only need time entries for a specific user, you can filter by `user_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
*
* @return TimeEntryCollection<TimeEntryResource>
*
@@ -118,17 +118,16 @@ class TimeEntryController extends Controller
*/
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$totalCount = $timeEntriesQuery->count();
@@ -159,7 +158,7 @@ class TimeEntryController extends Controller
if ($timeEntries->count() === 0) {
Log::warning('User has has more than '.$limit.' time entries on one date', [
'date' => $lastDate->toDateString(),
'member_id' => $request->input('member_id'),
'user_id' => $request->input('user_id'),
'auth_user_id' => Auth::id(),
'limit' => $limit,
]);
@@ -195,8 +194,7 @@ class TimeEntryController extends Controller
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->select($select)
->orderBy('time_entries.start', 'desc')
->orderBy('time_entries.id');
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start'));
@@ -205,7 +203,7 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
@@ -222,10 +220,9 @@ class TimeEntryController extends Controller
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
@@ -242,7 +239,7 @@ class TimeEntryController extends Controller
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery->with([
'task',
'client',
@@ -265,7 +262,7 @@ class TimeEntryController extends Controller
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
null,
@@ -372,10 +369,9 @@ class TimeEntryController extends Controller
*/
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
@@ -386,7 +382,7 @@ class TimeEntryController extends Controller
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -422,10 +418,9 @@ class TimeEntryController extends Controller
*/
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
@@ -441,7 +436,7 @@ class TimeEntryController extends Controller
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -564,7 +559,7 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
@@ -584,7 +579,7 @@ class TimeEntryController extends Controller
{
/** @var Member $member */
$member = Member::query()->findOrFail($request->input('member_id'));
if ($member->getKey() === $this->member($organization)->getKey()) {
if ($member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:create:own');
} else {
$this->checkPermission($organization, 'time-entries:create:all');
@@ -631,10 +626,9 @@ class TimeEntryController extends Controller
*/
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
{
$member = $this->member($organization);
/** @var Member|null $newMember */
$newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member_id === $member->getKey() && ($newMember === null || $newMember->getKey() === $member->getKey())) {
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
@@ -666,10 +660,6 @@ class TimeEntryController extends Controller
}
$timeEntry->fill($request->validated());
if ($newMember !== null) {
$timeEntry->member()->associate($newMember);
$timeEntry->user()->associate($newMember->user);
}
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
@@ -699,7 +689,6 @@ class TimeEntryController extends Controller
*/
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
{
$member = $this->member($organization);
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
@@ -724,9 +713,6 @@ class TimeEntryController extends Controller
throw new AuthorizationException;
}
/** @var Member|null $newMember */
$newMember = isset($changes['member_id']) ? Member::query()->findOrFail($changes['member_id']) : null;
$project = null;
$client = null;
$overwriteClient = false;
@@ -753,7 +739,7 @@ class TimeEntryController extends Controller
continue;
}
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
$error->push($id);
continue;
@@ -763,10 +749,6 @@ class TimeEntryController extends Controller
$oldTask = $timeEntry->task;
$timeEntry->fill($changes);
if ($newMember !== null) {
$timeEntry->member()->associate($newMember);
$timeEntry->user_id = $newMember->user_id;
}
// If project is changed, but task is not, we remove the old task from the time entry
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
$timeEntry->task()->disassociate();
@@ -807,8 +789,7 @@ class TimeEntryController extends Controller
*/
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
{
$member = $this->member($organization);
if ($timeEntry->member_id === $member->getKey()) {
if ($timeEntry->member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
} else {
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
@@ -865,7 +846,7 @@ class TimeEntryController extends Controller
continue;
}
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
$error->push($id);
continue;

View File

@@ -25,7 +25,7 @@ class InvitationStoreRequest extends BaseFormRequest
return [
'email' => [
'required',
'email:rfc,strict',
'email',
],
'role' => [
'required',

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
@@ -125,11 +124,6 @@ class ReportStoreRequest extends BaseFormRequest
}
},
],
'properties.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
'properties.task_ids' => [
'nullable',
'array',
@@ -255,15 +249,6 @@ class ReportStoreRequest extends BaseFormRequest
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
}
public function getPropertyTagMatchType(): ?TagMatchType
{
if (! $this->has('properties.tag_match_type') || $this->input('properties.tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->input('properties.tag_match_type'));
}
public function getPropertyRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
@@ -140,10 +139,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -251,15 +246,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
return ExportFormat::from($this->validated('format'));
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
@@ -126,10 +125,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -213,15 +208,6 @@ class TimeEntryAggregateRequest extends BaseFormRequest
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType;
use App\Models\Client;
use App\Models\Member;
@@ -111,10 +110,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -220,15 +215,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
return ExportFormat::from($this->validated('format'));
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
@@ -104,10 +103,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -195,15 +190,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -41,7 +41,7 @@ class UserUpdateRequest extends BaseFormRequest
'max:255',
],
'email' => [
'email:rfc,strict',
'email',
'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */

View File

@@ -56,8 +56,6 @@ class DetailedReportResource extends BaseResource
'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var string|null $tag_match_type Tag match type */
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */

View File

@@ -20,11 +20,6 @@ class RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, Shoul
use Queueable;
use SerializesModels;
/**
* Delete the job if its models no longer exist.
*/
public bool $deleteWhenMissingModels = true;
public Project $project;
/**

View File

@@ -20,11 +20,6 @@ class RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, ShouldQu
use Queueable;
use SerializesModels;
/**
* Delete the job if its models no longer exist.
*/
public bool $deleteWhenMissingModels = true;
public Task $task;
/**

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
@@ -57,8 +56,6 @@ class ReportPropertiesDto implements Castable
*/
public ?Collection $tagIds = null;
public ?TagMatchType $tagMatchType = null;
/**
* @var Collection<int, string>|null
*/
@@ -118,7 +115,6 @@ class ReportPropertiesDto implements Castable
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
@@ -148,7 +144,6 @@ class ReportPropertiesDto implements Castable
'clientIds' => $value->clientIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(),
'tagMatchType' => $value->tagMatchType?->value,
'taskIds' => $value->taskIds?->toArray(),
'group' => $value->group->value,
'subGroup' => $value->subGroup->value,
@@ -221,11 +216,6 @@ class ReportPropertiesDto implements Castable
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
}
public function setTagMatchType(?TagMatchType $tagMatchType): void
{
$this->tagMatchType = $tagMatchType;
}
/**
* @param array<mixed>|null $taskIds
*/

View File

@@ -29,8 +29,7 @@ class ClockifyProjectsImporter extends DefaultImporter
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
// Newer Clockify exports no longer contain a "Client" column.
if (($record['Client'] ?? '') !== '') {
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
@@ -46,7 +45,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => isset($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,
]);
}
@@ -81,6 +80,7 @@ class ClockifyProjectsImporter extends DefaultImporter
{
$requiredFields = [
'Project',
'Client',
'Status',
'Visibility',
'Billability',

View File

@@ -72,7 +72,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if (($record['Client'] ?? '') !== '') {
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
@@ -215,6 +215,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
{
$requiredFields = [
'Project',
'Client',
'Description',
'User',
'Group',

View File

@@ -171,7 +171,7 @@ abstract class DefaultImporter implements ImporterContract
}, validate: [
'email' => [
'required',
'email:rfc,strict',
'email',
'max:255',
],
]);

View File

@@ -123,7 +123,6 @@ class TogglDataImporter extends DefaultImporter
}
foreach ($projects as $project) {
$projectExternalId = $this->guardExternalIdentifier($project->id);
$clientId = null;
if ($project->client_id !== null) {
$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,
], (string) $project->id);
if (! file_exists($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'))) {
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" missing in ZIP');
if (! file_exists($temporaryDirectory->path('projects_users/'.$project->id.'.json'))) {
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) {
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);
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) {
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
@@ -171,7 +170,6 @@ class TogglDataImporter extends DefaultImporter
}
$projectIds = $this->projectImportHelper->getExternalIds();
foreach ($projectIds as $projectIdExternal) {
$projectIdExternal = $this->guardExternalIdentifier($projectIdExternal);
if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) {
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]
public function getName(): string
{

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\TagMatchType;
use App\Models\Member;
use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder;
@@ -193,21 +192,15 @@ class TimeEntryFilter
/**
* @param array<string>|null $tagIds
*/
public function addTagIdsFilter(?array $tagIds, ?TagMatchType $tagMatchType = TagMatchType::Contains): self
public function addTagIdsFilter(?array $tagIds): self
{
if ($tagIds === null) {
return $this;
}
$tagMatchType ??= TagMatchType::Contains;
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
// An empty selection (no tag IDs and not filtering for "none") is no constraint, so apply nothing.
// This also prevents the not-contains branch from collapsing into "only entries with null tags".
if (count($tagIds) === 0 && ! $includeNone) {
return $this;
}
$tagCondition = function (Builder $builder) use ($tagIds, $includeNone): void {
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
foreach ($tagIds as $tagId) {
$builder->orWhereJsonContains('tags', $tagId);
}
@@ -216,18 +209,7 @@ class TimeEntryFilter
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
});
}
};
if ($tagMatchType === TagMatchType::NotContains) {
$this->builder->where(function (Builder $builder) use ($tagCondition, $includeNone): void {
$builder->whereNot($tagCondition);
if (! $includeNone) {
$builder->orWhereNull('tags');
}
});
} else {
$this->builder->where($tagCondition);
}
});
return $this;
}

View File

@@ -189,9 +189,7 @@ ENV WITH_HORIZON=false \
WITH_SCHEDULER=false \
WITH_REVERB=false
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
RUN test -z "$(find . -name .git -print -quit)"
COPY --link --chown=${WWWUSER}:${WWWUSER} . .
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
RUN mkdir -p \

View File

@@ -117,43 +117,6 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx })
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 }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000);

View File

@@ -1,69 +0,0 @@
import { expect } from '@playwright/test';
import { test } from '../playwright/fixtures';
import { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting';
import { createTimeEntryWithTagViaApi } from './utils/api';
// Each test registers a new user and creates test data via the API
test.describe.configure({ timeout: 30000 });
test('detailed reporting: "Does Not Contain" excludes entries with the selected tag', async ({
page,
ctx,
}) => {
const tagA = 'MatchTagA ' + Math.floor(Math.random() * 10000);
const tagB = 'MatchTagB ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
await goToReportingDetailed(page);
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
// Open the Tags dropdown, select tagA, then switch the match mode to "Does Not Contain"
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('option').filter({ hasText: tagA }).click(),
]);
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
]);
await page.keyboard.press('Escape');
// The entry with tagA is excluded; the entry with tagB remains
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
});
test('detailed reporting: toggling between "Contains" and "Does Not Contain" flips the result', async ({
page,
ctx,
}) => {
const tagA = 'ToggleTagA ' + Math.floor(Math.random() * 10000);
const tagB = 'ToggleTagB ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
await goToReportingDetailed(page);
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('option').filter({ hasText: tagA }).click(),
]);
// "Contains" tagA -> only the tagA entry is listed
await page.keyboard.press('Escape');
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagB}`)).toHaveCount(0);
// "Does Not Contain" tagA -> flips to the tagB entry
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
]);
await page.keyboard.press('Escape');
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagA}`)).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');
});
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 }) => {
const project1Name = 'MultiProj1 ' + 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);
});
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 }) => {
const clientName = 'Existing Client ' + Math.floor(1 + Math.random() * 10000);
const projectName = 'Project With Client ' + Math.floor(1 + Math.random() * 10000);

View File

@@ -502,10 +502,8 @@ test.describe('Project Task Dropdown', () => {
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();
// The trigger reflects the selected task.
await expect(page.getByText(taskName)).toBeVisible();
});
test('test that keyboard navigation selects a project', async ({ page, ctx }) => {
@@ -656,10 +654,7 @@ test.describe('Project Task Dropdown', () => {
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();
await expect(page.getByText(taskName)).toBeVisible();
});
test('test that pressing space selects the highlighted project', async ({ page, ctx }) => {

View File

@@ -1,14 +0,0 @@
{
"Billing": {
"repository": "solidtime-io/extension-billing",
"ref": "v0.0.3"
},
"Services": {
"repository": "solidtime-io/extension-services",
"ref": "v0.0.1"
},
"Invoicing": {
"repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.1"
}
}

View File

@@ -44,14 +44,11 @@ const projectCountMap = computed(() => {
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>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
]);
const columns = computed(() => [

View File

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

View File

@@ -9,10 +9,10 @@ import {
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
ComboboxVirtualizer,
} from 'reka-ui';
} from 'radix-vue';
import { Check, Plus } from '@lucide/vue';
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 { useProjectsStore } from '@/utils/useProjects';
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));
// Pinned on open so rows don't re-sort while interacting; the project list itself stays reactive.
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 sortedProjects = ref<Project[]>([]);
const shownProjects = computed(() => {
return sortedProjects.value.filter((project) => {
@@ -74,7 +65,9 @@ watch(open, (isOpen) => {
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 #content>
<!-- kept open so the list stays visible during the popover close animation -->
<div>
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<ComboboxRoot
:open="true"
v-model:search-term="searchValue"
v-model:open="open"
:model-value="currentProject"
class="relative"
:ignore-filter="true"
@update:model-value="updateValue"
@update:open="
(value: boolean) => {
if (!value) open = false;
}
">
@update:model-value="updateValue">
<ComboboxAnchor>
<ComboboxInput
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"
placeholder="Search for a project..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
<ComboboxVirtualizer
v-slot="{ option: project }"
:options="shownProjects"
:estimate-size="32"
:text-content="(p: Project) => p.name">
<ComboboxItem
:value="project"
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>
<ComboboxItem
v-for="project in shownProjects"
:key="project.id"
:value="project"
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 items-center gap-2">
<span
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>
</ComboboxVirtualizer>
:style="{ backgroundColor: project.color }"
class="w-3 h-3 rounded-full shrink-0"></span>
<span>{{ project.name }}</span>
</span>
<span
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>
<div
v-if="canCreateProjects()"
@@ -168,7 +150,7 @@ function updateValue(project: Project) {
</div>
</ComboboxContent>
</ComboboxRoot>
</div>
</UseFocusTrap>
</template>
</Dropdown>
<ProjectCreateModal

View File

@@ -57,15 +57,12 @@ const clientNameMap = computed(() => {
return map;
});
// 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.
// Convert sort props to TanStack Table format
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
]);
// Define column accessors for sorting.

View File

@@ -1,8 +1,6 @@
<script setup lang="ts">
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid';
import { Check } from '@lucide/vue';
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
@@ -16,7 +14,6 @@ import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useTagsStore } from '@/utils/useTags';
import type { TagMatchType } from '@/types/reporting';
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -25,7 +22,6 @@ const selectedProjects = defineModel<string[]>('selectedProjects', { required: t
const selectedTasks = defineModel<string[]>('selectedTasks', { required: true });
const selectedClients = defineModel<string[]>('selectedClients', { required: true });
const selectedTags = defineModel<string[]>('selectedTags', { required: true });
const tagMatchType = defineModel<TagMatchType>('tagMatchType', { required: true });
const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
@@ -39,16 +35,6 @@ const emit = defineEmits<{
const { tags } = useTagsQuery();
const tagMatchOptions: { value: TagMatchType; label: string }[] = [
{ value: 'contains', label: 'Contains' },
{ value: 'not_contains', label: 'Does Not Contain' },
];
function selectTagMatchType(value: AcceptableValue) {
tagMatchType.value = value as TagMatchType;
emit('submit');
}
async function createTag(name: string) {
return await useTagsStore().createTag(name);
}
@@ -107,34 +93,6 @@ async function createTag(name: string) {
title="Tags"
:icon="TagIcon" />
</template>
<template #content-before-list>
<div class="mt-2 border-b border-card-background-separator pb-2">
<div
id="tag-match-type-label"
class="mb-1.5 px-2 text-xs font-medium text-text-tertiary uppercase">
Match
</div>
<RadioGroupRoot
:model-value="tagMatchType"
aria-labelledby="tag-match-type-label"
class="space-y-1"
@update:model-value="selectTagMatchType">
<RadioGroupItem
v-for="option in tagMatchOptions"
:key="option.value"
:value="option.value"
class="relative flex w-full items-center rounded-md py-1.5 pl-2 pr-8 text-left text-sm font-medium text-text-secondary hover:bg-card-background-active data-[state=checked]:text-text-primary">
{{ option.label }}
<span
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<RadioGroupIndicator>
<Check class="h-4 w-4" />
</RadioGroupIndicator>
</span>
</RadioGroupItem>
</RadioGroupRoot>
</div>
</template>
</TagDropdown>
<Select v-model="billable" @update:model-value="emit('submit')">

View File

@@ -49,7 +49,6 @@ import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery';
import type { TagMatchType } from '@/types/reporting';
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -68,7 +67,6 @@ const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
const tagMatchType = ref<TagMatchType>('contains');
const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false);
@@ -124,7 +122,6 @@ const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
@@ -369,7 +366,6 @@ const tableData = computed(() => {
v-model:selected-tasks="selectedTasks"
v-model:selected-clients="selectedClients"
v-model:selected-tags="selectedTags"
v-model:tag-match-type="tagMatchType"
v-model:billable="billable"
v-model:rounding-enabled="roundingEnabled"
v-model:rounding-type="roundingType"

View File

@@ -54,7 +54,6 @@ import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar
import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import type { TagMatchType } from '@/types/reporting';
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -72,7 +71,6 @@ const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
const tagMatchType = ref<TagMatchType>('contains');
const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false);
const roundingType = ref<TimeEntryRoundingType>('nearest');
@@ -104,7 +102,6 @@ function getFilterAttributes() {
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
@@ -327,7 +324,6 @@ async function downloadExport(format: ExportFormat) {
v-model:selected-tasks="selectedTasks"
v-model:selected-clients="selectedClients"
v-model:selected-tags="selectedTags"
v-model:tag-match-type="tagMatchType"
v-model:billable="billable"
v-model:rounding-enabled="roundingEnabled"
v-model:rounding-type="roundingType"

View File

@@ -448,7 +448,6 @@ const ReportStoreRequest = z
client_ids: z.union([z.array(z.string()), z.null()]).optional(),
project_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_match_type: z.enum(['contains', 'not_contains']).optional(),
task_ids: z.union([z.array(z.string()), z.null()]).optional(),
group: TimeEntryAggregationType,
sub_group: TimeEntryAggregationType,
@@ -482,7 +481,6 @@ const DetailedReportResource = z
client_ids: z.union([z.array(z.string()), z.null()]),
project_ids: z.union([z.array(z.string()), z.null()]),
tag_ids: z.union([z.array(z.string()), z.null()]),
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]),
task_ids: z.union([z.array(z.string()), z.null()]),
rounding_type: z.union([z.string(), z.null()]),
rounding_minutes: z.union([z.number(), z.null()]),
@@ -3786,11 +3784,6 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',
@@ -4172,11 +4165,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',
@@ -4371,11 +4359,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',
@@ -4504,11 +4487,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',

View File

@@ -8,8 +8,8 @@ import {
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
ComboboxVirtualizer,
} from 'reka-ui';
} from 'radix-vue';
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import { Check, Plus } from '@lucide/vue';
@@ -26,6 +26,10 @@ const searchInput = ref<HTMLElement | null>(null);
const open = ref(false);
const searchValue = ref('');
function isClientSelected(id: string) {
return model.value === id;
}
watch(open, (isOpen) => {
if (isOpen) {
nextTick(() => {
@@ -54,23 +58,15 @@ async function addClientIfNoneExists() {
}
}
const NO_CLIENT: { id: string | null; name: string } = { id: null, name: 'No Client' };
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']);
function updateValue(client: { id: string | null; name: string }) {
@@ -85,56 +81,56 @@ function updateValue(client: { id: string | null; name: string }) {
<slot name="trigger"></slot>
</template>
<template #content>
<div>
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<ComboboxRoot
:open="true"
v-model:search-term="searchValue"
v-model:open="open"
:model-value="currentClient"
class="relative"
:ignore-filter="true"
@update:model-value="updateValue"
@update:open="
(value: boolean) => {
if (!value) open = false;
}
">
@update:model-value="updateValue">
<ComboboxAnchor>
<ComboboxInput
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"
placeholder="Search for a client..." />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
<ComboboxVirtualizer
v-slot="{ option: row }"
:options="clientRows"
:estimate-size="32"
:text-content="clientRowName">
<ComboboxItem
:value="row"
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-client-id="row.id">
<span class="min-w-0 flex-1 truncate">{{ row.name }}</span>
<span
v-if="model === row.id"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check class="h-4 w-4" />
</span>
</ComboboxItem>
</ComboboxVirtualizer>
<ComboboxItem
:value="{ id: null, name: 'No Client' }"
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">
<span>No Client</span>
<span
v-if="model === null"
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<Check class="h-4 w-4" />
</span>
</ComboboxItem>
<ComboboxItem
v-for="client in filteredClients"
:key="client.id"
:value="client"
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-client-id="client.id">
<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>
<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>
</ComboboxRoot>
</div>
</UseFocusTrap>
</template>
</Dropdown>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts" generic="T">
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 {
ComboboxAnchor,
@@ -9,16 +9,10 @@ import {
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
ComboboxVirtualizer,
} from 'reka-ui';
} from 'radix-vue';
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[]>({
default: [],
});
@@ -33,25 +27,20 @@ const props = defineProps<{
const open = ref(false);
const searchValue = ref('');
// Pinned on open so rows don't re-sort while toggling; the item list itself stays reactive.
const pinnedSelection = ref<Set<string>>(new Set());
const sortedItems = ref<T[]>([]) as Ref<T[]>;
watch(open, (isOpen) => {
if (isOpen) {
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 search = searchValue.value.toLowerCase().trim();
if (!search) return sortedItems.value;
@@ -67,23 +56,6 @@ const showNoItem = computed(() => {
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) {
if (model.value.includes(id)) {
model.value = model.value.filter((itemId) => itemId !== id);
@@ -102,44 +74,46 @@ const emit = defineEmits(['update:modelValue', 'changed', 'submit']);
<slot name="trigger"></slot>
</template>
<template #content>
<!-- kept open so the list stays visible during the popover close animation -->
<ComboboxRoot
:open="true"
v-model:search-term="searchValue"
v-model:open="open"
class="p-2"
:ignore-filter="true"
@update:open="
(value: boolean) => {
if (!value) open = false;
}
">
:filter-function="(val: string[]) => val">
<ComboboxAnchor>
<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"
:placeholder="searchPlaceholder" />
</ComboboxAnchor>
<ComboboxContent
:dismiss-able="false"
position="inline"
class="mt-2 min-w-60 max-w-80">
<ComboboxViewport class="max-h-60 overflow-y-auto">
<ComboboxVirtualizer
v-slot="{ option }"
:options="rows"
:estimate-size="ROW_HEIGHT"
:text-content="nameForRow">
<ComboboxItem
:value="keyForRow(option)"
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"
@select.prevent="toggleItem(keyForRow(option))">
<Checkbox
:checked="model.includes(keyForRow(option))"
aria-hidden="true"
:tabindex="-1"
class="pointer-events-none" />
<span class="truncate">{{ nameForRow(option) }}</span>
</ComboboxItem>
</ComboboxVirtualizer>
class="mt-2 min-w-60 max-w-80 max-h-60 overflow-y-auto">
<ComboboxViewport>
<ComboboxItem
v-if="showNoItem"
:value="NONE_ID"
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(NONE_ID)">
<Checkbox
:checked="model.includes(NONE_ID)"
aria-hidden="true"
:tabindex="-1"
class="pointer-events-none" />
<span class="truncate">{{ noItemLabel }}</span>
</ComboboxItem>
<ComboboxItem
v-for="item in filteredItems"
:key="getKeyFromItem(item)"
:value="getKeyFromItem(item)"
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>
</ComboboxContent>
</ComboboxRoot>

View File

@@ -37,24 +37,19 @@ const model = defineModel<string[]>({
const open = ref(false);
const searchValue = ref('');
// Pinned on open so rows don't re-sort while toggling; the tag list itself stays reactive.
const pinnedSelection = ref<Set<string>>(new Set());
const sortedTags = ref<Tag[]>([]);
watch(open, (isOpen) => {
if (isOpen) {
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 search = searchValue.value.toLowerCase().trim();
if (!search) return sortedTags.value;
@@ -119,7 +114,6 @@ const showCreateTagModal = ref(false);
class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
placeholder="Search for a Tag..." />
</ComboboxAnchor>
<slot name="content-before-list"></slot>
<ComboboxContent
:dismiss-able="false"
position="inline"

View File

@@ -30,6 +30,7 @@ const project = defineModel<string | null>('project', {
const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const dropdownViewport = ref<HTMLElement | null>(null);
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
const searchValue = ref('');
@@ -116,17 +117,15 @@ const flatRows = computed<FlatRow[]>(() => {
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;
if (row?.kind === 'client') return 28;
if (row?.kind === 'task') return 32;
return 38;
},
getItemKey: (index: number) => flatRows.value[index]?.key ?? index,
overscan: 12,
@@ -142,6 +141,12 @@ const visibleRows = computed(() =>
}))
);
function measureRow(el: unknown): void {
if (el instanceof HTMLElement) {
rowVirtualizer.value.measureElement(el);
}
}
// 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.
@@ -527,11 +532,6 @@ const selectedProjectColor = computed(() => {
return currentProject.value?.color || 'var(--theme-color-icon-default)';
});
const selectedClientName = computed(() => {
const clientId = currentProject.value?.client_id;
return clientId ? clientsById.value.get(clientId)?.name : undefined;
});
// This state prevents the selection to jump to random items when the mouse cursor is
// over an item and some Item in the Dropdown is selected by keyboard navigation to scroll into view
const mouseEnterHighlightActivated = ref(true);
@@ -577,24 +577,15 @@ const showCreateProject = ref(false);
:size="props.size"
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
<div
class="w-2.5 h-2.5 rounded-full shrink-0"
class="w-3 h-3 rounded-full shrink-0"
:style="{ backgroundColor: selectedProjectColor }"></div>
<span class="truncate shrink-[1] text-text-primary">{{
<span class="truncate shrink-[1] text-text-primary pr-1">{{
selectedProjectName
}}</span>
<template v-if="currentTask">
<ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" />
<ChevronRightIcon class="w-4 h-4 text-text-tertiary shrink-0" />
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
</template>
<template v-if="selectedClientName">
<span class="dark:text-text-tertiary text-text-quaternary shrink-0"
></span
>
<span
class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary"
>{{ selectedClientName }}</span
>
</template>
</Button>
<button
v-if="allowReset && project !== null"
@@ -608,7 +599,7 @@ const showCreateProject = ref(false);
</slot>
</template>
<template #content>
<div>
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
<input
ref="searchInput"
:value="searchValue"
@@ -630,6 +621,8 @@ const showCreateProject = ref(false);
<div
v-for="{ virtualRow, row } in visibleRows"
:key="row.key"
:ref="measureRow"
:data-index="virtualRow.index"
class="absolute left-0 top-0 w-full"
:style="{ transform: `translateY(${virtualRow.start}px)` }">
<div
@@ -718,7 +711,7 @@ const showCreateProject = ref(false);
<span>Create new Project</span>
</button>
</div>
</div>
</UseFocusTrap>
</template>
</Dropdown>
<ProjectCreateModal

View File

@@ -1,2 +1 @@
export type ExportFormat = 'xlsx' | 'csv' | 'ods' | 'pdf';
export type TagMatchType = 'contains' | 'not_contains';

View File

@@ -1,2 +0,0 @@
"Project","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Project members","Project manager","Note"
"Project Without Client Column","Active","Public","Yes","Task 1, Task 2","0.00","100.00","","","","0.00","0.00","100.01","0.00","Constantin Graf","",""
1 Project Status Visibility Billability Tasks Tracked (h) Estimated (h) Remaining (h) Overage (h) Progress(%) Billable (h) Non-billable (h) Billable Rate (USD) Amount (USD) Project members Project manager Note
2 Project Without Client Column Active Public Yes Task 1, Task 2 0.00 100.00 0.00 0.00 100.01 0.00 Constantin Graf

View File

@@ -1,3 +0,0 @@
"Project","Description","Task","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
"Project A","","","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 B","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 Description Task 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 A 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 B 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

@@ -1,2 +0,0 @@
"Project","Description","Task","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Client"
"Project A","Working hard","Task 1","Peter Tester","","peter.test@email.test","","Yes","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM"
1 Project Description Task User Group Email Tags Billable Start Date Start Time End Date End Time Client
2 Project A Working hard Task 1 Peter Tester peter.test@email.test Yes 03/04/2024 10:23 AM 03/04/2024 11:23:01 AM

View File

@@ -5,7 +5,7 @@
{{ __('Your currently running time entry ":description" is now running for more than 8 hours!', ['description' => $timeEntry->description]) }}
@endif
{{ __('If you forgot to stop the Time Tracker, you can do so in solidtime:') }}
{{ __('If you forgot to stop the Time Tracker you do that in solidtime:') }}
@component('mail::button', ['url' => $dashboardUrl])
{{ __('Go to solidtime') }}

View File

@@ -45,7 +45,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Assert
$response->assertStatus(200);
$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
->has('data')
->has('links')
@@ -84,12 +84,9 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
->has('links')
->has('meta')
->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

View File

@@ -13,8 +13,6 @@ use App\Models\ProjectMember;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\BillableRateService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Passport\Passport;
use Mockery\MockInterface;
@@ -83,49 +81,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$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
{
// Arrange

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1\Public;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
@@ -668,58 +667,4 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
],
]);
}
public function test_show_applies_not_contains_tag_match_type(): void
{
// Arrange
$organization = Organization::factory()->create();
$tagA = Tag::factory()->forOrganization($organization)->create();
$tagB = Tag::factory()->forOrganization($organization)->create();
// Entry with tagA (should be excluded by "does not contain tagA")
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 100)
->create([
'tags' => [$tagA->getKey()],
]);
// Entry with a different tag (should be included)
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 200)
->create([
'tags' => [$tagB->getKey()],
]);
// Entry without tags (should be included)
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 50)
->create();
$reportDto = new ReportPropertiesDto;
$reportDto->start = now()->subDays(2);
$reportDto->end = now();
$reportDto->group = TimeEntryAggregationType::Project;
$reportDto->subGroup = TimeEntryAggregationType::Task;
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
$reportDto->weekStart = Weekday::Monday;
$reportDto->timezone = 'Europe/Vienna';
$reportDto->setTagIds([$tagA->getKey()]);
$reportDto->setTagMatchType(TagMatchType::NotContains);
$report = Report::factory()->forOrganization($organization)->public()->create([
'public_until' => null,
'properties' => $reportDto,
]);
// Act
$response = $this->getJson(route('api.v1.public.reports.show'), [
'X-Api-Key' => $report->share_secret,
]);
// Assert: tagA entry (100s) excluded; tagB (200s) + untagged (50s) included
$response->assertOk();
$response->assertJson([
'data' => [
'seconds' => 250,
'grouped_type' => TimeEntryAggregationType::Project->value,
],
]);
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
@@ -52,7 +51,7 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
// Assert
$response->assertStatus(200);
$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
->has('data')
->has('links')
@@ -686,64 +685,4 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
'id' => $report->getKey(),
]);
}
public function test_store_endpoint_persists_tag_match_type(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:create',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
'name' => 'Report with tag match type',
'is_public' => false,
'properties' => [
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
'history_group' => TimeEntryAggregationType::Day->value,
'tag_ids' => [$tag->getKey()],
'tag_match_type' => TagMatchType::NotContains->value,
],
]);
// Assert
$response->assertStatus(201);
/** @var Report $report */
$report = Report::query()->findOrFail($response->json('data.id'));
$this->assertSame(TagMatchType::NotContains, $report->properties->tagMatchType);
// DetailedReportResource exposes the match type in the response
$response->assertJsonPath('data.properties.tag_match_type', TagMatchType::NotContains->value);
}
public function test_store_endpoint_rejects_invalid_tag_match_type(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:create',
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
'name' => 'Report with invalid tag match type',
'is_public' => false,
'properties' => [
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
'history_group' => TimeEntryAggregationType::Day->value,
'tag_match_type' => 'invalid_value',
],
]);
// Assert
$response->assertStatus(422);
$response->assertInvalid(['properties.tag_match_type']);
}
}

View File

@@ -44,7 +44,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract
// Assert
$response->assertStatus(200);
$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
->has('data')
->has('links')

View File

@@ -6,7 +6,6 @@ namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
@@ -24,7 +23,6 @@ use App\Models\User;
use App\Service\TimeEntryFilter;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
@@ -92,30 +90,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
}
public function test_index_endpoint_filters_by_member_id_instead_of_legacy_user_id(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$legacyUser = User::factory()->create();
$timeEntry = TimeEntry::factory()->forMember($data->member)->create([
'user_id' => $legacyUser->getKey(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
}
public function test_index_endpoint_fails_if_user_filter_is_from_different_organization(): void
{
// Arrange
@@ -150,10 +124,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'member_id' => $member->getKey(),
]));
$response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey(), 'user_id' => $user->getKey()]));
// Assert
$this->assertResponseCode($response, 200);
@@ -420,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
{
// Arrange
@@ -1799,29 +1717,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_aggregate_endpoint_filters_by_member_id_instead_of_legacy_user_id(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$legacyUser = User::factory()->create();
TimeEntry::factory()->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create([
'user_id' => $legacyUser->getKey(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
]));
// Assert
$response->assertSuccessful();
$response->assertJsonPath('data.seconds', 100);
}
public function test_aggregate_endpoint_groups_by_two_groups(): void
{
// Arrange
@@ -2869,32 +2764,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_update_endpoint_updates_user_id_when_member_id_changes(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:all',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
$timeEntry = TimeEntry::factory()->forMember($data->member)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
'member_id' => $otherMember->getKey(),
]);
// Assert
$response->assertValid();
$this->assertResponseCode($response, 200);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry->getKey(),
'member_id' => $otherMember->getKey(),
'user_id' => $otherUser->getKey(),
]);
}
public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
{
// Arrange
@@ -3231,40 +3100,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_destroy_multiple_uses_member_id_for_own_permission_checks(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:delete:own',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
$timeEntry = TimeEntry::factory()->forMember($otherMember)->create([
'user_id' => $data->user->getKey(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.time-entries.destroy-multiple', [$data->organization->getKey()]), [
'ids' => [
$timeEntry->getKey(),
],
]);
// Assert
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertExactJson([
'success' => [],
'error' => [
$timeEntry->getKey(),
],
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry->getKey(),
]);
}
public function test_destroy_multiple_deletes_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void
{
// Arrange
@@ -3676,46 +3511,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_update_multiple_uses_member_id_for_own_permission_checks(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
$timeEntry = TimeEntry::factory()->forMember($otherMember)->create([
'user_id' => $data->user->getKey(),
]);
$timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
'ids' => [
$timeEntry->getKey(),
],
'changes' => [
'description' => $timeEntriesFake->description,
],
]);
// Assert
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertExactJson([
'success' => [],
'error' => [
$timeEntry->getKey(),
],
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry->getKey(),
'description' => $timeEntry->description,
]);
}
public function test_update_multiple_updates_sets_description_to_empty_if_the_client_sends_null(): void
{
// Arrange
@@ -3762,51 +3557,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_update_multiple_updates_user_id_when_member_id_changes(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:all',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
$timeEntry1 = TimeEntry::factory()->forMember($data->member)->create();
$timeEntry2 = TimeEntry::factory()->forMember($data->member)->create();
Passport::actingAs($data->user);
// Act
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
'ids' => [
$timeEntry1->getKey(),
$timeEntry2->getKey(),
],
'changes' => [
'member_id' => $otherMember->getKey(),
],
]);
// Assert
$response->assertValid();
$response->assertStatus(200);
$response->assertExactJson([
'success' => [
$timeEntry1->getKey(),
$timeEntry2->getKey(),
],
'error' => [],
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry1->getKey(),
'member_id' => $otherMember->getKey(),
'user_id' => $otherUser->getKey(),
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry2->getKey(),
'member_id' => $otherMember->getKey(),
'user_id' => $otherUser->getKey(),
]);
}
public function test_update_multiple_updates_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void
{
// Arrange
@@ -4601,153 +4351,4 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $timeEntryWithoutTag->getKey());
}
public function test_index_endpoint_with_not_contains_tag_match_type_excludes_entries_with_tag(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
$timeEntryWithTag = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => [$tag->getKey()],
]);
$timeEntryWithEmptyTags = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => [],
]);
$timeEntryWithNullTags = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => null,
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'tag_ids' => [$tag->getKey()],
'tag_match_type' => TagMatchType::NotContains->value,
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert: the tagged entry is excluded; the untagged (empty + null) entries remain
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertJsonCount(2, 'data');
$returnedIds = collect($response->json('data'))->pluck('id');
$this->assertTrue($returnedIds->contains($timeEntryWithEmptyTags->getKey()));
$this->assertTrue($returnedIds->contains($timeEntryWithNullTags->getKey()));
$this->assertFalse($returnedIds->contains($timeEntryWithTag->getKey()));
}
public function test_index_endpoint_with_contains_tag_match_type_returns_only_entries_with_tag(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
$timeEntryWithTag = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => [$tag->getKey()],
]);
TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => [],
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'tag_ids' => [$tag->getKey()],
'tag_match_type' => TagMatchType::Contains->value,
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert: only the entry that has the tag
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $timeEntryWithTag->getKey());
}
public function test_index_endpoint_rejects_invalid_tag_match_type(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'tag_ids' => [$tag->getKey()],
'tag_match_type' => 'invalid_value',
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 422);
$response->assertInvalid(['tag_match_type']);
}
public function test_aggregate_endpoint_with_not_contains_tag_match_type_excludes_entries_with_tag(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->startWithDuration(Carbon::now()->subHour(), 100)
->create([
'tags' => [$tag->getKey()],
]);
TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->startWithDuration(Carbon::now()->subHour(), 200)
->create([
'tags' => [],
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
$data->organization->getKey(),
'tag_ids' => [$tag->getKey()],
'tag_match_type' => TagMatchType::NotContains->value,
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert: only the untagged entry (200s) is aggregated
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertJsonPath('data.seconds', 200);
}
}

View File

@@ -96,29 +96,6 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
);
}
public function test_import_of_test_file_without_client_column_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Newer Clockify exports no longer contain a "Client" column.
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_4.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project Without Client Column')->firstOrFail();
$this->assertNull($project->client_id);
$this->assertSame(100 * 3600, $project->estimated_time);
$this->assertEqualsCanonicalizing(
['Task 1', 'Task 2'],
Task::query()->where('project_id', $project->id)->pluck('name')->all(),
);
}
public function test_import_supports_activities_column_alias_for_tasks(): void
{
// Arrange

View File

@@ -136,46 +136,6 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(1, $report->tasksCreated);
}
public function test_import_of_test_file_without_client_column_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
// Newer Clockify exports no longer contain a "Client" column.
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_6.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(2, $report->projectsCreated);
$this->assertSame(0, $report->clientsCreated);
}
public function test_import_of_test_file_with_client_column_but_missing_values_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
// Rows shorter than the header are padded with null by the CSV reader.
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_7.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$this->assertSame(1, $report->timeEntriesCreated);
$this->assertSame(1, $report->projectsCreated);
$this->assertSame(0, $report->clientsCreated);
}
public function test_import_fails_if_month_in_date_is_bigger_than_12(): void
{
// Arrange

View File

@@ -11,8 +11,6 @@ use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\TogglDataImporter;
use Exception;
use PHPUnit\Framework\Attributes\CoversClass;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ZipArchive;
#[CoversClass(TogglDataImporter::class)]
#[CoversClass(ImportException::class)]
@@ -90,82 +88,6 @@ class TogglDataImporterTest extends ImporterTestAbstract
$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
{
// Arrange

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Enums\TagMatchType;
use App\Models\Client;
use App\Models\Project;
use App\Models\Tag;
@@ -251,188 +250,4 @@ class TimeEntryFilterTest extends TestCaseWithDatabase
$this->assertTrue($timeEntries->contains($timeEntryWithNoTags));
$this->assertFalse($timeEntries->contains($timeEntryWithTag2));
}
public function test_add_tag_ids_filter_not_contains_includes_entries_without_matching_tag(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create([
'tags' => [$tag1->getKey()],
]);
$timeEntryWithTag2 = TimeEntry::factory()->create([
'tags' => [$tag2->getKey()],
]);
$timeEntryWithAllTags = TimeEntry::factory()->create([
'tags' => [$tag1->getKey(), $tag2->getKey()],
]);
$timeEntryWithEmptyTags = TimeEntry::factory()->create([
'tags' => [],
]);
$timeEntryWithNullTags = TimeEntry::factory()->create([
'tags' => null,
]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addTagIdsFilter([$tag1->getKey()], TagMatchType::NotContains);
// Assert
$timeEntries = $builder->get();
$this->assertCount(3, $timeEntries);
$this->assertFalse($timeEntries->contains($timeEntryWithTag1));
$this->assertTrue($timeEntries->contains($timeEntryWithTag2));
$this->assertFalse($timeEntries->contains($timeEntryWithAllTags));
$this->assertTrue($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertTrue($timeEntries->contains($timeEntryWithNullTags));
}
public function test_add_tag_ids_filter_not_contains_with_none_excludes_entries_without_tags(): void
{
// Arrange
$tag = Tag::factory()->create();
$timeEntryWithTag = TimeEntry::factory()->create([
'tags' => [$tag->getKey()],
]);
$timeEntryWithEmptyTags = TimeEntry::factory()->create([
'tags' => [],
]);
$timeEntryWithNullTags = TimeEntry::factory()->create([
'tags' => null,
]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addTagIdsFilter([TimeEntryFilter::NONE_VALUE], TagMatchType::NotContains);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithTag));
$this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertFalse($timeEntries->contains($timeEntryWithNullTags));
}
public function test_add_tag_ids_filter_not_contains_with_multiple_tags_excludes_entries_with_any_of_them(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$tag3 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]);
$timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]);
$timeEntryWithTag3 = TimeEntry::factory()->create(['tags' => [$tag3->getKey()]]);
// a filtered tag (tag1) mixed with an unrelated one (tag3): still excluded
$timeEntryWithTag1AndTag3 = TimeEntry::factory()->create(['tags' => [$tag1->getKey(), $tag3->getKey()]]);
$timeEntryWithoutTags = TimeEntry::factory()->create(['tags' => null]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act: "does not contain tag1 or tag2" (NOT (has tag1 OR has tag2))
$filter->addTagIdsFilter([$tag1->getKey(), $tag2->getKey()], TagMatchType::NotContains);
// Assert: only entries that have neither tag1 nor tag2 remain
$timeEntries = $builder->get();
$this->assertCount(2, $timeEntries);
$this->assertFalse($timeEntries->contains($timeEntryWithTag1));
$this->assertFalse($timeEntries->contains($timeEntryWithTag2));
$this->assertTrue($timeEntries->contains($timeEntryWithTag3));
$this->assertFalse($timeEntries->contains($timeEntryWithTag1AndTag3));
$this->assertTrue($timeEntries->contains($timeEntryWithoutTags));
}
public function test_add_tag_ids_filter_contains_mode_returns_only_entries_with_tag(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]);
$timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]);
$timeEntryWithEmptyTags = TimeEntry::factory()->create(['tags' => []]);
$timeEntryWithNullTags = TimeEntry::factory()->create(['tags' => null]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act: explicit contains mode
$filter->addTagIdsFilter([$tag1->getKey()], TagMatchType::Contains);
// Assert: only the entry that has tag1
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithTag1));
$this->assertFalse($timeEntries->contains($timeEntryWithTag2));
$this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertFalse($timeEntries->contains($timeEntryWithNullTags));
}
public function test_add_tag_ids_filter_not_contains_with_none_and_tag_excludes_tagged_and_untagged(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]);
$timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]);
$timeEntryWithBothTags = TimeEntry::factory()->create(['tags' => [$tag1->getKey(), $tag2->getKey()]]);
$timeEntryWithEmptyTags = TimeEntry::factory()->create(['tags' => []]);
$timeEntryWithNullTags = TimeEntry::factory()->create(['tags' => null]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act: NOT (has tag1 OR has no tags) => has at least one tag and not tag1
$filter->addTagIdsFilter([$tag1->getKey(), TimeEntryFilter::NONE_VALUE], TagMatchType::NotContains);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertFalse($timeEntries->contains($timeEntryWithTag1));
$this->assertTrue($timeEntries->contains($timeEntryWithTag2));
$this->assertFalse($timeEntries->contains($timeEntryWithBothTags));
$this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertFalse($timeEntries->contains($timeEntryWithNullTags));
}
public function test_add_tag_ids_filter_with_empty_array_applies_no_filter(): void
{
// Arrange
$tag = Tag::factory()->create();
TimeEntry::factory()->create(['tags' => [$tag->getKey()]]);
TimeEntry::factory()->create(['tags' => []]);
TimeEntry::factory()->create(['tags' => null]);
// Act + Assert: an empty selection is no constraint in either mode
$builderNotContains = TimeEntry::query();
(new TimeEntryFilter($builderNotContains))->addTagIdsFilter([], TagMatchType::NotContains);
$this->assertCount(3, $builderNotContains->get());
$builderContains = TimeEntry::query();
(new TimeEntryFilter($builderContains))->addTagIdsFilter([], TagMatchType::Contains);
$this->assertCount(3, $builderContains->get());
}
public function test_add_tag_ids_filter_with_null_match_type_defaults_to_contains(): void
{
// Arrange
$tag = Tag::factory()->create();
$timeEntryWithTag = TimeEntry::factory()->create(['tags' => [$tag->getKey()]]);
$timeEntryWithoutTag = TimeEntry::factory()->create(['tags' => null]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act: a null match type falls back to "contains"
$filter->addTagIdsFilter([$tag->getKey()], null);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithTag));
$this->assertFalse($timeEntries->contains($timeEntryWithoutTag));
}
}