Compare commits

...

29 Commits

Author SHA1 Message Date
Gregor Vostrak
eb7233dfba anchor cross-day calendar e2e tests to Wednesday and Thursday
The tests used today and tomorrow (or yesterday) and skipped when those fell outside the visible week. The Saturday skips were also wrong for the Monday week start e2e users get, so the tests failed whenever CI ran on a Sunday. Using Wednesday and Thursday of the current week keeps both days visible whichever day the suite runs, so the day-of-week skips are gone.
2026-09-28 17:18:40 +02:00
Gregor Vostrak
398a81798b move project task count into its own sortable column
Also removes the ring around the project color dot, shrinks it slightly and tightens its spacing to the name.
2026-09-28 17:18:40 +02:00
Gregor Vostrak
d54296e66a fix live timer restarting while duration input is paused 2026-09-16 15:34:16 +02:00
Gregor Vostrak
95ddbf9ead fix placeholder users being resolved by authentication flows 2026-09-16 15:25:08 +02:00
Constantin Graf
70646a0dd4 Add additional validation for import, Enhanced ZIP extraction in importer 2026-09-16 15:02:54 +02:00
Gregor Vostrak
5b12c09747 bump invoicing extension to v0.0.6 for pagination ui package change 2026-09-07 17:13:20 +02:00
Gregor Vostrak
24023353f2 bump ui package version 2026-09-07 16:40:55 +02:00
Gregor Vostrak
720d20c10e move pagination component to ui package 2026-09-07 16:40:55 +02:00
Gregor Vostrak
82ea9af8b5 fix radix dialog focus restore behaviour 2026-09-04 14:55:09 +02:00
Gregor Vostrak
169d522da0 fix activity graph border color 2026-09-03 17:15:19 +02:00
Gregor Vostrak
45c7377802 bump invoicing extension to v0.0.5 2026-08-31 16:38:25 +02:00
Gregor Vostrak
8e57275cef bump invoicing extension version 2026-08-31 16:03:26 +02:00
Gregor Vostrak
efc6b55628 add helper functions for tanstack form and cent conversions 2026-08-31 16:03:26 +02:00
Gregor Vostrak
c12789376d fix type error and adapt formatting to new prettier version changes 2026-08-31 16:03:26 +02:00
Gregor Vostrak
4795812b60 align alter dialog positioning with other modals 2026-08-31 16:03:26 +02:00
Gregor Vostrak
0e00979ab8 make sure all invoices routes show active state in the navigation 2026-08-31 16:03:26 +02:00
Gregor Vostrak
f1426fcb5e add combobox to ui package 2026-08-31 16:03:26 +02:00
Constantin Graf
23f512d4a4 Add permissions and types for invoice recipients 2026-08-31 16:03:26 +02:00
Constantin Graf
637475e669 Add invite-only registration mode 2026-08-31 13:36:25 +02:00
Constantin Graf
3ec2abb309 Add invite-only registration mode
Support configurable on, invite-only, and off registration modes, including case-insensitive invitation checks and test coverage.
2026-08-31 13:36:25 +02:00
Gregor Vostrak
3e36b1cc01 replace TimezoneModalMismatch unit test with e2e test 2026-08-31 12:48:22 +02:00
Andrew Herron
7831bc697e Fix timezone mismatch modal posting to the removed Jetstream route
Use useUpdateUserMutation, matching UpdateProfileInformationForm.
2026-08-31 12:48:22 +02:00
Constantin Graf
602a8daa1f Suppress expected OAuth access denial reports 2026-08-31 12:42:00 +02:00
Constantin Graf
38b448a729 Suppress reporting for expected API exceptions 2026-08-31 12:42:00 +02:00
Constantin Graf
e6f071f87f Fixed formatting 2026-08-31 12:26:02 +02:00
Constantin Graf
693a1fa7e0 Test still-running email preference 2026-08-31 12:26:02 +02:00
Constantin Graf
77f14b696e Move email preference to notifications section 2026-08-31 12:26:02 +02:00
Constantin Graf
593372bae5 Add still-running email preference 2026-08-31 12:26:02 +02:00
dependabot[bot]
db9ca51fc4 Bump docker/login-action from 4 to 4.5.2
Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 12:14:38 +02:00
125 changed files with 3759 additions and 1510 deletions

View File

@@ -5,7 +5,8 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true APP_DEBUG=true
APP_URL=https://solidtime.test APP_URL=https://solidtime.test
APP_FORCE_HTTPS=false APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true # Supported values: on, off, invite-only (true/false are supported for backwards compatibility)
APP_ENABLE_REGISTRATION=on
SUPER_ADMINS=admin@example.com SUPER_ADMINS=admin@example.com
PAGINATION_PER_PAGE_DEFAULT=500 PAGINATION_PER_PAGE_DEFAULT=500

View File

@@ -141,7 +141,7 @@ jobs:
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4 uses: docker/login-action@v4.5.2
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
@@ -195,7 +195,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4 uses: docker/login-action@v4.5.2
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}

View File

@@ -177,7 +177,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v4.5.2
with: with:
registry: rg.fr-par.scw.cloud/solidtime registry: rg.fr-par.scw.cloud/solidtime
username: nologin username: nologin

View File

@@ -117,13 +117,13 @@ jobs:
${{ env.GHCR_REPO }} ${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry" - name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v4.5.2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4 uses: docker/login-action@v4.5.2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -177,13 +177,13 @@ jobs:
merge-multiple: true merge-multiple: true
- name: "Login to Docker Hub" - name: "Login to Docker Hub"
uses: docker/login-action@v4 uses: docker/login-action@v4.5.2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR" - name: "Login to GHCR"
uses: docker/login-action@v4 uses: docker/login-action@v4.5.2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Enums\RegistrationMode;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Events\NewsletterRegistered; use App\Events\NewsletterRegistered;
use App\Models\User; use App\Models\User;
use App\Service\InvitationService;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use App\Service\UserService; use App\Service\UserService;
@@ -31,13 +33,14 @@ class CreateNewUser implements CreatesNewUsers
*/ */
public function create(array $input): User public function create(array $input): User
{ {
if (! config('app.enable_registration')) { $registrationMode = RegistrationMode::fromConfig(config('app.enable_registration'));
if ($registrationMode === RegistrationMode::Off) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => [__('Registration is disabled.')], 'email' => [__('Registration is disabled.')],
]); ]);
} }
Validator::make($input, [ $validated = Validator::make($input, [
'name' => [ 'name' => [
'required', 'required',
'string', 'string',
@@ -60,6 +63,20 @@ class CreateNewUser implements CreatesNewUsers
], ],
])->validate(); ])->validate();
if ($registrationMode === RegistrationMode::InviteOnly) {
$invitationService = app(InvitationService::class);
$email = (string) $validated['email'];
if (! $invitationService->hasAcceptedInvitationForEmail($email)) {
$message = $invitationService->hasPendingInvitationForEmail($email)
? __('Please accept the organization invitation sent to your email address before registering.')
: __('Registration is only available to invited users.');
throw ValidationException::withMessages([
'email' => [$message],
]);
}
}
$timezone = null; $timezone = null;
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) { if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
if (app(TimezoneService::class)->isValid($input['timezone'])) { if (app(TimezoneService::class)->isValid($input['timezone'])) {

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* User provider that only resolves non-placeholder users.
*
* Placeholder users are created by imports and when members are removed from an
* organization. They can share an email address with a real user, so resolving a user by
* email can return a placeholder instead of the real account. The login flow filters them
* out explicitly, but the password broker and the guard credential checks (for example the
* password confirmation) resolve users through the configured user provider.
*
* Registered as the "eloquent" provider driver in the AuthServiceProvider, so it replaces the
* built-in one for every provider in config/auth.php.
*/
class ActiveUserProvider extends EloquentUserProvider
{
/**
* @param Model|null $model
* @return Builder<Model>
*/
#[\Override]
protected function newModelQuery($model = null): Builder
{
$query = parent::newModelQuery($model);
$query->getQuery()->where('is_placeholder', '=', false);
return $query;
}
}

View File

@@ -51,7 +51,8 @@ class TimeEntrySendStillRunningMailsCommand extends Command
]) ])
->whereHas('user', function (Builder $query): void { ->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */ /** @var Builder<User> $query */
$query->where('is_placeholder', '=', false); $query->where('is_placeholder', '=', false)
->where('send_time_entry_still_running_email', '=', true);
}) })
->orderBy('created_at', 'asc') ->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void { ->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum RegistrationMode: string
{
case On = 'on';
case InviteOnly = 'invite-only';
case Off = 'off';
public static function fromConfig(mixed $value): self
{
if ($value === true) {
return self::On;
}
if ($value === false || $value === null) {
return self::Off;
}
return match (strtolower(trim((string) $value))) {
'1', 'on', 'true' => self::On,
'invite-only' => self::InviteOnly,
default => self::Off,
};
}
}

View File

@@ -60,7 +60,6 @@ abstract class ApiException extends Exception
*/ */
public function report(): bool public function report(): bool
{ {
// TODO: temporary activated
return false; return false;
} }
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
{ {
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members'; public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class CanNotRemoveOwnerFromOrganization extends ApiException class CanNotRemoveOwnerFromOrganization extends ApiException
{ {
public const string KEY = 'can_not_remove_owner_from_organization'; public const string KEY = 'can_not_remove_owner_from_organization';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
{ {
public const string KEY = 'changing_role_of_placeholder_is_not_allowed'; public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
{ {
public const string KEY = 'changing_role_to_placeholder_is_not_allowed'; public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
} }

View File

@@ -19,6 +19,16 @@ class EntityStillInUseApiException extends ApiException
public const string KEY = 'entity_still_in_use'; public const string KEY = 'entity_still_in_use';
/**
* Report the exception.
*
* @return bool true means the exception handler will not report it again
*/
public function report(): bool
{
return true;
}
/** /**
* Get the translated message for the exception. * Get the translated message for the exception.
*/ */

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class FeatureIsNotAvailableInFreePlanApiException extends ApiException class FeatureIsNotAvailableInFreePlanApiException extends ApiException
{ {
public const string KEY = 'feature_is_not_available_in_free_plan'; public const string KEY = 'feature_is_not_available_in_free_plan';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class InactiveUserCanNotBeUsedApiException extends ApiException class InactiveUserCanNotBeUsedApiException extends ApiException
{ {
public const string KEY = 'inactive_user_can_not_be_used'; public const string KEY = 'inactive_user_can_not_be_used';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class InvitationForTheEmailAlreadyExistsApiException extends ApiException class InvitationForTheEmailAlreadyExistsApiException extends ApiException
{ {
public const string KEY = 'invitation_for_the_email_already_exists'; public const string KEY = 'invitation_for_the_email_already_exists';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OnlyOwnerCanChangeOwnership extends ApiException class OnlyOwnerCanChangeOwnership extends ApiException
{ {
public const string KEY = 'only_owner_can_change_ownership'; public const string KEY = 'only_owner_can_change_ownership';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
{ {
public const string KEY = 'only_placeholders_can_be_merged_into_another_member'; public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
{ {
public const string KEY = 'organization_has_no_subscription_but_multiple_members'; public const string KEY = 'organization_has_no_subscription_but_multiple_members';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OrganizationNeedsAtLeastOneOwner extends ApiException class OrganizationNeedsAtLeastOneOwner extends ApiException
{ {
public const string KEY = 'organization_needs_at_least_one_owner'; public const string KEY = 'organization_needs_at_least_one_owner';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OverlappingTimeEntryApiException extends ApiException class OverlappingTimeEntryApiException extends ApiException
{ {
public const string KEY = 'overlapping_time_entry'; public const string KEY = 'overlapping_time_entry';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
{ {
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception'; public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class TimeEntryCanNotBeRestartedApiException extends ApiException class TimeEntryCanNotBeRestartedApiException extends ApiException
{ {
public const string KEY = 'time_entry_can_not_be_restarted'; public const string KEY = 'time_entry_can_not_be_restarted';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,14 @@ namespace App\Exceptions\Api;
class TimeEntryStillRunningApiException extends ApiException class TimeEntryStillRunningApiException extends ApiException
{ {
public const string KEY = 'time_entry_still_running'; public const string KEY = 'time_entry_still_running';
/**
* Report the exception.
*
* @return bool true means the exception handler will not report it again
*/
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
{ {
public const string KEY = 'user_is_already_member_of_organization'; public const string KEY = 'user_is_already_member_of_organization';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfProjectApiException extends ApiException class UserIsAlreadyMemberOfProjectApiException extends ApiException
{ {
public const string KEY = 'user_is_already_member_of_project'; public const string KEY = 'user_is_already_member_of_project';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserNotPlaceholderApiException extends ApiException class UserNotPlaceholderApiException extends ApiException
{ {
public const string KEY = 'user_not_placeholder'; public const string KEY = 'user_not_placeholder';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
{ {
public const string KEY = 'user_resend_email_verification_no_pending_email'; public const string KEY = 'user_resend_email_verification_no_pending_email';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,6 +7,7 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use League\OAuth2\Server\Exception\OAuthServerException;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -34,6 +35,10 @@ class Handler extends ExceptionHandler
// //
}); });
$this->dontReportWhen(fn (Throwable $e): bool => $e instanceof OAuthServerException
&& $e->getErrorType() === 'access_denied'
&& $e->getHttpStatusCode() === 401);
// A request on an untrusted host (see App\Http\Middleware\TrustHosts) // A request on an untrusted host (see App\Http\Middleware\TrustHosts)
// otherwise renders as a bare "Bad request." 400. Show a message that // otherwise renders as a bare "Bad request." 400. Show a message that
// says how to fix it instead. The framework has already converted the // says how to fix it instead. The framework has already converted the

View File

@@ -124,6 +124,10 @@ class UserController extends Controller
$user->week_start = $request->getWeekStart(); $user->week_start = $request->getWeekStart();
} }
if ($request->getSendTimeEntryStillRunningEmail() !== null) {
$user->send_time_entry_still_running_email = $request->getSendTimeEntryStillRunningEmail();
}
$user->save(); $user->save();
if ($emailToVerify !== null) { if ($emailToVerify !== null) {

View File

@@ -36,6 +36,7 @@ class OrganizationInvitationController extends Controller
} }
return redirect(route('register')) return redirect(route('register'))
->with('registration_email', $email)
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [ ->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
'organization' => $organization->name, 'organization' => $organization->name,
])) ]))

View File

@@ -24,6 +24,7 @@ class ImportRequest extends BaseFormRequest
'data' => [ 'data' => [
'required', 'required',
'string', 'string',
'max:'.config('import.max_data_size'),
], ],
]; ];
} }

View File

@@ -58,6 +58,9 @@ class UserUpdateRequest extends BaseFormRequest
'week_start' => [ 'week_start' => [
Rule::enum(Weekday::class), Rule::enum(Weekday::class),
], ],
'send_time_entry_still_running_email' => [
'boolean',
],
]; ];
} }
@@ -81,6 +84,13 @@ class UserUpdateRequest extends BaseFormRequest
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null; return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
} }
public function getSendTimeEntryStillRunningEmail(): ?bool
{
return $this->has('send_time_entry_still_running_email')
? $this->boolean('send_time_entry_still_running_email')
: null;
}
public function hasPhotoKey(): bool public function hasPhotoKey(): bool
{ {
return $this->has('photo'); return $this->has('photo');

View File

@@ -36,6 +36,8 @@ class UserResource extends BaseResource
'timezone' => $this->resource->timezone, 'timezone' => $this->resource->timezone,
/** @var Weekday $week_start Starting day of the week */ /** @var Weekday $week_start Starting day of the week */
'week_start' => $this->resource->week_start->value, 'week_start' => $this->resource->week_start->value,
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
]; ];
} }
} }

View File

@@ -38,10 +38,14 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string|null $pending_email * @property string|null $pending_email
* @property Carbon|null $email_verified_at * @property Carbon|null $email_verified_at
* @property string|null $password * @property string|null $password
* @property string|null $remember_token
* @property string|null $two_factor_secret * @property string|null $two_factor_secret
* @property string|null $two_factor_recovery_codes
* @property Carbon|null $two_factor_confirmed_at
* @property string $timezone * @property string $timezone
* @property bool $is_placeholder * @property bool $is_placeholder
* @property Weekday $week_start * @property Weekday $week_start
* @property bool $send_time_entry_still_running_email
* @property string|null $profile_photo_path * @property string|null $profile_photo_path
* @property-read Organization|null $currentOrganization * @property-read Organization|null $currentOrganization
* @property-read string $profile_photo_url * @property-read string $profile_photo_url
@@ -108,6 +112,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
'is_admin' => 'boolean', 'is_admin' => 'boolean',
'is_placeholder' => 'boolean', 'is_placeholder' => 'boolean',
'week_start' => Weekday::class, 'week_start' => Weekday::class,
'send_time_entry_still_running_email' => 'boolean',
]; ];
/** /**
@@ -117,6 +122,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
*/ */
protected $attributes = [ protected $attributes = [
'week_start' => Weekday::Monday, 'week_start' => Weekday::Monday,
'send_time_entry_still_running_email' => true,
]; ];
/** /**
@@ -147,7 +153,9 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail(); return $this->is_placeholder === false
&& in_array($this->email, config('auth.super_admins', []), true)
&& $this->hasVerifiedEmail();
} }
public function isMemberOfOrganization(Organization $organization): bool public function isMemberOfOrganization(Organization $organization): bool

View File

@@ -4,11 +4,14 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Auth\ActiveUserProvider;
use App\Models\Passport\AuthCode; use App\Models\Passport\AuthCode;
use App\Models\Passport\Client; use App\Models\Passport\Client;
use App\Models\Passport\RefreshToken; use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token; use App\Models\Passport\Token;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth;
use Laravel\Passport\Passport; use Laravel\Passport\Passport;
class AuthServiceProvider extends ServiceProvider class AuthServiceProvider extends ServiceProvider
@@ -26,6 +29,13 @@ class AuthServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// Replaces the built-in eloquent user provider, so that no authentication flow can
// resolve a placeholder user. The driver name is kept, because Passport recognizes
// only providers that are configured with the driver "eloquent".
Auth::provider('eloquent', function (Application $app, array $config): ActiveUserProvider {
return new ActiveUserProvider($app->make('hash'), $config['model']);
});
// define scopes for passport tokens // define scopes for passport tokens
Passport::tokensCan([ Passport::tokensCan([
'create' => 'Create resources', 'create' => 'Create resources',

View File

@@ -45,6 +45,7 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::registerView(function () { Fortify::registerView(function () {
return Inertia::render('Auth/Register', [ return Inertia::render('Auth/Register', [
'email' => session('registration_email', ''),
'terms_url' => config('auth.terms_url'), 'terms_url' => config('auth.terms_url'),
'privacy_policy_url' => config('auth.privacy_policy_url'), 'privacy_policy_url' => config('auth.privacy_policy_url'),
'newsletter_consent' => config('auth.newsletter_consent'), 'newsletter_consent' => config('auth.newsletter_consent'),

View File

@@ -9,11 +9,8 @@ use App\Service\Import\Importers\ImporterContract;
use App\Service\Import\Importers\ImporterProvider; use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto; use App\Service\Import\Importers\ReportDto;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImportService class ImportService
{ {
@@ -25,8 +22,6 @@ class ImportService
/** @var ImporterContract $importer */ /** @var ImporterContract $importer */
$importer = app(ImporterProvider::class)->getImporter($importerType); $importer = app(ImporterProvider::class)->getImporter($importerType);
$importer->init($organization); $importer->init($organization);
Storage::disk(config('filesystems.default'))
->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1); $lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);

View File

@@ -16,7 +16,6 @@ use Illuminate\Support\Str;
use League\Csv\Reader; use League\Csv\Reader;
use Override; use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory; use Spatie\TemporaryDirectory\TemporaryDirectory;
use ZipArchive;
class SolidtimeImporter extends DefaultImporter class SolidtimeImporter extends DefaultImporter
{ {
@@ -34,16 +33,10 @@ class SolidtimeImporter extends DefaultImporter
$temporaryDirectoryZip = null; $temporaryDirectoryZip = null;
$temporaryDirectory = null; $temporaryDirectory = null;
try { try {
$zip = new ZipArchive;
$temporaryDirectoryZip = TemporaryDirectory::make(); $temporaryDirectoryZip = TemporaryDirectory::make();
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data); file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
if ($res !== true) {
throw new ImportException('Invalid ZIP, error code: '.$res);
}
$temporaryDirectory = TemporaryDirectory::make(); $temporaryDirectory = TemporaryDirectory::make();
$zip->extractTo($temporaryDirectory->path()); app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
$zip->close();
if (! file_exists($temporaryDirectory->path('meta.json'))) { if (! file_exists($temporaryDirectory->path('meta.json'))) {
throw new ImportException('File "meta.json" missing in ZIP'); throw new ImportException('File "meta.json" missing in ZIP');

View File

@@ -13,7 +13,6 @@ use Illuminate\Support\Str;
use Override; use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory; use Spatie\TemporaryDirectory\TemporaryDirectory;
use ValueError; use ValueError;
use ZipArchive;
class TogglDataImporter extends DefaultImporter class TogglDataImporter extends DefaultImporter
{ {
@@ -26,16 +25,10 @@ class TogglDataImporter extends DefaultImporter
$temporaryDirectoryZip = null; $temporaryDirectoryZip = null;
$temporaryDirectory = null; $temporaryDirectory = null;
try { try {
$zip = new ZipArchive;
$temporaryDirectoryZip = TemporaryDirectory::make(); $temporaryDirectoryZip = TemporaryDirectory::make();
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data); file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
if ($res !== true) {
throw new ImportException('Invalid ZIP, error code: '.$res);
}
$temporaryDirectory = TemporaryDirectory::make(); $temporaryDirectory = TemporaryDirectory::make();
$zip->extractTo($temporaryDirectory->path()); app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
$zip->close();
if (! file_exists($temporaryDirectory->path('clients.json'))) { if (! file_exists($temporaryDirectory->path('clients.json'))) {
throw new ImportException('File "clients.json" missing in ZIP'); throw new ImportException('File "clients.json" missing in ZIP');
} }

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use ZipArchive;
/**
* Extracts uploaded ZIP archives with limits on file count, total uncompressed
* size and entry paths, so a small malicious archive can not fill the disk
* (decompression bomb) or write outside the target directory (zip slip).
*/
class ZipImportHelper
{
private const int CHUNK_SIZE = 1024 * 1024;
/**
* @throws ImportException
*/
public function extract(string $zipPath, string $targetPath): void
{
$zip = new ZipArchive;
$res = $zip->open($zipPath, ZipArchive::RDONLY);
if ($res !== true) {
throw new ImportException('Invalid ZIP, error code: '.$res);
}
try {
$maxFiles = (int) config('import.zip_max_files');
$maxUncompressedSize = (int) config('import.zip_max_uncompressed_size');
if ($zip->numFiles > $maxFiles) {
throw new ImportException('ZIP contains too many files, maximum is '.$maxFiles);
}
// Check the sizes declared in the archive before writing anything to disk
$declaredSize = 0;
for ($index = 0; $index < $zip->numFiles; $index++) {
$stat = $zip->statIndex($index);
if ($stat === false) {
throw new ImportException('Invalid ZIP entry');
}
$this->validateEntryName($stat['name']);
$declaredSize += $stat['size'];
if ($declaredSize > $maxUncompressedSize) {
throw new ImportException('ZIP uncompressed size exceeds the maximum of '.$maxUncompressedSize.' bytes');
}
}
// The declared sizes can be forged, so the written bytes are counted as well
$writtenSize = 0;
for ($index = 0; $index < $zip->numFiles; $index++) {
$stat = $zip->statIndex($index);
if ($stat === false) {
throw new ImportException('Invalid ZIP entry');
}
$name = $stat['name'];
$entryPath = $targetPath.DIRECTORY_SEPARATOR.$name;
if (str_ends_with($name, '/')) {
$this->ensureDirectoryExists($entryPath);
continue;
}
$this->ensureDirectoryExists(dirname($entryPath));
$stream = $zip->getStreamIndex($index);
if ($stream === false) {
throw new ImportException('ZIP entry "'.$name.'" can not be read');
}
$target = fopen($entryPath, 'wb');
if ($target === false) {
fclose($stream);
throw new ImportException('ZIP entry "'.$name.'" can not be extracted');
}
try {
while (! feof($stream)) {
$chunk = fread($stream, self::CHUNK_SIZE);
if ($chunk === false) {
throw new ImportException('ZIP entry "'.$name.'" can not be read');
}
$writtenSize += strlen($chunk);
if ($writtenSize > $maxUncompressedSize) {
throw new ImportException('ZIP uncompressed size exceeds the maximum of '.$maxUncompressedSize.' bytes');
}
fwrite($target, $chunk);
}
} finally {
fclose($target);
fclose($stream);
}
}
} finally {
$zip->close();
}
}
/**
* @throws ImportException
*/
private function validateEntryName(string $name): void
{
if ($name === '' || str_contains($name, "\0") || str_contains($name, '\\') || str_starts_with($name, '/')) {
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
}
if (preg_match('/^[a-zA-Z]:/', $name) === 1) {
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
}
foreach (explode('/', rtrim($name, '/')) as $segment) {
if ($segment === '' || $segment === '..') {
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
}
}
}
/**
* @throws ImportException
*/
private function ensureDirectoryExists(string $path): void
{
if (is_dir($path)) {
return;
}
if (! mkdir($path, 0700, true) && ! is_dir($path)) {
throw new ImportException('Directory "'.$path.'" can not be created');
}
}
}

View File

@@ -18,6 +18,22 @@ use Illuminate\Support\Facades\Mail;
class InvitationService class InvitationService
{ {
public function hasAcceptedInvitationForEmail(string $email): bool
{
return OrganizationInvitation::query()
->whereRaw('lower(email) = ?', [strtolower($email)])
->whereNotNull('accepted_at')
->exists();
}
public function hasPendingInvitationForEmail(string $email): bool
{
return OrganizationInvitation::query()
->whereRaw('lower(email) = ?', [strtolower($email)])
->whereNull('accepted_at')
->exists();
}
/** /**
* @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException * @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException
*/ */

View File

@@ -218,7 +218,16 @@ class MemberService
$placeholderUser = $user->replicate(); $placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true; $placeholderUser->is_placeholder = true;
$placeholderUser->current_team_id = $member->organization_id; // Reset authentication relevant properties on the placeholder user
$placeholderUser->password = null;
$placeholderUser->remember_token = null;
$placeholderUser->two_factor_secret = null;
$placeholderUser->two_factor_recovery_codes = null;
$placeholderUser->two_factor_confirmed_at = null;
$placeholderUser->email_verified_at = null;
$placeholderUser->pending_email = null;
$placeholderUser->current_team_id = null;
$placeholderUser->profile_photo_path = null;
$placeholderUser->save(); $placeholderUser->save();
$member->user()->associate($placeholderUser); $member->user()->associate($placeholderUser);

View File

@@ -80,6 +80,10 @@ class PermissionStore
'invoices:update', 'invoices:update',
'invoices:download', 'invoices:download',
'invoices:delete', 'invoices:delete',
'invoice-recipients:view',
'invoice-recipients:create',
'invoice-recipients:update',
'invoice-recipients:delete',
'invoice-settings:view', 'invoice-settings:view',
'invoice-settings:update', 'invoice-settings:update',
], ],
@@ -147,6 +151,10 @@ class PermissionStore
'invoices:update', 'invoices:update',
'invoices:download', 'invoices:download',
'invoices:delete', 'invoices:delete',
'invoice-recipients:view',
'invoice-recipients:create',
'invoice-recipients:update',
'invoice-recipients:delete',
'invoice-settings:view', 'invoice-settings:view',
'invoice-settings:update', 'invoice-settings:update',
], ],
@@ -203,6 +211,10 @@ class PermissionStore
'invoices:update', 'invoices:update',
'invoices:download', 'invoices:download',
'invoices:delete', 'invoices:delete',
'invoice-recipients:view',
'invoice-recipients:create',
'invoice-recipients:update',
'invoice-recipients:delete',
'invoice-settings:view', 'invoice-settings:view',
'invoice-settings:update', 'invoice-settings:update',
], ],

View File

@@ -100,7 +100,7 @@ return [
'force_https' => (bool) env('APP_FORCE_HTTPS', false), 'force_https' => (bool) env('APP_FORCE_HTTPS', false),
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false), 'enable_registration' => env('APP_ENABLE_REGISTRATION', 'off'),
'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false), 'local_email_verification' => (bool) env('APP_LOCAL_EMAIL_VERIFICATION', false),

34
config/import.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Import payload limit
|--------------------------------------------------------------------------
|
| Maximum length of the base64 encoded "data" field of an import request in
| bytes. Requests with a larger payload are rejected with a validation error.
|
*/
'max_data_size' => (int) (env('IMPORT_MAX_DATA_SIZE') ?: 50 * 1024 * 1024),
/*
|--------------------------------------------------------------------------
| ZIP extraction limits
|--------------------------------------------------------------------------
|
| Limits applied to ZIP based importers before and during extraction to
| protect the instance against decompression bombs. The uncompressed size
| is the sum of all files in the archive in bytes.
|
*/
'zip_max_files' => (int) (env('IMPORT_ZIP_MAX_FILES') ?: 100),
'zip_max_uncompressed_size' => (int) (env('IMPORT_ZIP_MAX_UNCOMPRESSED_SIZE') ?: 500 * 1024 * 1024),
];

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->boolean('send_time_entry_still_running_email')->default(true)->after('week_start');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('send_time_entry_still_running_email');
});
}
};

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Placeholder users used to be created as a full copy of the user they were made from,
* which included the credentials and the account state of that user. A placeholder is a
* stand-in for a person in one organization, not an account, and the row shares the email
* address with the real account, so these values are removed from the placeholders that
* already exist. The organization a placeholder belongs to is recorded on its member row.
*/
public function up(): void
{
DB::table('users')
->where('is_placeholder', '=', true)
->where(function (Builder $builder): void {
$builder->whereNotNull('password')
->orWhereNotNull('remember_token')
->orWhereNotNull('two_factor_secret')
->orWhereNotNull('two_factor_recovery_codes')
->orWhereNotNull('two_factor_confirmed_at')
->orWhereNotNull('email_verified_at')
->orWhereNotNull('pending_email')
->orWhereNotNull('current_team_id')
->orWhereNotNull('profile_photo_path');
})
->update([
'password' => null,
'remember_token' => null,
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
'email_verified_at' => null,
'pending_email' => null,
'current_team_id' => null,
'profile_photo_path' => null,
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

@@ -53,6 +53,47 @@ function todayAt(hour: number, minute: number = 0): string {
return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
} }
// Cross-day tests anchor to Wednesday and Thursday of the current week instead of today and
// tomorrow. E2E users start the week on Monday, so both days are always in the visible week,
// whichever day the suite runs on.
function wednesdayAt(hour: number, minute: number = 0): Date {
const now = new Date();
const daysSinceMonday = (now.getDay() + 6) % 7;
return new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - daysSinceMonday + 2,
hour,
minute,
0,
0
);
}
function thursdayAt(hour: number, minute: number = 0): Date {
const d = wednesdayAt(hour, minute);
d.setDate(d.getDate() + 1);
return d;
}
function toApiTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
// Local calendar date (YYYY-MM-DD), matching FullCalendar's data-date attributes
function toDateStr(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
}
async function columnHeaderCenterX(page: Page, date: Date): Promise<number> {
const header = page.locator(`.fc-col-header-cell[data-date="${toDateStr(date)}"]`);
await expect(header).toBeVisible();
const box = await header.boundingBox();
return box!.x + box!.width / 2;
}
/** /**
* These tests verify that changing the project on a time entry via the calendar * These tests verify that changing the project on a time entry via the calendar
* updates the billable status to match the new project's is_billable setting. * updates the billable status to match the new project's is_billable setting.
@@ -962,14 +1003,9 @@ test.describe('Drag-to-Move Events', () => {
page, page,
ctx, ctx,
}) => { }) => {
const now = new Date(); // Create entry: Thursday 00:30 → Thursday 01:30 (1 hour, near midnight)
const dayOfWeek = now.getDay(); const start = toApiTimestamp(thursdayAt(0, 30));
// Need today to have a previous day visible in the week view (skip Sunday with Monday week start) const end = toApiTimestamp(thursdayAt(1, 30));
test.skip(dayOfWeek === 1, 'Skipping on Monday — previous day not visible in week view');
// Create entry: today 00:30 → today 01:30 (1 hour, near midnight)
const start = todayAt(0, 30);
const end = todayAt(1, 30);
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Drag up past midnight test', description: 'Drag up past midnight test',
start, start,
@@ -978,11 +1014,10 @@ test.describe('Drag-to-Move Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00'); await scrollCalendarToTime(page, '00:00:00');
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const thursdayCol = page.locator(
.toISOString() `.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
.split('T')[0]; );
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`); const event = thursdayCol
const event = todayCol
.locator('.fc-event') .locator('.fc-event')
.filter({ hasText: 'Drag up past midnight test' }); .filter({ hasText: 'Drag up past midnight test' });
await expect(event).toBeVisible({ timeout: 10000 }); await expect(event).toBeVisible({ timeout: 10000 });
@@ -1018,10 +1053,7 @@ test.describe('Drag-to-Move Events', () => {
expect(newDurationMs).toBe(3600000); expect(newDurationMs).toBe(3600000);
// The event should have moved to the previous day // The event should have moved to the previous day
const yesterdayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) expect(toDateStr(newStart)).toBe(toDateStr(wednesdayAt(0)));
.toISOString()
.split('T')[0];
expect(newStart.toISOString().split('T')[0]).toBe(yesterdayStr);
}); });
}); });
@@ -1261,8 +1293,8 @@ test.describe('Resize Events', () => {
}); });
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => { test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
const start = todayAt(10); const start = toApiTimestamp(wednesdayAt(10));
const end = todayAt(11); const end = toApiTimestamp(wednesdayAt(11));
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Cross resize end test', description: 'Cross resize end test',
start, start,
@@ -1271,26 +1303,8 @@ test.describe('Resize Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00'); await scrollCalendarToTime(page, '09:00:00');
// Find a column AFTER today (for end resize, end must be > start) // Resize into Thursday's column (for end resize, end must be > start)
const headers = page.locator('.fc-col-header-cell'); const targetX = await columnHeaderCenterX(page, thursdayAt(0));
const headerCount = await headers.count();
let targetX: number | undefined;
let todayIndex = -1;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
todayIndex = i;
break;
}
}
// Pick first column after today, or skip if today is last
for (let i = todayIndex + 1; i < headerCount; i++) {
const box = await headers.nth(i).boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
test.skip(targetX === undefined, 'No column after today to resize to');
const event = page const event = page
.locator('.fc-event') .locator('.fc-event')
@@ -1318,7 +1332,7 @@ test.describe('Resize Events', () => {
// First drag down vertically to engage resize (like test 3.1) // First drag down vertically to engage resize (like test 3.1)
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 }); await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
// Then move horizontally to a later day column // Then move horizontally to a later day column
await page.mouse.move(targetX!, bottomY + slotHeight * 4, { steps: 10 }); await page.mouse.move(targetX, bottomY + slotHeight * 4, { steps: 10 });
await page.mouse.up(); await page.mouse.up();
})(), })(),
]); ]);
@@ -1340,8 +1354,8 @@ test.describe('Resize Events', () => {
page, page,
ctx, ctx,
}) => { }) => {
const start = todayAt(10); const start = toApiTimestamp(wednesdayAt(10));
const end = todayAt(14); const end = toApiTimestamp(wednesdayAt(14));
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Cross resize end test', description: 'Cross resize end test',
start, start,
@@ -1350,30 +1364,8 @@ test.describe('Resize Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00'); await scrollCalendarToTime(page, '09:00:00');
// Find a non-today column header that is AFTER today (later day needed for end-edge resize) // Resize into Thursday's column (later day needed for end-edge resize)
const headers = page.locator('.fc-col-header-cell'); const targetX = await columnHeaderCenterX(page, thursdayAt(0));
const headerCount = await headers.count();
let targetX: number | undefined;
let foundToday = false;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
foundToday = true;
continue;
}
if (foundToday) {
const box = await header.boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
}
// If today is the last column, use the one before today instead won't work for end resize,
// so skip this test in that edge case
if (targetX === undefined) {
test.skip();
return;
}
const event = page const event = page
.locator('.fc-event') .locator('.fc-event')
@@ -1398,7 +1390,7 @@ test.describe('Resize Events', () => {
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.down(); await page.mouse.down();
// Move to a different day column at same Y position // Move to a different day column at same Y position
await page.mouse.move(targetX!, bottomY - 3, { steps: 15 }); await page.mouse.move(targetX, bottomY - 3, { steps: 15 });
await page.mouse.up(); await page.mouse.up();
})(), })(),
]); ]);
@@ -1464,22 +1456,9 @@ test.describe('Resize Events', () => {
}); });
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => { test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
// Create entry spanning today evening → tomorrow morning // Create entry spanning Wednesday evening → Thursday morning
const start = todayAt(20); const start = toApiTimestamp(wednesdayAt(20));
const tomorrow = new Date(); const end = toApiTimestamp(thursdayAt(10));
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
const end = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
10,
0,
0,
0
)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi-day end resize', description: 'Multi-day end resize',
@@ -1488,14 +1467,14 @@ test.describe('Resize Events', () => {
}); });
await goToCalendar(page); await goToCalendar(page);
// Check if tomorrow column is visible const thursdayCol = page.locator(
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`); `.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view'); );
await scrollCalendarToTime(page, '09:00:00'); await scrollCalendarToTime(page, '09:00:00');
// Find the event segment on tomorrow's column // Find the event segment on Thursday's column
const event = tomorrowCol const event = thursdayCol
.locator('.fc-event') .locator('.fc-event')
.filter({ hasText: 'Multi-day end resize' }) .filter({ hasText: 'Multi-day end resize' })
.first(); .first();
@@ -1541,22 +1520,9 @@ test.describe('Resize Events', () => {
page, page,
ctx, ctx,
}) => { }) => {
// Create entry spanning today → tomorrow // Create entry spanning Wednesday → Thursday
const start = todayAt(10); const start = toApiTimestamp(wednesdayAt(10));
const tomorrow = new Date(); const end = toApiTimestamp(thursdayAt(14));
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
const end = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
14,
0,
0,
0
)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Backward end resize multi', description: 'Backward end resize multi',
@@ -1565,29 +1531,16 @@ test.describe('Resize Events', () => {
}); });
await goToCalendar(page); await goToCalendar(page);
// Check if tomorrow column is visible const thursdayCol = page.locator(
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`); `.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view'); );
await scrollCalendarToTime(page, '12:00:00'); await scrollCalendarToTime(page, '12:00:00');
// Find today's column header to get its X center const wednesdayX = await columnHeaderCenterX(page, wednesdayAt(0));
const headers = page.locator('.fc-col-header-cell');
const headerCount = await headers.count();
let todayX: number | undefined;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
const box = await header.boundingBox();
todayX = box!.x + box!.width / 2;
break;
}
}
test.skip(todayX === undefined, 'Could not find today column header');
// Find event segment on tomorrow's column and resize end backward to today // Find event segment on Thursday's column and resize end backward to Wednesday
const event = tomorrowCol const event = thursdayCol
.locator('.fc-event') .locator('.fc-event')
.filter({ hasText: 'Backward end resize multi' }) .filter({ hasText: 'Backward end resize multi' })
.first(); .first();
@@ -1609,9 +1562,9 @@ test.describe('Resize Events', () => {
await page.mouse.move(centerX, bottomY - 3); await page.mouse.move(centerX, bottomY - 3);
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.down(); await page.mouse.down();
// Drag down a bit first, then move to today's column at a Y after the start // Drag down a bit first, then move to Wednesday's column at a Y after the start
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 }); await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
await page.mouse.move(todayX!, bottomY + slotHeight, { steps: 10 }); await page.mouse.move(wednesdayX, bottomY + slotHeight, { steps: 10 });
await page.mouse.up(); await page.mouse.up();
})(), })(),
]); ]);
@@ -1627,8 +1580,8 @@ test.describe('Resize Events', () => {
}); });
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => { test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
const start = todayAt(10); const start = toApiTimestamp(thursdayAt(10));
const end = todayAt(14); const end = toApiTimestamp(thursdayAt(14));
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'End before start test', description: 'End before start test',
start, start,
@@ -1637,25 +1590,8 @@ test.describe('Resize Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00'); await scrollCalendarToTime(page, '09:00:00');
// Find a column BEFORE today // Target Wednesday's column, the day before the entry
const headers = page.locator('.fc-col-header-cell'); const targetX = await columnHeaderCenterX(page, wednesdayAt(0));
const headerCount = await headers.count();
let targetX: number | undefined;
let todayIndex = -1;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
todayIndex = i;
break;
}
}
for (let i = todayIndex - 1; i >= 0; i--) {
const box = await headers.nth(i).boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
test.skip(targetX === undefined, 'No column before today to test');
const event = page const event = page
.locator('.fc-event') .locator('.fc-event')
@@ -1683,7 +1619,7 @@ test.describe('Resize Events', () => {
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.down(); await page.mouse.down();
// Move to earlier column at a Y position near the top of the grid (before start time) // Move to earlier column at a Y position near the top of the grid (before start time)
await page.mouse.move(targetX!, eventBox!.y - slotHeight * 4, { steps: 15 }); await page.mouse.move(targetX, eventBox!.y - slotHeight * 4, { steps: 15 });
await page.mouse.up(); await page.mouse.up();
// Wait for any potential API call // Wait for any potential API call
@@ -1732,30 +1668,21 @@ test.describe('Click-Drag Selection to Create', () => {
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({ test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
page, page,
}) => { }) => {
const now = new Date();
const dayOfWeek = now.getDay();
// Need today and tomorrow both visible (skip Saturday with Monday week start)
test.skip(dayOfWeek === 6, 'Skipping on Saturday — tomorrow not visible in week view');
await goToCalendar(page); await goToCalendar(page);
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
// Use mid-day times so both start and end slots are visible in the viewport // Use mid-day times so both start and end slots are visible in the viewport
await scrollCalendarToTime(page, '10:00:00'); await scrollCalendarToTime(page, '10:00:00');
// Find today's and tomorrow's columns // Find Wednesday's and Thursday's columns
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const wednesdayStr = toDateStr(wednesdayAt(0));
.toISOString() const thursdayStr = toDateStr(thursdayAt(0));
.split('T')[0];
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
.toISOString()
.split('T')[0];
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`); const wednesdayCol = page.locator(`.fc-timegrid-col[data-date="${wednesdayStr}"]`);
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`); const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${thursdayStr}"]`);
await expect(todayCol).toBeVisible(); await expect(wednesdayCol).toBeVisible();
await expect(tomorrowCol).toBeVisible(); await expect(thursdayCol).toBeVisible();
// Find the 11:00 slot (start) and 13:00 slot (end on tomorrow) // Find the 11:00 slot (start) and 13:00 slot (end on Thursday)
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first(); const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first(); const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
await expect(startSlot).toBeVisible(); await expect(startSlot).toBeVisible();
@@ -1763,18 +1690,18 @@ test.describe('Click-Drag Selection to Create', () => {
const startSlotBox = await startSlot.boundingBox(); const startSlotBox = await startSlot.boundingBox();
const endSlotBox = await endSlot.boundingBox(); const endSlotBox = await endSlot.boundingBox();
const todayColBox = await todayCol.boundingBox(); const wednesdayColBox = await wednesdayCol.boundingBox();
const tomorrowColBox = await tomorrowCol.boundingBox(); const thursdayColBox = await thursdayCol.boundingBox();
// Start drag at 11:00 on today's column // Start drag at 11:00 on Wednesday's column
const startX = todayColBox!.x + todayColBox!.width / 2; const startX = wednesdayColBox!.x + wednesdayColBox!.width / 2;
const startY = startSlotBox!.y + 2; const startY = startSlotBox!.y + 2;
// End drag at 13:00 on tomorrow's column // End drag at 13:00 on Thursday's column
const endX = tomorrowColBox!.x + tomorrowColBox!.width / 2; const endX = thursdayColBox!.x + thursdayColBox!.width / 2;
const endY = endSlotBox!.y + 2; const endY = endSlotBox!.y + 2;
// Drag from today to tomorrow — move down first, then across // Drag from Wednesday to Thursday, moving down first, then across
const slotHeight = await getSlotHeight(page); const slotHeight = await getSlotHeight(page);
await page.mouse.move(startX, startY); await page.mouse.move(startX, startY);
await page.mouse.down(); await page.mouse.down();
@@ -1792,9 +1719,9 @@ test.describe('Click-Drag Selection to Create', () => {
await expect(dialog.getByText('Start')).toBeVisible(); await expect(dialog.getByText('Start')).toBeVisible();
await expect(dialog.getByText('End')).toBeVisible(); await expect(dialog.getByText('End')).toBeVisible();
// Start date should be today, end date should be tomorrow // Start date should be Wednesday, end date should be Thursday
await expect(dialog.getByText(todayStr)).toBeVisible(); await expect(dialog.getByText(wednesdayStr)).toBeVisible();
await expect(dialog.getByText(tomorrowStr)).toBeVisible(); await expect(dialog.getByText(thursdayStr)).toBeVisible();
}); });
}); });
@@ -1876,17 +1803,9 @@ test.describe('Timezone & Localization', () => {
test.describe('Multi-Day Events', () => { test.describe('Multi-Day Events', () => {
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => { test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
// Create entry that spans from today 22:00 to tomorrow 02:00 // Create entry that spans from Wednesday 22:00 to Thursday 02:00
const now = new Date(); const start = toApiTimestamp(wednesdayAt(22));
const dayOfWeek = now.getDay(); const end = toApiTimestamp(thursdayAt(2));
// If today is Saturday (6), the entry would span to next week and may not be visible
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day entry', description: 'Multi day entry',
@@ -1901,14 +1820,8 @@ test.describe('Multi-Day Events', () => {
}); });
test('multi-day event can be edited via click', async ({ page, ctx }) => { test('multi-day event can be edited via click', async ({ page, ctx }) => {
const now = new Date(); const start = toApiTimestamp(wednesdayAt(22));
test.skip(now.getDay() === 6, 'Skip on Saturday'); const end = toApiTimestamp(thursdayAt(2));
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day edit test', description: 'Multi day edit test',
@@ -1927,14 +1840,8 @@ test.describe('Multi-Day Events', () => {
}); });
test('multi-day event context menu works', async ({ page, ctx }) => { test('multi-day event context menu works', async ({ page, ctx }) => {
const now = new Date(); const start = toApiTimestamp(wednesdayAt(22));
test.skip(now.getDay() === 6, 'Skip on Saturday'); const end = toApiTimestamp(thursdayAt(2));
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day ctx test', description: 'Multi day ctx test',
@@ -1954,16 +1861,11 @@ test.describe('Multi-Day Events', () => {
page, page,
ctx, ctx,
}) => { }) => {
const now = new Date(); // Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
const dayOfWeek = now.getDay(); const startDate = wednesdayAt(22);
// Need today and tomorrow both visible (skip Saturday) const endDate = thursdayAt(2);
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week'); const start = toApiTimestamp(startDate);
const end = toApiTimestamp(endDate);
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day drag test', description: 'Multi day drag test',
@@ -1973,12 +1875,9 @@ test.describe('Multi-Day Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00'); await scrollCalendarToTime(page, '00:00:00');
// Find the clipped segment on tomorrow's column (00:00-02:00) // Find the clipped segment on Thursday's column (00:00-02:00)
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
.toISOString() const event = thursdayCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
.split('T')[0];
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
const event = tomorrowCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
await expect(event).toBeVisible({ timeout: 10000 }); await expect(event).toBeVisible({ timeout: 10000 });
const eventBox = await event.boundingBox(); const eventBox = await event.boundingBox();
@@ -2013,27 +1912,19 @@ test.describe('Multi-Day Events', () => {
// Duration must be preserved (4 hours) // Duration must be preserved (4 hours)
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000); expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
// The start should still be on today (not jumped to tomorrow) // The start should still be on Wednesday (not jumped to Thursday)
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate()) expect(toDateStr(newStart)).toBe(toDateStr(startDate));
.toISOString()
.split('T')[0];
expect(newStart.toISOString().split('T')[0]).toBe(todayStr);
}); });
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({ test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
page, page,
ctx, ctx,
}) => { }) => {
const now = new Date(); // Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
const dayOfWeek = now.getDay(); const startDate = wednesdayAt(22);
// Need today and tomorrow both visible (skip Saturday) const endDate = thursdayAt(2);
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week'); const start = toApiTimestamp(startDate);
const end = toApiTimestamp(endDate);
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day drag up test', description: 'Multi day drag up test',
@@ -2043,12 +1934,9 @@ test.describe('Multi-Day Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00'); await scrollCalendarToTime(page, '00:00:00');
// Find the clipped segment on tomorrow's column (00:00-02:00) // Find the clipped segment on Thursday's column (00:00-02:00)
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
.toISOString() const event = thursdayCol
.split('T')[0];
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
const event = tomorrowCol
.locator('.fc-event') .locator('.fc-event')
.filter({ hasText: 'Multi day drag up test' }); .filter({ hasText: 'Multi day drag up test' });
await expect(event).toBeVisible({ timeout: 10000 }); await expect(event).toBeVisible({ timeout: 10000 });

View File

@@ -0,0 +1,32 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { getInvitationAcceptUrl } from './utils/mailpit';
test('prefills the registration email after accepting an invitation', async ({ page, browser }) => {
const memberId = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
const memberEmail = `prefill-${memberId}@invitation.test`;
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
await page.getByRole('button', { name: 'Invite Member' }).click();
await page.getByPlaceholder('Member Email').fill(memberEmail);
await page.getByRole('button', { name: 'Employee' }).click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/invitations') &&
response.request().method() === 'POST' &&
response.status() === 204
),
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
]);
const inviteeContext = await browser.newContext();
const inviteePage = await inviteeContext.newPage();
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
await inviteePage.goto(acceptUrl);
await inviteePage.waitForURL(/\/register$/);
await expect(inviteePage.getByLabel('Email')).toHaveValue(memberEmail);
await inviteeContext.close();
});

View File

@@ -20,6 +20,12 @@ function profileInformationForm(page: Page) {
.locator('xpath=ancestor::*[descendant::form][1]'); .locator('xpath=ancestor::*[descendant::form][1]');
} }
function notificationSettingsForm(page: Page) {
return page
.getByRole('heading', { name: 'Notifications', exact: true })
.locator('xpath=ancestor::*[descendant::form][1]');
}
async function saveProfileForm(page: Page): Promise<void> { async function saveProfileForm(page: Page): Promise<void> {
const form = profileInformationForm(page); const form = profileInformationForm(page);
await form.getByRole('button', { name: 'Save' }).click(); await form.getByRole('button', { name: 'Save' }).click();
@@ -50,6 +56,22 @@ test('week-start change persists across reload', async ({ page }) => {
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday'); await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
}); });
test('still-running email notification setting persists across reload', async ({ page }) => {
await goToProfilePage(page);
const form = notificationSettingsForm(page);
const checkbox = form.getByLabel('Still-running time entry reminders');
await expect(checkbox).toBeChecked();
await checkbox.uncheck();
await form.getByRole('button', { name: 'Save' }).click();
await expect(form.getByText('Saved.', { exact: true })).toBeVisible();
await page.reload();
await expect(
notificationSettingsForm(page).getByLabel('Still-running time entry reminders')
).not.toBeChecked();
});
test('profile photo can be uploaded, persists across reload, and can be removed', async ({ test('profile photo can be uploaded, persists across reload, and can be removed', async ({
page, page,
}) => { }) => {

View File

@@ -0,0 +1,89 @@
import { test, expect } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page, TestContext } from '../playwright/fixtures';
import { getCurrentUserViaApi, updateUserProfileViaApi } from './utils/api';
const BROWSER_TIMEZONE = 'Europe/Vienna';
const MISMATCHED_TIMEZONE = 'America/New_York';
test.use({ timezoneId: BROWSER_TIMEZONE });
function mismatchModal(page: Page) {
return page.getByRole('dialog').filter({ hasText: 'Timezone mismatch detected' });
}
async function openPageWithTimezoneMismatch(page: Page, ctx: TestContext) {
await updateUserProfileViaApi(ctx, { timezone: MISMATCHED_TIMEZONE });
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(mismatchModal(page)).toBeVisible();
}
test('timezone mismatch modal saves the device timezone through the users API', async ({
page,
ctx,
}) => {
await openPageWithTimezoneMismatch(page, ctx);
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'PUT' &&
response.status() === 200
),
mismatchModal(page).getByRole('button', { name: 'Update timezone' }).click(),
]);
await expect(mismatchModal(page)).toBeHidden();
const user = await getCurrentUserViaApi(ctx);
expect(user.timezone).toBe(BROWSER_TIMEZONE);
// After the automatic reload the timezones match again, so the modal stays gone.
await page.waitForLoadState('load');
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(mismatchModal(page)).toBeHidden();
});
test('timezone mismatch modal does not open for a different timezone with the same time', async ({
page,
ctx,
}) => {
// Berlin and Vienna share the same offset and DST rules, so the times match.
await updateUserProfileViaApi(ctx, { timezone: 'Europe/Berlin' });
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
// Wait until the dashboard is rendered so the modal had its chance to mount.
await expect(page.getByTestId('dashboard_view')).toBeVisible();
await expect(mismatchModal(page)).toBeHidden();
});
test('timezone mismatch modal stays open when the update fails', async ({ page, ctx }) => {
await openPageWithTimezoneMismatch(page, ctx);
await page.route('**/api/v1/users/*', (route) => {
if (route.request().method() === 'PUT') {
return route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Server error' }),
});
}
return route.fallback();
});
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/api/v1/users/') &&
response.request().method() === 'PUT' &&
response.status() === 500
),
mismatchModal(page).getByRole('button', { name: 'Update timezone' }).click(),
]);
await expect(mismatchModal(page)).toBeVisible();
await expect(page.getByText('Failed to update profile')).toBeVisible();
const user = await getCurrentUserViaApi(ctx);
expect(user.timezone).toBe(MISMATCHED_TIMEZONE);
});

View File

@@ -9,6 +9,6 @@
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.2" "ref": "v0.0.6"
} }
} }

2195
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,7 @@
"@floating-ui/core": "^1.7.5", "@floating-ui/core": "^1.7.5",
"@floating-ui/vue": "^1.1.11", "@floating-ui/vue": "^1.1.11",
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@lucide/vue": "^1.14.0", "@lucide/vue": "^1.28.0",
"@rushstack/eslint-patch": "^1.16.1", "@rushstack/eslint-patch": "^1.16.1",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.32.0", "@tanstack/vue-form": "^1.32.0",
@@ -67,7 +67,7 @@
"@tanstack/vue-virtual": "^3.13.24", "@tanstack/vue-virtual": "^3.13.24",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.4.0",
"@vueuse/integrations": "^14.3.0", "@vueuse/integrations": "^14.3.0",
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",
"chroma-js": "^3.2.0", "chroma-js": "^3.2.0",
@@ -79,7 +79,7 @@
"parse-duration": "^2.1.6", "parse-duration": "^2.1.6",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"radix-vue": "^1.9.17", "radix-vue": "^1.9.17",
"reka-ui": "^2.9.7", "reka-ui": "^2.10.1",
"tailwind-merge": "^2.6.1", "tailwind-merge": "^2.6.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vue-draggable-plus": "^0.6.1", "vue-draggable-plus": "^0.6.1",

View File

@@ -7,7 +7,7 @@ import { type Client } from '@/packages/api/src';
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue'; import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'; import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue'; import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
import Pagination from '@/Components/Common/Pagination.vue'; import Pagination from '@/packages/ui/src/Pagination.vue';
import { canCreateClients } from '@/utils/permissions'; import { canCreateClients } from '@/utils/permissions';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { import {

View File

@@ -6,11 +6,12 @@ import { computed, ref, watch } from 'vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue'; import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue'; import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue'; import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import Pagination from '@/Components/Common/Pagination.vue'; import Pagination from '@/packages/ui/src/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue'; import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn = export type SortColumn =
| 'name' | 'name'
| 'tasks'
| 'client_name' | 'client_name'
| 'spent_time' | 'spent_time'
| 'progress' | 'progress'
@@ -23,6 +24,7 @@ import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/pac
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { useClientsQuery } from '@/utils/useClientsQuery'; import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
@@ -62,6 +64,16 @@ const clientNameMap = computed(() => {
return map; return map;
}); });
const { tasks } = useTasksQuery();
const taskCountMap = computed(() => {
const map = new Map<string, number>();
tasks.value.forEach((task) => {
map.set(task.project_id, (map.get(task.project_id) ?? 0) + 1);
});
return map;
});
// Define column accessors for sorting. // Define column accessors for sorting.
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first, // Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
// while text columns default to ascending (A-Z) on first click (chevron down). // while text columns default to ascending (A-Z) on first click (chevron down).
@@ -70,6 +82,11 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
id: 'name', id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(), accessorFn: (row: Project) => row.name.toLowerCase(),
}, },
{
id: 'tasks',
sortDescFirst: true,
accessorFn: (row: Project) => taskCountMap.value.get(row.id) ?? 0,
},
{ {
id: 'client_name', id: 'client_name',
accessorFn: (row: Project) => { accessorFn: (row: Project) => {
@@ -167,7 +184,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
} }
const gridTemplate = computed(() => { const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`; return `grid-template-columns: minmax(300px, 1fr) minmax(100px, auto) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
}); });
</script> </script>

View File

@@ -36,6 +36,9 @@ function handleSort(column: SortColumn) {
@sort="handleSort"> @sort="handleSort">
Name Name
</SortableTableHeaderCell> </SortableTableHeaderCell>
<SortableTableHeaderCell column="tasks" v-bind="sortState" @sort="handleSort">
Tasks
</SortableTableHeaderCell>
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort"> <SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
Client Client
</SortableTableHeaderCell> </SortableTableHeaderCell>

View File

@@ -88,17 +88,19 @@ const showEditProjectModal = ref(false);
<ContextMenuTrigger as-child> <ContextMenuTrigger as-child>
<TableRow :href="route('projects.show', { project: project.id })"> <TableRow :href="route('projects.show', { project: project.id })">
<div <div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6"> class="whitespace-nowrap min-w-0 flex items-center space-x-3 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
<div <div
:style="{ :style="{ backgroundColor: project.color }"
backgroundColor: project.color, class="w-2.5 h-2.5 ml-1 rounded-full"></div>
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-3 h-3 ml-1 rounded-full"></div>
<span class="overflow-ellipsis overflow-hidden"> <span class="overflow-ellipsis overflow-hidden">
{{ project.name }} {{ project.name }}
</span> </span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="projectTasksCount">
{{ projectTasksCount }} {{ projectTasksCount === 1 ? 'Task' : 'Tasks' }}
</span>
<span v-else class="text-text-tertiary">--</span>
</div> </div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary"> <div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden"> <div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">

View File

@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { usePage } from '@inertiajs/vue3';
import { useForm, usePage } from '@inertiajs/vue3';
import type { User } from '@/types/models'; import type { User } from '@/types/models';
import TimezoneMismatchModal from '@/packages/ui/src/TimezoneMismatchModal.vue'; import TimezoneMismatchModal from '@/packages/ui/src/TimezoneMismatchModal.vue';
import { useUpdateUserMutation } from '@/utils/useUserQuery';
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = ref(false);
const page = usePage<{ const page = usePage<{
auth: { auth: {
@@ -13,33 +12,28 @@ const page = usePage<{
}; };
}>(); }>();
function handleUpdate(timezone: string) { const updateUser = useUpdateUserMutation();
saving.value = true;
const form = useForm({
_method: 'PUT',
timezone: timezone,
name: page.props.auth.user.name,
email: page.props.auth.user.email,
week_start: page.props.auth.user.week_start,
});
form.post(route('user-profile-information.update'), { async function handleUpdate(timezone: string) {
errorBag: 'updateProfileInformation', try {
preserveScroll: true, await updateUser.mutateAsync({
onSuccess: () => { userId: page.props.auth.user.id,
saving.value = false; body: { timezone },
show.value = false; });
location.reload(); show.value = false;
}, // reload the whole page to re-read the timezone update
onError: () => { location.reload();
saving.value = false; } catch {
}, // notification handled by mutation
}); }
} }
</script> </script>
<template> <template>
<TimezoneMismatchModal v-model:show="show" :saving="saving" @update="handleUpdate" /> <TimezoneMismatchModal
v-model:show="show"
:saving="updateUser.isPending.value"
@update="handleUpdate" />
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -63,7 +63,7 @@ const max = computed(() => {
}); });
const backgroundColor = useCssVariable('--theme-color-card-background'); const backgroundColor = useCssVariable('--theme-color-card-background');
const borderColor = useCssVariable('--color-border'); const borderColor = useCssVariable('--color-border-secondary');
const labelColor = useCssVariable('--color-text-secondary'); const labelColor = useCssVariable('--color-text-secondary');
const chartColorRaw = useCssVariable('--theme-color-chart'); const chartColorRaw = useCssVariable('--theme-color-chart');

View File

@@ -146,7 +146,10 @@ const changeSummary = computed<PlanLine[]>(() => {
{ times: range(plan.breakSlot), label: 'Break' }, { times: range(plan.breakSlot), label: 'Break' },
{ times: range(plan.secondHalf), label: workLabel }, { times: range(plan.secondHalf), label: workLabel },
...plan.shifted.map((shift) => ({ ...plan.shifted.map((shift) => ({
times: moved(req.otherEntries.find((e) => e.id === shift.id)!, shift), times: moved(
req.otherEntries.find((e) => e.id === shift.id)!,
shift
),
label: props.entryLabel(shift.id), label: props.entryLabel(shift.id),
})), })),
]; ];

View File

@@ -161,7 +161,7 @@ const emit = defineEmits<{
:organization-billable-rate="organization?.billable_rate ?? null" :organization-billable-rate="organization?.billable_rate ?? null"
:no-project-value="null" :no-project-value="null"
align="start" align="start"
@changed="(p, t) => emit('add-row', p, t)"> @changed="(p: string | null, t: string | null) => emit('add-row', p, t)">
<template #trigger> <template #trigger>
<Button variant="ghost" size="sm" class="text-text-secondary"> <Button variant="ghost" size="sm" class="text-text-secondary">
<PlusIcon class="h-4 w-4 mr-1 text-icon-default" /> <PlusIcon class="h-4 w-4 mr-1 text-icon-default" />

View File

@@ -25,16 +25,21 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template> <template>
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay <AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" /> class="fixed inset-0 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
<AlertDialogContent <div class="absolute inset-0 bg-default-background opacity-30" />
v-bind="forwarded" </AlertDialogOverlay>
:class=" <div
cn( class="fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-14 xl:pt-24 justify-center overflow-auto">
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', <AlertDialogContent
props.class v-bind="forwarded"
) :class="
"> cn(
<slot /> 'pointer-events-auto bg-default-background grid w-full max-w-lg gap-4 border border-border-tertiary p-6 shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
</AlertDialogContent> props.class
)
">
<slot />
</AlertDialogContent>
</div>
</AlertDialogPortal> </AlertDialogPortal>
</template> </template>

View File

@@ -253,7 +253,9 @@ const page = usePage<{
v-if="isInvoicingActivated() && canViewInvoices()" v-if="isInvoicingActivated() && canViewInvoices()"
title="Invoices" title="Invoices"
:icon="DocumentTextIcon" :icon="DocumentTextIcon"
:current="route().current('invoices')" :current="
route().current('invoices') || route().current('invoices.*')
"
href="/invoices"></NavigationSidebarItem> href="/invoices"></NavigationSidebarItem>
</ul> </ul>
</nav> </nav>

View File

@@ -8,9 +8,13 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue'; import TextInput from '@/packages/ui/src/Input/TextInput.vue';
const props = defineProps<{
email: string;
}>();
const form = useForm({ const form = useForm({
name: '', name: '',
email: '', email: props.email,
password: '', password: '',
password_confirmation: '', password_confirmation: '',
terms: false, terms: false,

View File

@@ -35,8 +35,7 @@ async function deleteUser() {
} catch (error) { } catch (error) {
if (error && typeof error === 'object' && 'response' in error) { if (error && typeof error === 'object' && 'response' in error) {
const response = error.response as const response = error.response as
| { status?: number; data?: { errors?: { password?: string[] } } } { status?: number; data?: { errors?: { password?: string[] } } } | undefined;
| undefined;
if (response?.status === 422) { if (response?.status === 422) {
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.'; passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
} }

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import ActionMessage from '@/Components/ActionMessage.vue';
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { Checkbox } from '@/packages/ui/src';
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldLabel,
} from '@/packages/ui/src/field';
import { getApiValidationFieldErrors } from '@/utils/apiValidation';
import { useUpdateUserMutation, useUserQuery } from '@/utils/useUserQuery';
const { user } = useUserQuery();
const updateUser = useUpdateUserMutation();
const sendTimeEntryStillRunningEmail = ref(true);
const recentlySaved = ref(false);
watch(
user,
(currentUser, previousUser) => {
if (currentUser && previousUser === undefined) {
sendTimeEntryStillRunningEmail.value = currentUser.send_time_entry_still_running_email;
}
},
{ immediate: true }
);
const isUserLoaded = computed(() => user.value !== undefined);
const isSaveDisabled = computed(() => !isUserLoaded.value || updateUser.isPending.value);
const fieldErrors = computed<Record<string, string>>(() =>
getApiValidationFieldErrors(updateUser.error.value)
);
async function save() {
if (isSaveDisabled.value || !user.value) return;
if (sendTimeEntryStillRunningEmail.value === user.value.send_time_entry_still_running_email) {
flashSaved();
return;
}
try {
const updatedUser = await updateUser.mutateAsync({
userId: user.value.id,
body: {
send_time_entry_still_running_email: sendTimeEntryStillRunningEmail.value,
},
});
sendTimeEntryStillRunningEmail.value = updatedUser.send_time_entry_still_running_email;
flashSaved();
} catch {
// 422: field errors render below. Other errors: toast handled in the mutation.
}
}
function flashSaved() {
recentlySaved.value = true;
setTimeout(() => (recentlySaved.value = false), 2000);
}
</script>
<template>
<FormSection @submitted="save">
<template #title>Notifications</template>
<template #description>Choose which email notifications you want to receive.</template>
<template #form>
<div class="col-span-6 sm:col-span-4">
<Field orientation="horizontal">
<Checkbox
id="send_time_entry_still_running_email"
v-model:checked="sendTimeEntryStillRunningEmail"
:disabled="!isUserLoaded" />
<FieldContent>
<FieldLabel for="send_time_entry_still_running_email">
Still-running time entry reminders
</FieldLabel>
<FieldDescription>
Email me when a time entry has been running for more than 8 hours.
</FieldDescription>
</FieldContent>
</Field>
<FieldError v-if="fieldErrors.send_time_entry_still_running_email">
{{ fieldErrors.send_time_entry_still_running_email }}
</FieldError>
</div>
</template>
<template #actions>
<ActionMessage :on="recentlySaved" class="me-3">Saved.</ActionMessage>
<PrimaryButton :class="{ 'opacity-25': isSaveDisabled }" :disabled="isSaveDisabled">
Save
</PrimaryButton>
</template>
</FormSection>
</template>

View File

@@ -9,6 +9,7 @@ import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfile
import type { Session } from '@/types/jetstream'; import type { Session } from '@/types/jetstream';
import ApiTokensForm from '@/Pages/Profile/Partials/ApiTokensForm.vue'; import ApiTokensForm from '@/Pages/Profile/Partials/ApiTokensForm.vue';
import ThemeForm from '@/Pages/Profile/Partials/ThemeForm.vue'; import ThemeForm from '@/Pages/Profile/Partials/ThemeForm.vue';
import NotificationSettingsForm from '@/Pages/Profile/Partials/NotificationSettingsForm.vue';
defineProps<{ defineProps<{
confirmsTwoFactorAuthentication: boolean; confirmsTwoFactorAuthentication: boolean;
@@ -36,6 +37,12 @@ defineProps<{
<SectionBorder /> <SectionBorder />
</div> </div>
<div>
<NotificationSettingsForm />
<SectionBorder />
</div>
<div> <div>
<UpdatePasswordForm class="mt-10 sm:mt-0" /> <UpdatePasswordForm class="mt-10 sm:mt-0" />

View File

@@ -9,7 +9,7 @@ import {
ArrowDownTrayIcon, ArrowDownTrayIcon,
LockClosedIcon, LockClosedIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import Pagination from '@/Components/Common/Pagination.vue'; import Pagination from '@/packages/ui/src/Pagination.vue';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,

View File

@@ -38,8 +38,7 @@ const deleteTeam = async () => {
} catch (error) { } catch (error) {
if (error && typeof error === 'object' && 'response' in error) { if (error && typeof error === 'object' && 'response' in error) {
const response = error.response as const response = error.response as
| { status?: number; data?: { errors?: { password?: string[] } } } { status?: number; data?: { errors?: { password?: string[] } } } | undefined;
| undefined;
if (response?.status === 422) { if (response?.status === 422) {
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.'; passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
} }

View File

@@ -118,6 +118,11 @@ export type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getIn
export type DetailedInvoice = DetailedInvoiceResponse['data']; export type DetailedInvoice = DetailedInvoiceResponse['data'];
export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0]; export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];
export type InvoiceRecipient = ZodiosResponseByAlias<
SolidTimeApi,
'getInvoiceRecipients'
>['data'][0];
export type InvoiceRecipientBody = ZodiosBodyByAlias<SolidTimeApi, 'createInvoiceRecipient'>;
export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>; export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;

View File

@@ -45,14 +45,54 @@ const InvitationResource = z
const InvitationStoreRequest = z const InvitationStoreRequest = z
.object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) }) .object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })
.passthrough(); .passthrough();
const InvoiceRecipientResource = z
.object({
id: z.string(),
organization_id: z.string(),
name: z.string(),
vatin: z.union([z.string(), z.null()]),
address_line_1: z.union([z.string(), z.null()]),
address_line_2: z.union([z.string(), z.null()]),
address_line_3: z.union([z.string(), z.null()]),
address_post_code: z.union([z.string(), z.null()]),
address_city: z.union([z.string(), z.null()]),
address_country: z.union([z.string(), z.null()]),
phone: z.union([z.string(), z.null()]),
email: z.union([z.string(), z.null()]),
is_archived: z.boolean(),
archived_at: z.union([z.string(), z.null()]),
invoices_count: z.number().int(),
has_non_draft_invoices: z.boolean(),
created_at: z.union([z.string(), z.null()]),
updated_at: z.union([z.string(), z.null()]),
})
.passthrough();
const InvoiceRecipientCollection = z.array(InvoiceRecipientResource);
const InvoiceRecipientRequest = z
.object({
name: z.string(),
vatin: z.union([z.string(), z.null()]).optional(),
address_line_1: z.union([z.string(), z.null()]).optional(),
address_line_2: z.union([z.string(), z.null()]).optional(),
address_line_3: z.union([z.string(), z.null()]).optional(),
address_post_code: z.union([z.string(), z.null()]).optional(),
address_city: z.union([z.string(), z.null()]).optional(),
address_country: z.union([z.string(), z.null()]).optional(),
phone: z.union([z.string(), z.null()]).optional(),
email: z.union([z.string(), z.null()]).optional(),
is_archived: z.boolean().optional(),
})
.passthrough();
const InvoiceResource = z const InvoiceResource = z
.object({ .object({
id: z.string(), id: z.string(),
organization_id: z.string(), organization_id: z.string(),
invoice_recipient_id: z.string(),
reference: z.string(), reference: z.string(),
seller_name: z.string(), seller_name: z.string(),
buyer_name: z.string(), recipient: z.string(),
status: z.string(), status: z.string(),
status_label: z.string(),
date: z.string(), date: z.string(),
due_at: z.string(), due_at: z.string(),
paid_date: z.string(), paid_date: z.string(),
@@ -76,16 +116,7 @@ const InvoiceStoreRequest = z
seller_address_country: z.union([z.string(), z.null()]).optional(), seller_address_country: z.union([z.string(), z.null()]).optional(),
seller_phone: z.union([z.string(), z.null()]).optional(), seller_phone: z.union([z.string(), z.null()]).optional(),
seller_email: z.union([z.string(), z.null()]).optional(), seller_email: z.union([z.string(), z.null()]).optional(),
buyer_name: z.string(), invoice_recipient_id: z.string(),
buyer_vatin: z.union([z.string(), z.null()]).optional(),
buyer_address_line_1: z.union([z.string(), z.null()]).optional(),
buyer_address_line_2: z.union([z.string(), z.null()]).optional(),
buyer_address_line_3: z.union([z.string(), z.null()]).optional(),
buyer_address_post_code: z.union([z.string(), z.null()]).optional(),
buyer_address_city: z.union([z.string(), z.null()]).optional(),
buyer_address_country: z.union([z.string(), z.null()]).optional(),
buyer_phone: z.union([z.string(), z.null()]).optional(),
buyer_email: z.union([z.string(), z.null()]).optional(),
date: z.string(), date: z.string(),
billing_period_start: z.union([z.string(), z.null()]).optional(), billing_period_start: z.union([z.string(), z.null()]).optional(),
billing_period_end: z.union([z.string(), z.null()]).optional(), billing_period_end: z.union([z.string(), z.null()]).optional(),
@@ -130,6 +161,7 @@ const DetailedInvoiceResource = z
.object({ .object({
id: z.string(), id: z.string(),
organization_id: z.string(), organization_id: z.string(),
invoice_recipient_id: z.string(),
reference: z.string(), reference: z.string(),
seller_name: z.string(), seller_name: z.string(),
seller_vatin: z.string(), seller_vatin: z.string(),
@@ -141,16 +173,7 @@ const DetailedInvoiceResource = z
seller_address_country: z.string(), seller_address_country: z.string(),
seller_phone: z.string(), seller_phone: z.string(),
seller_email: z.string(), seller_email: z.string(),
buyer_name: z.string(), recipient: InvoiceRecipientResource,
buyer_vatin: z.string(),
buyer_address_line_1: z.string(),
buyer_address_line_2: z.string(),
buyer_address_line_3: z.string(),
buyer_address_post_code: z.string(),
buyer_address_city: z.string(),
buyer_address_country: z.string(),
buyer_phone: z.string(),
buyer_email: z.string(),
paid_date: z.string(), paid_date: z.string(),
due_at: z.string(), due_at: z.string(),
discount_type: z.string(), discount_type: z.string(),
@@ -171,7 +194,7 @@ const DetailedInvoiceResource = z
entries: z.array(InvoiceEntryResource), entries: z.array(InvoiceEntryResource),
}) })
.passthrough(); .passthrough();
const InvoiceStatus = z.enum(['draft', 'sent', 'cancelled']); const InvoiceStatus = z.enum(['draft', 'sent', 'paid', 'cancelled']);
const InvoiceUpdateRequest = z const InvoiceUpdateRequest = z
.object({ .object({
status: InvoiceStatus, status: InvoiceStatus,
@@ -187,16 +210,7 @@ const InvoiceUpdateRequest = z
seller_address_country: z.union([z.string(), z.null()]), seller_address_country: z.union([z.string(), z.null()]),
seller_phone: z.union([z.string(), z.null()]), seller_phone: z.union([z.string(), z.null()]),
seller_email: z.union([z.string(), z.null()]), seller_email: z.union([z.string(), z.null()]),
buyer_name: z.string(), invoice_recipient_id: z.string(),
buyer_vatin: z.union([z.string(), z.null()]),
buyer_address_line_1: z.union([z.string(), z.null()]),
buyer_address_line_2: z.union([z.string(), z.null()]),
buyer_address_line_3: z.union([z.string(), z.null()]),
buyer_address_post_code: z.union([z.string(), z.null()]),
buyer_address_city: z.union([z.string(), z.null()]),
buyer_address_country: z.union([z.string(), z.null()]),
buyer_phone: z.union([z.string(), z.null()]),
buyer_email: z.union([z.string(), z.null()]),
date: z.string(), date: z.string(),
billing_period_start: z.union([z.string(), z.null()]), billing_period_start: z.union([z.string(), z.null()]),
billing_period_end: z.union([z.string(), z.null()]), billing_period_end: z.union([z.string(), z.null()]),
@@ -705,6 +719,7 @@ const UserResource = z
profile_photo_url: z.string(), profile_photo_url: z.string(),
timezone: z.string(), timezone: z.string(),
week_start: Weekday, week_start: Weekday,
send_time_entry_still_running_email: z.boolean(),
}) })
.passthrough(); .passthrough();
const UserUpdateRequest = z const UserUpdateRequest = z
@@ -714,6 +729,7 @@ const UserUpdateRequest = z
photo: z.union([z.string(), z.null()]), photo: z.union([z.string(), z.null()]),
timezone: z.string(), timezone: z.string(),
week_start: Weekday, week_start: Weekday,
send_time_entry_still_running_email: z.boolean(),
}) })
.partial() .partial()
.passthrough(); .passthrough();
@@ -1895,6 +1911,125 @@ const endpoints = makeApi([
}, },
], ],
}, },
{
method: 'get',
path: '/v1/organizations/:organization/invoice-recipients',
alias: 'getInvoiceRecipients',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientCollection }).passthrough(),
},
{
method: 'post',
path: '/v1/organizations/:organization/invoice-recipients',
alias: 'createInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: InvoiceRecipientRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
},
{
method: 'get',
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient',
alias: 'getInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoiceRecipient',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
},
{
method: 'put',
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient',
alias: 'updateInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: InvoiceRecipientRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoiceRecipient',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
},
{
method: 'post',
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient/duplicate',
alias: 'duplicateInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: InvoiceRecipientRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoiceRecipient',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
},
{
method: 'delete',
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient',
alias: 'deleteInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoiceRecipient',
type: 'Path',
schema: z.string(),
},
],
response: z.void(),
},
{ {
method: 'get', method: 'get',
path: '/v1/organizations/:organization/invoices', path: '/v1/organizations/:organization/invoices',
@@ -1911,6 +2046,11 @@ const endpoints = makeApi([
type: 'Query', type: 'Query',
schema: z.number().int().gte(1).lte(2147483647).optional(), schema: z.number().int().gte(1).lte(2147483647).optional(),
}, },
{
name: 'status',
type: 'Query',
schema: InvoiceStatus.optional(),
},
], ],
response: z.object({ data: InvoiceCollection }).passthrough(), response: z.object({ data: InvoiceCollection }).passthrough(),
errors: [ errors: [

View File

@@ -1,6 +1,6 @@
{ {
"name": "@solidtime/ui", "name": "@solidtime/ui",
"version": "0.0.22", "version": "0.0.23",
"description": "Package containing the solidtime ui components", "description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs", "main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js", "module": "./dist/solidtime-ui-lib.js",

View File

@@ -11,6 +11,7 @@ import {
CommandShortcut, CommandShortcut,
} from '../command'; } from '../command';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
import type { import type {
CommandPaletteCommand, CommandPaletteCommand,
CommandPaletteGroup, CommandPaletteGroup,
@@ -36,6 +37,8 @@ const emit = defineEmits<{
select: [command: CommandPaletteCommand | EntitySearchResult]; select: [command: CommandPaletteCommand | EntitySearchResult];
}>(); }>();
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
// Non-empty groups for rendering // Non-empty groups for rendering
const nonEmptyGroups = computed(() => props.groups.filter((g) => g.commands.length > 0)); const nonEmptyGroups = computed(() => props.groups.filter((g) => g.commands.length > 0));
@@ -71,7 +74,9 @@ watch(open, (isOpen) => {
) )
"> ">
<DialogContent <DialogContent
class="pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"> class="pointer-events-auto bg-default-background w-full max-w-lg border border-border-tertiary shadow-lg sm:rounded-lg outline-none overflow-hidden p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus">
<CommandRoot <CommandRoot
v-model:search-term="searchTerm" v-model:search-term="searchTerm"
class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">

View File

@@ -8,7 +8,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/packages/ui/src/tooltip'; } from '@/packages/ui/src/tooltip';
const active = defineModel({ default: false }); const active = defineModel<boolean>({ default: false });
const emit = defineEmits(['changed']); const emit = defineEmits(['changed']);
function toggleBillable() { function toggleBillable() {
active.value = !active.value; active.value = !active.value;

View File

@@ -16,7 +16,7 @@ const props = withDefaults(
); );
const emit = defineEmits(['open', 'submit']); const emit = defineEmits(['open', 'submit']);
const open = defineModel({ default: false }); const open = defineModel<boolean>({ default: false });
function handleAutofocus(event: Event) { function handleAutofocus(event: Event) {
if (props.autoFocus === false) { if (props.autoFocus === false) {

View File

@@ -13,11 +13,11 @@ import { type ComputedRef } from 'vue';
const temporaryCustomTimerEntry = ref<string>(''); const temporaryCustomTimerEntry = ref<string>('');
const start = defineModel('start', { const start = defineModel<string>('start', {
default: '', default: '',
}); });
const end = defineModel('end', { const end = defineModel<string>('end', {
default: '', default: '',
}); });

View File

@@ -20,7 +20,7 @@ const NONE_ID = 'none';
const ROW_HEIGHT = 32; const ROW_HEIGHT = 32;
const model = defineModel<string[]>({ const model = defineModel<string[]>({
default: [], default: () => [],
}); });
const props = defineProps<{ const props = defineProps<{

View File

@@ -16,8 +16,8 @@ import {
ChevronRightIcon, ChevronRightIcon,
EllipsisHorizontalIcon, EllipsisHorizontalIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import { buttonVariants } from '@/packages/ui/src'; import { buttonVariants } from './Buttons/index';
import { cn } from '@/lib/utils'; import { cn } from './utils/cn';
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
const page = defineModel<number>('page', { default: 1 }); const page = defineModel<number>('page', { default: 1 });

View File

@@ -32,7 +32,7 @@ const props = withDefaults(
); );
const model = defineModel<string[]>({ const model = defineModel<string[]>({
default: [], default: () => [],
}); });
const open = ref(false); const open = ref(false);

View File

@@ -21,7 +21,7 @@ import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import type { TimeEntriesGroupedByType } from '@/types/time-entries'; import type { TimeEntriesGroupedByType } from '@/types/time-entries';
const selectedTimeEntries = defineModel<TimeEntry[]>('selected', { const selectedTimeEntries = defineModel<TimeEntry[]>('selected', {
default: [], default: () => [],
}); });
const props = withDefaults( const props = withDefaults(

View File

@@ -20,7 +20,7 @@ const emit = defineEmits<{
}>(); }>();
const model = defineModel<string[]>({ const model = defineModel<string[]>({
default: [], default: () => [],
}); });
const timeEntryTags = computed<Tag[]>(() => { const timeEntryTags = computed<Tag[]>(() => {

View File

@@ -10,7 +10,7 @@ const emit = defineEmits<{
}>(); }>();
const model = defineModel<string[]>({ const model = defineModel<string[]>({
default: [], default: () => [],
}); });
const iconColorClasses = computed(() => { const iconColorClasses = computed(() => {
if (model.value.length > 0) { if (model.value.length > 0) {

View File

@@ -7,7 +7,7 @@ import { getUserTimezone } from './utils/settings';
import { getDayJsInstance } from './utils/time'; import { getDayJsInstance } from './utils/time';
import { useSessionStorage } from '@vueuse/core'; import { useSessionStorage } from '@vueuse/core';
const show = defineModel('show', { default: false }); const show = defineModel<boolean>('show', { default: false });
const emit = defineEmits<{ const emit = defineEmits<{
update: [timezone: string]; update: [timezone: string];

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { ComboboxRootEmits, ComboboxRootProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxRoot, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxRootProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<ComboboxRootEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<!-- Keep the trigger inside this root. Reka treats anything within the root
element as "not outside", so it suppresses its own dismissal for trigger
clicks and ComboboxInput's blur-close ignores them. Putting the trigger
in a separate Popover instead gives two competing open states, and the
popover then closes on mousedown and reopens on the following click. -->
<ComboboxRoot v-slot="slotProps" v-bind="forwarded" :class="cn('min-w-0', props.class)">
<slot v-bind="slotProps" />
</ComboboxRoot>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { ComboboxAnchorProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxAnchor, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxAnchorProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxAnchor v-bind="forwarded" :class="cn('w-full', props.class)">
<slot />
</ComboboxAnchor>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { ComboboxInputEmits, ComboboxInputProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { Search } from '@lucide/vue';
import { ComboboxInput, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';
defineOptions({
inheritAttrs: false,
});
const props = defineProps<ComboboxInputProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<ComboboxInputEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<div class="relative items-center border-b border-card-background-separator">
<ComboboxInput
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'h-10 w-full border-0 rounded-none bg-transparent pl-9 pr-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:ring-0',
props.class
)
" />
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-3">
<Search class="size-4 text-text-tertiary" />
</span>
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ComboboxItemEmits, ComboboxItemProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxItem, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<ComboboxItemEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ComboboxItem
v-bind="forwarded"
:class="
cn(
'flex w-full cursor-default items-center rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active',
props.class
)
">
<slot />
</ComboboxItem>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { ComboboxContentEmits, ComboboxContentProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxContent, ComboboxPortal, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<ComboboxContentProps & { class?: HTMLAttributes['class'] }>(),
{
position: 'popper',
align: 'start',
sideOffset: 4,
class: undefined,
}
);
const emits = defineEmits<ComboboxContentEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ComboboxPortal>
<ComboboxContent
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'z-50 w-[--reka-popper-anchor-width] min-w-60 overflow-hidden rounded-lg border border-popover-border bg-popover text-popover-foreground shadow-dropdown outline-none origin-[var(--reka-combobox-content-transform-origin)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
">
<slot />
</ComboboxContent>
</ComboboxPortal>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { ComboboxSeparatorProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxSeparator, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxSeparatorProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxSeparator
v-bind="forwarded"
:class="cn('my-1 h-px bg-card-background-separator', props.class)" />
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { ComboboxTriggerProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxTrigger, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxTriggerProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<!-- Reka hardcodes tabindex="-1" here because it expects a ComboboxInput
next to the trigger in the anchor to be the focusable control. Our input
lives inside ComboboxList, so this trigger is the control and has to be
tabbable. tabindex is a fallthrough attr, which Vue applies after Reka's
own props and therefore wins.
Reka also hardcodes aria-label="Show popup", which would otherwise be
the accessible name. Pass :aria-label on the child element (it wins
again, because Slot merges child props over attrs) to name the trigger
after its current value. -->
<ComboboxTrigger v-bind="forwarded" :class="cn(props.class)" tabindex="0">
<slot />
</ComboboxTrigger>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ComboboxViewportProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxViewport, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxViewportProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxViewport
v-bind="forwarded"
:class="cn('ui-combobox-viewport max-h-60 overflow-y-auto p-2', props.class)">
<slot />
</ComboboxViewport>
</template>
<style>
/* Reka's ComboboxViewport injects a global style that hides scrollbars; the
attribute+class selector below is more specific and restores them. */
[data-reka-combobox-viewport].ui-combobox-viewport {
scrollbar-width: thin;
-ms-overflow-style: auto;
}
[data-reka-combobox-viewport].ui-combobox-viewport::-webkit-scrollbar {
display: block;
width: 8px;
}
[data-reka-combobox-viewport].ui-combobox-viewport::-webkit-scrollbar-thumb {
background-color: rgb(127 127 127 / 0.4);
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,9 @@
export { default as Combobox } from './Combobox.vue';
export { default as ComboboxAnchor } from './ComboboxAnchor.vue';
export { default as ComboboxInput } from './ComboboxInput.vue';
export { default as ComboboxItem } from './ComboboxItem.vue';
export { default as ComboboxList } from './ComboboxList.vue';
export { default as ComboboxSeparator } from './ComboboxSeparator.vue';
export { default as ComboboxTrigger } from './ComboboxTrigger.vue';
export { default as ComboboxViewport } from './ComboboxViewport.vue';
export { ComboboxVirtualizer } from 'reka-ui';

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
import { import {
DialogContent, DialogContent,
type DialogContentEmits, type DialogContentEmits,
@@ -20,6 +21,10 @@ const delegatedProps = computed(() => {
}); });
const forwarded = useForwardPropsEmits(delegatedProps, emits); const forwarded = useForwardPropsEmits(delegatedProps, emits);
// Forwarded consumer listeners run first, so a consumer can still take over
// by calling preventDefault() on close-auto-focus.
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
</script> </script>
<template> <template>
@@ -36,7 +41,9 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
'my-3 md:my-14 xl:my-24 bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', 'my-3 md:my-14 xl:my-24 bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
props.class props.class
) )
"> "
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus">
<slot /> <slot />
</DialogContent> </DialogContent>
</div> </div>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
import { useDialogFocusRestore } from '../utils/useDialogFocusRestore';
import { X } from '@lucide/vue'; import { X } from '@lucide/vue';
import { import {
DialogClose, DialogClose,
@@ -22,6 +23,10 @@ const delegatedProps = computed(() => {
}); });
const forwarded = useForwardPropsEmits(delegatedProps, emits); const forwarded = useForwardPropsEmits(delegatedProps, emits);
// Forwarded consumer listeners run first, so a consumer can still take over
// by calling preventDefault() on close-auto-focus.
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
</script> </script>
<template> <template>
@@ -36,6 +41,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
) )
" "
v-bind="forwarded" v-bind="forwarded"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus"
@pointer-down-outside=" @pointer-down-outside="
(event) => { (event) => {
const originalEvent = event.detail.originalEvent; const originalEvent = event.detail.originalEvent;

View File

@@ -32,6 +32,7 @@ import InputLabel from './Input/InputLabel.vue';
import TextInput from './Input/TextInput.vue'; import TextInput from './Input/TextInput.vue';
import LoadingSpinner from './LoadingSpinner.vue'; import LoadingSpinner from './LoadingSpinner.vue';
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import Pagination from './Pagination.vue';
import ProjectBadge from './Project/ProjectBadge.vue'; import ProjectBadge from './Project/ProjectBadge.vue';
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue'; import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue'; import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
@@ -57,6 +58,16 @@ import {
CalendarNextButton, CalendarNextButton,
CalendarPrevButton, CalendarPrevButton,
} from './calendar/index'; } from './calendar/index';
import {
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
ComboboxViewport,
} from './combobox/index';
import { CommandPalette } from './CommandPalette/index'; import { CommandPalette } from './CommandPalette/index';
import { import {
ContextMenu, ContextMenu,
@@ -176,6 +187,14 @@ export {
CardTitle, CardTitle,
Checkbox, Checkbox,
color, color,
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
ComboboxViewport,
CommandPalette, CommandPalette,
ContextMenu, ContextMenu,
ContextMenuCheckboxItem, ContextMenuCheckboxItem,
@@ -239,6 +258,7 @@ export {
NumberFieldDecrement, NumberFieldDecrement,
NumberFieldIncrement, NumberFieldIncrement,
NumberFieldInput, NumberFieldInput,
Pagination,
Popover, Popover,
PopoverAnchor, PopoverAnchor,
PopoverContent, PopoverContent,

View File

@@ -43,6 +43,13 @@ export function formatCents(
return formatMoney(amount / 100, currency, format, currencySymbol, numberFormat); return formatMoney(amount / 100, currency, format, currencySymbol, numberFormat);
} }
/*
* Converts a major-unit amount (e.g. 19.99) to integer hundredths (i.e. cents)
*/
export function toCents(value: number | undefined | null): number {
return Math.round((value || 0) * 100);
}
export function getOrganizationCurrencySymbol(currency: string) { export function getOrganizationCurrencySymbol(currency: string) {
return (0) return (0)
.toLocaleString('de-DE', { .toLocaleString('de-DE', {

View File

@@ -1,9 +1,5 @@
export type NumberFormat = export type NumberFormat =
| 'point-comma' 'point-comma' | 'comma-point' | 'space-comma' | 'space-point' | 'apostrophe-point';
| 'comma-point'
| 'space-comma'
| 'space-point'
| 'apostrophe-point';
/** /**
* Formats a number according to the specified format * Formats a number according to the specified format

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