mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 13:12:16 +01:00
Compare commits
6 Commits
v0.11.3
...
99aa7ed450
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99aa7ed450 | ||
|
|
f582adab0d | ||
|
|
c60cff04ce | ||
|
|
cae41e4b4f | ||
|
|
8973be9dab | ||
|
|
2a0b8d31e6 |
@@ -4,18 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\In;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||
|
||||
class AddOrganizationMember implements AddsTeamMembers
|
||||
@@ -25,70 +16,6 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
*/
|
||||
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
|
||||
|
||||
$this->validate($organization, $email, $role);
|
||||
|
||||
$newOrganizationMember = User::query()
|
||||
->where('email', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->firstOrFail();
|
||||
|
||||
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the add member operation.
|
||||
*/
|
||||
protected function validate(Organization $organization, string $email, ?string $role): void
|
||||
{
|
||||
Validator::make([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
], $this->rules())->after(
|
||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||
)->validateWithBag('addTeamMember');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules for adding a team member.
|
||||
*
|
||||
* @return array<string, array<ValidationRule|Rule|string|In>>
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->where('is_placeholder', '=', false);
|
||||
})->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
Role::Admin->value,
|
||||
Role::Manager->value,
|
||||
Role::Employee->value,
|
||||
]),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the user is not already on the team.
|
||||
*/
|
||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
||||
{
|
||||
return function ($validator) use ($team, $email): void {
|
||||
$validator->errors()->addIf(
|
||||
$team->hasRealUserWithEmail($email),
|
||||
'email',
|
||||
__('This user already belongs to the team.')
|
||||
);
|
||||
};
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ class CreateOrganization implements CreatesTeams
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
*
|
||||
* @deprecated Use REST endpoint instead
|
||||
*/
|
||||
public function create(User $user, array $input): Organization
|
||||
{
|
||||
|
||||
@@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams
|
||||
{
|
||||
/**
|
||||
* Delete the given team.
|
||||
*
|
||||
* @deprecated Use REST endpoint instead
|
||||
*/
|
||||
public function delete(Organization $organization): void
|
||||
{
|
||||
|
||||
@@ -16,6 +16,8 @@ class DeleteUser implements DeletesUsers
|
||||
* Delete the given user.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*
|
||||
* @deprecated Use REST endpoint instead
|
||||
*/
|
||||
public function delete(User $user): void
|
||||
{
|
||||
|
||||
@@ -18,6 +18,8 @@ class ValidateOrganizationDeletion
|
||||
* @param Organization $organization Organization to be deleted
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @deprecated Use REST endpoint instead
|
||||
*/
|
||||
public function validate(User $user, Organization $organization): void
|
||||
{
|
||||
|
||||
28
app/Events/MemberAdded.php
Normal file
28
app/Events/MemberAdded.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class MemberAdded
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Member $member;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public User $user;
|
||||
|
||||
public function __construct(Member $member, Organization $organization, User $user)
|
||||
{
|
||||
$this->member = $member;
|
||||
$this->organization = $organization;
|
||||
$this->user = $user;
|
||||
}
|
||||
}
|
||||
28
app/Events/MemberAdding.php
Normal file
28
app/Events/MemberAdding.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class MemberAdding
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public User $user;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public Role $role;
|
||||
|
||||
public function __construct(User $user, Organization $organization, Role $role)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->organization = $organization;
|
||||
$this->role = $role;
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,17 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\DeletionService;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\OrganizationService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class OrganizationController extends Controller
|
||||
{
|
||||
@@ -80,4 +86,48 @@ class OrganizationController extends Controller
|
||||
|
||||
return new OrganizationResource($organization, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization
|
||||
*
|
||||
* @operationId createOrganization
|
||||
*/
|
||||
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
|
||||
{
|
||||
$user = $this->user();
|
||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());
|
||||
|
||||
$currency = $ipLookupResponse?->currency;
|
||||
|
||||
$organization = $organizationService->createOrganization(
|
||||
$request->getName(),
|
||||
$user,
|
||||
false,
|
||||
$currency
|
||||
);
|
||||
|
||||
$user->switchTeam($organization);
|
||||
|
||||
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
||||
$organization = $organization->refresh();
|
||||
AfterCreateOrganization::dispatch($organization);
|
||||
|
||||
return new OrganizationResource($organization, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete organization
|
||||
*
|
||||
* @operationId deleteOrganization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'organizations:delete');
|
||||
|
||||
$deletionService->deleteOrganization($organization);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||
use App\Http\Resources\V1\User\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
@@ -24,4 +28,29 @@ class UserController extends Controller
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the deletion of a user.
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
*
|
||||
* @operationId deleteUser
|
||||
*
|
||||
* @param User $user The user instance to be deleted.
|
||||
* @param DeletionService $deletionService The service responsible for performing the user deletion.
|
||||
* @return JsonResponse A JSON response with a 204 No Content status upon successful deletion.
|
||||
*
|
||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
|
||||
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
|
||||
*/
|
||||
public function destroy(User $user, DeletionService $deletionService): JsonResponse
|
||||
{
|
||||
if ($user->getKey() !== $this->user()->getKey()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$deletionService->deleteUser($user);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,30 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Service\DashboardService;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
|
||||
public function dashboard(): Response
|
||||
{
|
||||
$user = $this->user();
|
||||
$organization = $this->currentOrganization();
|
||||
|
||||
$latestTeamActivity = null;
|
||||
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
||||
}
|
||||
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
return Inertia::render('Dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use RuntimeException;
|
||||
|
||||
class OrganizationInvitationController extends Controller
|
||||
{
|
||||
public function accept(OrganizationInvitation $invitation, MemberService $memberService): RedirectResponse
|
||||
{
|
||||
$email = strtolower($invitation->email);
|
||||
$role = Role::tryFrom($invitation->role);
|
||||
if ($role === null || $role === Role::Owner || $role === Role::Placeholder) {
|
||||
throw new RuntimeException('Invalid role');
|
||||
}
|
||||
|
||||
$newOrganizationMember = User::query()
|
||||
->where('email', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->first();
|
||||
|
||||
if ($newOrganizationMember === null) {
|
||||
if ($invitation->accepted_at === null) {
|
||||
$invitation->accepted_at = now();
|
||||
$invitation->save();
|
||||
}
|
||||
|
||||
return redirect(route('register', [
|
||||
'bannerStyle' => 'info',
|
||||
'bannerText' => __('Please create an account to finish joining the :organization organization.', [
|
||||
'organization' => $invitation->organization->name,
|
||||
]),
|
||||
]));
|
||||
} else {
|
||||
$organization = $invitation->organization;
|
||||
if ($memberService->isEmailAlreadyMember($organization, $email)) {
|
||||
return redirect(route('dashboard', [
|
||||
'bannerStyle' => 'danger',
|
||||
'bannerText' => __('You are already a member of the :organization organization.', [
|
||||
'organization' => $organization->name,
|
||||
]),
|
||||
]));
|
||||
}
|
||||
|
||||
$memberService->addMember($newOrganizationMember, $organization, $role);
|
||||
|
||||
$invitation->delete();
|
||||
|
||||
return redirect(route('dashboard', [
|
||||
'bannerStyle' => 'success',
|
||||
'bannerText' => __('Great! You have accepted the invitation to join the :organization organization.', [
|
||||
'organization' => $invitation->organization->name,
|
||||
]),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Organization;
|
||||
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\Organization;
|
||||
|
||||
/**
|
||||
* @property Organization $organization Organization from model binding
|
||||
*/
|
||||
class OrganizationStoreRequest extends BaseFormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return (string) $this->input('name');
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class RemovePlaceholder
|
||||
{
|
||||
/**
|
||||
* Handle the event.
|
||||
*/
|
||||
public function handle(TeamMemberAdded $event): void
|
||||
{
|
||||
$memberService = app(MemberService::class);
|
||||
$member = Member::query()
|
||||
->whereBelongsTo($event->team, 'organization')
|
||||
->whereBelongsTo($event->user, 'user')
|
||||
->firstOrFail();
|
||||
$placeholders = Member::query()
|
||||
->whereHas('user', function (Builder $query) use ($event): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', true)
|
||||
->where('email', '=', $event->user->email);
|
||||
})
|
||||
->whereBelongsTo($event->team, 'organization')
|
||||
->with(['user'])
|
||||
->get();
|
||||
|
||||
foreach ($placeholders as $placeholder) {
|
||||
/** @var Member $placeholder */
|
||||
$placeholderUser = $placeholder->user;
|
||||
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
|
||||
$placeholder->delete();
|
||||
$placeholderUser->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class OrganizationInvitationMail extends Mailable
|
||||
@@ -32,9 +33,12 @@ class OrganizationInvitationMail extends Mailable
|
||||
public function build(): self
|
||||
{
|
||||
return $this->markdown('emails.organization-invitation', [
|
||||
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
||||
'invitation' => $this->invitation,
|
||||
]),
|
||||
'acceptUrl' => URL::to(URL::signedRoute(
|
||||
'organization-invitations.accept',
|
||||
['invitation' => $this->invitation->getKey()],
|
||||
Carbon::now()->addDays(90),
|
||||
false
|
||||
)),
|
||||
])->subject(__('Organization Invitation'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $user_id
|
||||
* @property bool $employees_can_see_billable_rates
|
||||
* @property bool $employees_can_manage_tasks
|
||||
* @property bool $prevent_overlapping_time_entries
|
||||
* @property User $owner
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
|
||||
@@ -18,6 +18,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $email
|
||||
* @property string $role
|
||||
* @property string $organization_id
|
||||
* @property Carbon|null $accepted_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property-read Organization $organization
|
||||
@@ -41,14 +42,16 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
||||
protected $table = 'organization_invitations';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @var array<int, string>
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'email',
|
||||
'role',
|
||||
];
|
||||
public function casts(): array
|
||||
{
|
||||
return [
|
||||
'accepted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the organization that the invitation belongs to.
|
||||
|
||||
@@ -62,18 +62,6 @@ class OrganizationPolicy
|
||||
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can add team members.
|
||||
*/
|
||||
public function addTeamMember(User $user, Organization $organization): bool
|
||||
{
|
||||
if (Filament::isServing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update team member permissions.
|
||||
*/
|
||||
|
||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\RemovePlaceholder;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -21,9 +19,6 @@ class EventServiceProvider extends ServiceProvider
|
||||
Registered::class => [
|
||||
SendEmailVerificationNotification::class,
|
||||
],
|
||||
TeamMemberAdded::class => [
|
||||
RemovePlaceholder::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,9 +8,11 @@ use App\Enums\Role;
|
||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||
|
||||
@@ -21,11 +23,7 @@ class InvitationService
|
||||
*/
|
||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||
{
|
||||
if (Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereRelation('user', 'email', '=', $email)
|
||||
->where('role', '!=', Role::Placeholder->value)
|
||||
->exists()) {
|
||||
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||
}
|
||||
|
||||
@@ -48,4 +46,37 @@ class InvitationService
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Organization>
|
||||
*/
|
||||
public function processAcceptedInvitations(User $user): Collection
|
||||
{
|
||||
$organizations = new Collection;
|
||||
|
||||
$invitations = OrganizationInvitation::query()
|
||||
->where('email', $user->email)
|
||||
->whereNotNull('accepted_at')
|
||||
->get();
|
||||
|
||||
foreach ($invitations as $invitation) {
|
||||
$organization = $invitation->organization;
|
||||
$role = Role::tryFrom($invitation->role);
|
||||
if ($role === null) {
|
||||
Log::error('Invalid role in invitation', [
|
||||
'invitation' => $invitation->getKey(),
|
||||
'role' => $invitation->role,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
app(MemberService::class)->addMember($user, $organization, $role);
|
||||
|
||||
$invitation->delete();
|
||||
|
||||
$organizations->push($organization);
|
||||
}
|
||||
|
||||
return $organizations;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\MemberAdded;
|
||||
use App\Events\MemberAdding;
|
||||
use App\Events\MemberRemoved;
|
||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||
@@ -36,7 +38,8 @@ class MemberService
|
||||
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
||||
{
|
||||
if (! $asSuperAdmin) {
|
||||
AddingTeamMember::dispatch($organization, $user);
|
||||
MemberAdding::dispatch($user, $organization, $role);
|
||||
AddingTeamMember::dispatch($organization, $user); // Legacy event
|
||||
}
|
||||
|
||||
$member = new Member;
|
||||
@@ -49,14 +52,37 @@ class MemberService
|
||||
$user->currentOrganization()->associate($organization);
|
||||
$user->save();
|
||||
});
|
||||
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
|
||||
|
||||
if (! $asSuperAdmin) {
|
||||
TeamMemberAdded::dispatch($organization, $user);
|
||||
MemberAdded::dispatch($member, $organization, $user);
|
||||
TeamMemberAdded::dispatch($organization, $user); // Legacy event
|
||||
}
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
private function mergePlaceholderMembersIntoExistingMember(Member $member, Organization $organization, User $user): void
|
||||
{
|
||||
$placeholders = Member::query()
|
||||
->whereHas('user', function (Builder $query) use ($user): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', true)
|
||||
->where('email', '=', $user->email);
|
||||
})
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->with(['user'])
|
||||
->get();
|
||||
|
||||
foreach ($placeholders as $placeholder) {
|
||||
/** @var Member $placeholder */
|
||||
$placeholderUser = $placeholder->user;
|
||||
$this->assignOrganizationEntitiesToDifferentMember($organization, $placeholder, $member);
|
||||
$placeholder->delete();
|
||||
$placeholderUser->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CanNotRemoveOwnerFromOrganization
|
||||
* @throws EntityStillInUseApiException
|
||||
@@ -209,4 +235,13 @@ class MemberService
|
||||
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||
}
|
||||
}
|
||||
|
||||
public function isEmailAlreadyMember(Organization $organization, string $email): bool
|
||||
{
|
||||
return Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereRelation('user', 'email', '=', $email)
|
||||
->where('role', '!=', Role::Placeholder->value)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class UserService
|
||||
): User {
|
||||
$user = new User;
|
||||
$user->name = $name;
|
||||
$user->email = $email;
|
||||
$user->email = strtolower($email);
|
||||
$user->password = Hash::make($password);
|
||||
$user->timezone = $timezone;
|
||||
$user->week_start = $weekStart;
|
||||
@@ -47,19 +47,22 @@ class UserService
|
||||
}
|
||||
$user->save();
|
||||
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$this->getOrganizationNameForUserName($user->name),
|
||||
$user,
|
||||
true,
|
||||
$currency,
|
||||
$numberFormat,
|
||||
$currencyFormat,
|
||||
$dateFormat,
|
||||
$intervalFormat,
|
||||
$timeFormat,
|
||||
);
|
||||
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
if ($organizations->isEmpty()) {
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$this->getOrganizationNameForUserName($user->name),
|
||||
$user,
|
||||
true,
|
||||
$currency,
|
||||
$numberFormat,
|
||||
$currencyFormat,
|
||||
$dateFormat,
|
||||
$intervalFormat,
|
||||
$timeFormat,
|
||||
);
|
||||
$user->ownedTeams()->save($organization);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
1562
composer.lock
generated
1562
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -25,9 +25,24 @@ class OrganizationInvitationFactory extends Factory
|
||||
'email' => $this->faker->unique()->safeEmail(),
|
||||
'role' => Role::Employee->value,
|
||||
'organization_id' => Organization::factory(),
|
||||
'accepted_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function role(Role $role): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'role' => $role->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function accepted(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'accepted_at' => $this->faker->dateTime(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function forOrganization(Organization $organization): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('organization_invitations', function (Blueprint $table): void {
|
||||
$table->timestamp('accepted_at')->nullable()->after('email');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('organization_invitations', function (Blueprint $table): void {
|
||||
$table->dropColumn('accepted_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
172
e2e/calendar-settings.spec.ts
Normal file
172
e2e/calendar-settings.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
async function goToCalendar(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
||||
await expect(page.locator('.fc')).toBeVisible();
|
||||
}
|
||||
|
||||
async function openSettingsPopover(page: Page) {
|
||||
await page.getByRole('button', { name: 'Calendar settings' }).click();
|
||||
await expect(page.getByText('Calendar Settings')).toBeVisible();
|
||||
}
|
||||
|
||||
async function clearCalendarSettings(page: Page) {
|
||||
await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings'));
|
||||
}
|
||||
|
||||
test.describe('Calendar Settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await clearCalendarSettings(page);
|
||||
});
|
||||
|
||||
test('settings popover shows all fields with correct defaults', async ({ page }) => {
|
||||
await goToCalendar(page);
|
||||
await openSettingsPopover(page);
|
||||
|
||||
await expect(page.getByLabel('Snap Interval')).toContainText('15 min');
|
||||
await expect(page.getByLabel('Start Time')).toContainText('12:00 AM');
|
||||
await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)');
|
||||
await expect(page.getByLabel('Grid Scale')).toContainText('15 min');
|
||||
});
|
||||
|
||||
test('snap interval can be changed and persists across reload', async ({ page }) => {
|
||||
await goToCalendar(page);
|
||||
await openSettingsPopover(page);
|
||||
|
||||
// Change snap interval to 30 min
|
||||
await page.getByLabel('Snap Interval').click();
|
||||
await page.getByRole('option', { name: '30 min' }).click();
|
||||
await page.locator('.fc-toolbar-title').click();
|
||||
|
||||
// Verify localStorage was updated
|
||||
const stored = await page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}')
|
||||
);
|
||||
expect(stored.snapMinutes).toBe(30);
|
||||
|
||||
// Reload and verify persistence
|
||||
await page.reload();
|
||||
await expect(page.locator('.fc')).toBeVisible();
|
||||
await openSettingsPopover(page);
|
||||
await expect(page.getByLabel('Snap Interval')).toContainText('30 min');
|
||||
});
|
||||
|
||||
test('start time change is applied to calendar and rejects values >= end time', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToCalendar(page);
|
||||
|
||||
// Verify 7 AM slot exists with default start (00:00)
|
||||
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).not.toHaveCount(0);
|
||||
|
||||
await openSettingsPopover(page);
|
||||
|
||||
// Set end time to 6 PM first
|
||||
await page.getByLabel('End Time').click();
|
||||
await page.getByRole('option', { name: '6:00 PM' }).click();
|
||||
|
||||
// Change start time to 8 AM (valid)
|
||||
await page.getByLabel('Start Time').click();
|
||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||
await page.locator('.fc-toolbar-title').click();
|
||||
|
||||
// Calendar should no longer show hours before 8 AM
|
||||
await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0);
|
||||
await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0);
|
||||
|
||||
// Try to set start time to 6 PM (invalid: equals end time)
|
||||
await openSettingsPopover(page);
|
||||
await page.getByLabel('Start Time').click();
|
||||
await page.getByRole('option', { name: '6:00 PM' }).click();
|
||||
|
||||
// Should be rejected — start time stays at 8 AM
|
||||
await expect(page.getByLabel('Start Time')).toContainText('8:00 AM');
|
||||
});
|
||||
|
||||
test('end time change is applied to calendar and rejects values <= start time', async ({
|
||||
page,
|
||||
}) => {
|
||||
await goToCalendar(page);
|
||||
|
||||
// Verify 19:00 slot exists with default end (24:00)
|
||||
await expect(page.locator('.fc-timegrid-slot[data-time="19:00:00"]')).not.toHaveCount(0);
|
||||
|
||||
await openSettingsPopover(page);
|
||||
|
||||
// Set start time to 8 AM first
|
||||
await page.getByLabel('Start Time').click();
|
||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||
|
||||
// Change end time to 6 PM (valid)
|
||||
await page.getByLabel('End Time').click();
|
||||
await page.getByRole('option', { name: '6:00 PM' }).click();
|
||||
await page.locator('.fc-toolbar-title').click();
|
||||
|
||||
// Calendar should no longer show hours at or after 6 PM
|
||||
await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0);
|
||||
await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0);
|
||||
|
||||
// Try to set end time to 8 AM (invalid: equals start time)
|
||||
await openSettingsPopover(page);
|
||||
await page.getByLabel('End Time').click();
|
||||
await page.getByRole('option', { name: '8:00 AM' }).click();
|
||||
|
||||
// Should be rejected — end time stays at 6 PM
|
||||
await expect(page.getByLabel('End Time')).toContainText('6:00 PM');
|
||||
});
|
||||
|
||||
test('grid scale affects number of calendar slots', async ({ page }) => {
|
||||
await goToCalendar(page);
|
||||
|
||||
// Count slots with default 15-min scale
|
||||
const defaultSlotCount = await page.locator('.fc-timegrid-slot').count();
|
||||
|
||||
// Change to 30 min scale (should halve the slots)
|
||||
await openSettingsPopover(page);
|
||||
await page.getByLabel('Grid Scale').click();
|
||||
await page.getByRole('option', { name: '30 min' }).click();
|
||||
await page.locator('.fc-toolbar-title').click();
|
||||
|
||||
const largerSlotCount = await page.locator('.fc-timegrid-slot').count();
|
||||
expect(largerSlotCount).toBeLessThan(defaultSlotCount);
|
||||
|
||||
// Change to 5 min scale (should have many more slots)
|
||||
await openSettingsPopover(page);
|
||||
await page.getByLabel('Grid Scale').click();
|
||||
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
||||
await page.locator('.fc-toolbar-title').click();
|
||||
|
||||
const smallerSlotCount = await page.locator('.fc-timegrid-slot').count();
|
||||
expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount);
|
||||
});
|
||||
|
||||
test('all settings persist across navigation', async ({ page }) => {
|
||||
await goToCalendar(page);
|
||||
await openSettingsPopover(page);
|
||||
|
||||
// Change every setting
|
||||
await page.getByLabel('Snap Interval').click();
|
||||
await page.getByRole('option', { name: '5 min', exact: true }).click();
|
||||
await page.getByLabel('Start Time').click();
|
||||
await page.getByRole('option', { name: '6:00 AM' }).click();
|
||||
await page.getByLabel('End Time').click();
|
||||
await page.getByRole('option', { name: '10:00 PM' }).click();
|
||||
await page.getByLabel('Grid Scale').click();
|
||||
await page.getByRole('option', { name: '30 min' }).click();
|
||||
await page.locator('.fc-toolbar-title').click();
|
||||
|
||||
// Navigate away and back
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await goToCalendar(page);
|
||||
|
||||
// Verify all settings persisted
|
||||
await openSettingsPopover(page);
|
||||
await expect(page.getByLabel('Snap Interval')).toContainText('5 min');
|
||||
await expect(page.getByLabel('Start Time')).toContainText('6:00 AM');
|
||||
await expect(page.getByLabel('End Time')).toContainText('10:00 PM');
|
||||
await expect(page.getByLabel('Grid Scale')).toContainText('30 min');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverContent, PopoverTrigger, Button } from '..';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { Field, FieldLabel } from '../field';
|
||||
import { Settings } from 'lucide-vue-next';
|
||||
import { ref, watch } from 'vue';
|
||||
import type { CalendarSettings } from './calendarSettings';
|
||||
|
||||
export type { CalendarSettings };
|
||||
|
||||
const props = defineProps<{
|
||||
settings: CalendarSettings;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:settings': [value: CalendarSettings];
|
||||
}>();
|
||||
|
||||
const snapMinutes = ref(String(props.settings.snapMinutes));
|
||||
const startHour = ref(String(props.settings.startHour));
|
||||
const endHour = ref(String(props.settings.endHour));
|
||||
const slotMinutes = ref(String(props.settings.slotMinutes));
|
||||
|
||||
watch(
|
||||
() => props.settings,
|
||||
(s) => {
|
||||
snapMinutes.value = String(s.snapMinutes);
|
||||
startHour.value = String(s.startHour);
|
||||
endHour.value = String(s.endHour);
|
||||
slotMinutes.value = String(s.slotMinutes);
|
||||
}
|
||||
);
|
||||
|
||||
function emitUpdate(partial: Partial<CalendarSettings>) {
|
||||
emit('update:settings', { ...props.settings, ...partial });
|
||||
}
|
||||
|
||||
function onSnapChange(value: string) {
|
||||
snapMinutes.value = value;
|
||||
emitUpdate({ snapMinutes: parseInt(value) });
|
||||
}
|
||||
|
||||
function onStartHourChange(value: string) {
|
||||
const newStart = parseInt(value);
|
||||
// Ensure start < end
|
||||
if (newStart >= parseInt(endHour.value)) {
|
||||
startHour.value = String(props.settings.startHour);
|
||||
return;
|
||||
}
|
||||
startHour.value = value;
|
||||
emitUpdate({ startHour: newStart });
|
||||
}
|
||||
|
||||
function onEndHourChange(value: string) {
|
||||
const newEnd = parseInt(value);
|
||||
// Ensure end > start
|
||||
if (newEnd <= parseInt(startHour.value)) {
|
||||
endHour.value = String(props.settings.endHour);
|
||||
return;
|
||||
}
|
||||
endHour.value = value;
|
||||
emitUpdate({ endHour: newEnd });
|
||||
}
|
||||
|
||||
function onSlotChange(value: string) {
|
||||
slotMinutes.value = value;
|
||||
emitUpdate({ slotMinutes: parseInt(value) });
|
||||
}
|
||||
|
||||
const snapOptions = [
|
||||
{ value: '1', label: '1 min' },
|
||||
{ value: '5', label: '5 min' },
|
||||
{ value: '10', label: '10 min' },
|
||||
{ value: '15', label: '15 min' },
|
||||
{ value: '30', label: '30 min' },
|
||||
{ value: '60', label: '1 hour' },
|
||||
];
|
||||
|
||||
const slotOptions = [
|
||||
{ value: '5', label: '5 min' },
|
||||
{ value: '10', label: '10 min' },
|
||||
{ value: '15', label: '15 min' },
|
||||
{ value: '30', label: '30 min' },
|
||||
{ value: '60', label: '1 hour' },
|
||||
];
|
||||
|
||||
// Generate hour options 0-24
|
||||
const hourOptions = Array.from({ length: 25 }, (_, i) => ({
|
||||
value: String(i),
|
||||
label:
|
||||
i === 0
|
||||
? '12:00 AM'
|
||||
: i === 12
|
||||
? '12:00 PM'
|
||||
: i === 24
|
||||
? '12:00 AM (next)'
|
||||
: i < 12
|
||||
? `${i}:00 AM`
|
||||
: `${i - 12}:00 PM`,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" aria-label="Calendar settings" class="h-8 w-8 p-0">
|
||||
<Settings class="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-72 p-4">
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm font-semibold">Calendar Settings</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="calendar-snap">Snap Interval</FieldLabel>
|
||||
<Select
|
||||
:model-value="snapMinutes"
|
||||
@update:model-value="(v) => onSnapChange(v as string)">
|
||||
<SelectTrigger id="calendar-snap" size="sm" class="w-full">
|
||||
<SelectValue placeholder="Snap interval" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in snapOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="calendar-start-hour">Start Time</FieldLabel>
|
||||
<Select
|
||||
:model-value="startHour"
|
||||
@update:model-value="(v) => onStartHourChange(v as string)">
|
||||
<SelectTrigger id="calendar-start-hour" size="sm" class="w-full">
|
||||
<SelectValue placeholder="Start time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in hourOptions.slice(0, -1)"
|
||||
:key="opt.value"
|
||||
:value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="calendar-end-hour">End Time</FieldLabel>
|
||||
<Select
|
||||
:model-value="endHour"
|
||||
@update:model-value="(v) => onEndHourChange(v as string)">
|
||||
<SelectTrigger id="calendar-end-hour" size="sm" class="w-full">
|
||||
<SelectValue placeholder="End time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in hourOptions.slice(1)"
|
||||
:key="opt.value"
|
||||
:value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="calendar-scale">Grid Scale</FieldLabel>
|
||||
<Select
|
||||
:model-value="slotMinutes"
|
||||
@update:model-value="(v) => onSlotChange(v as string)">
|
||||
<SelectTrigger id="calendar-scale" size="sm" class="w-full">
|
||||
<SelectValue placeholder="Grid scale" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in slotOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -15,13 +15,22 @@ import {
|
||||
onActivated,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import chroma from 'chroma-js';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
formatHumanReadableDuration,
|
||||
formatDuration,
|
||||
} from '../utils/time';
|
||||
import { getUserTimezone, getWeekStart } from '../utils/settings';
|
||||
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
||||
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
||||
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
||||
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
||||
import type { CalendarSettings } from './calendarSettings';
|
||||
import { useVisualSnap } from './useVisualSnap';
|
||||
import activityStatusPlugin, {
|
||||
type ActivityPeriod,
|
||||
renderActivityStatusBoxes,
|
||||
@@ -81,6 +90,22 @@ const selectedTimeEntry = ref<TimeEntry | null>(null);
|
||||
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||
|
||||
// Calendar settings with localStorage persistence via VueUse
|
||||
const calendarSettings = useLocalStorage<CalendarSettings>(
|
||||
'solidtime:calendar-settings',
|
||||
{
|
||||
snapMinutes: 15,
|
||||
startHour: 0,
|
||||
endHour: 24,
|
||||
slotMinutes: 15,
|
||||
},
|
||||
{ mergeDefaults: true }
|
||||
);
|
||||
|
||||
function onSettingsUpdate(newSettings: CalendarSettings) {
|
||||
calendarSettings.value = newSettings;
|
||||
}
|
||||
|
||||
// Reactive "now" for running time entry - updates every minute
|
||||
const currentTime = ref(getDayJsInstance()());
|
||||
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -204,16 +229,19 @@ function emitDatesChange(arg: DatesSetArg) {
|
||||
}
|
||||
|
||||
function handleDateSelect(arg: { start: Date; end: Date }) {
|
||||
const startTime = getDayJsInstance()(arg.start.toISOString())
|
||||
stopVisualSnap();
|
||||
const snap = calendarSettings.value.snapMinutes;
|
||||
const startLocal = getDayJsInstance()(arg.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc();
|
||||
const endTime = getDayJsInstance()(arg.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc();
|
||||
newEventStart.value = startTime;
|
||||
newEventEnd.value = endTime;
|
||||
.tz(getUserTimezone(), true);
|
||||
const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true);
|
||||
const snappedStart = snapStartToGrid(startLocal, snap);
|
||||
let snappedEnd = snapEndToGrid(endLocal, snap);
|
||||
if (!snappedEnd.isAfter(snappedStart)) {
|
||||
snappedEnd = snappedStart.add(snap, 'minute');
|
||||
}
|
||||
newEventStart.value = snappedStart.utc();
|
||||
newEventEnd.value = snappedEnd.utc();
|
||||
showCreateTimeEntryModal.value = true;
|
||||
}
|
||||
|
||||
@@ -227,90 +255,143 @@ function handleEventClick(arg: EventClickArg) {
|
||||
showEditTimeEntryModal.value = true;
|
||||
}
|
||||
|
||||
// Snap a dayjs time down to the previous snap boundary (for start times)
|
||||
function snapStartToGrid(time: Dayjs, snapMinutes: number): Dayjs {
|
||||
const minutes = time.hour() * 60 + time.minute();
|
||||
const snapped = Math.floor(minutes / snapMinutes) * snapMinutes;
|
||||
return time.startOf('day').add(snapped, 'minute');
|
||||
}
|
||||
|
||||
// Snap a dayjs time up to the next snap boundary (for end times)
|
||||
function snapEndToGrid(time: Dayjs, snapMinutes: number): Dayjs {
|
||||
const minutes = time.hour() * 60 + time.minute();
|
||||
const snapped = Math.ceil(minutes / snapMinutes) * snapMinutes;
|
||||
return time.startOf('day').add(snapped, 'minute');
|
||||
}
|
||||
|
||||
// --- Visual snap (composable) ---
|
||||
const {
|
||||
startDragSnap: startVisualDragSnap,
|
||||
startResizeSnap: startVisualResizeSnap,
|
||||
stop: stopVisualSnap,
|
||||
} = useVisualSnap({
|
||||
calendarRef,
|
||||
snapMinutes: () => calendarSettings.value.snapMinutes,
|
||||
slotMinutes: () => calendarSettings.value.slotMinutes,
|
||||
formatDuration: (seconds) =>
|
||||
formatHumanReadableDuration(
|
||||
seconds,
|
||||
organization?.value?.interval_format,
|
||||
organization?.value?.number_format
|
||||
),
|
||||
});
|
||||
|
||||
async function handleEventDrop(arg: EventDropArg) {
|
||||
stopVisualSnap();
|
||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||
const timeEntry = ext.timeEntry;
|
||||
if (!arg.event.start || !arg.event.end) return;
|
||||
// Running entries have no end time — can't compute duration for drop
|
||||
if (!timeEntry.end) return;
|
||||
const snap = calendarSettings.value.snapMinutes;
|
||||
const startLocal = getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0);
|
||||
const snappedStart = snapStartToGrid(startLocal, snap);
|
||||
const durationMs = getLocalizedDayJs(timeEntry.end).diff(getLocalizedDayJs(timeEntry.start));
|
||||
const snappedEnd = snappedStart.add(durationMs, 'millisecond');
|
||||
// Set FC event to snapped position immediately to avoid flash
|
||||
arg.event.setDates(snappedStart.utc(true).toDate(), snappedEnd.utc(true).toDate());
|
||||
const updatedTimeEntry = {
|
||||
...timeEntry,
|
||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
start: snappedStart.utc().format(),
|
||||
end: snappedEnd.utc().format(),
|
||||
} as TimeEntry;
|
||||
await props.updateTimeEntry(updatedTimeEntry);
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
async function handleEventResize(arg: EventChangeArg) {
|
||||
stopVisualSnap();
|
||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||
const timeEntry = ext.timeEntry;
|
||||
if (!arg.event.start || !arg.event.end) return;
|
||||
const snap = calendarSettings.value.snapMinutes;
|
||||
|
||||
const newStartLocal = getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0);
|
||||
const newEndLocal = getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0);
|
||||
const origStartLocal = getLocalizedDayJs(timeEntry.start).second(0);
|
||||
|
||||
const startChanged = !newStartLocal.isSame(origStartLocal, 'minute');
|
||||
|
||||
// Snap only the changed edge once, reuse for both setDates and API update
|
||||
const snappedStart = startChanged ? snapStartToGrid(newStartLocal, snap) : null;
|
||||
const snappedEnd = !startChanged && !ext.isRunning ? snapEndToGrid(newEndLocal, snap) : null;
|
||||
|
||||
// Set FC event to snapped position immediately to avoid flash.
|
||||
// Use the original event date for the edge that wasn't resized.
|
||||
if (snappedStart) {
|
||||
arg.event.setDates(snappedStart.utc(true).toDate(), arg.oldEvent.end!);
|
||||
} else if (snappedEnd) {
|
||||
arg.event.setDates(arg.oldEvent.start!, snappedEnd.utc(true).toDate());
|
||||
}
|
||||
const updatedTimeEntry = {
|
||||
...timeEntry,
|
||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
// Preserve null end for running entries
|
||||
end: ext.isRunning
|
||||
? null
|
||||
: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
start: snappedStart ? snappedStart.utc().format() : timeEntry.start,
|
||||
end: ext.isRunning ? null : snappedEnd ? snappedEnd.utc().format() : timeEntry.end,
|
||||
} as TimeEntry;
|
||||
await props.updateTimeEntry(updatedTimeEntry);
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
const calendarOptions = computed(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
|
||||
initialView: 'timeGridWeek',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'timeGridWeek,timeGridDay',
|
||||
},
|
||||
height: 'parent',
|
||||
slotMinTime: '00:00:00',
|
||||
slotMaxTime: '24:00:00',
|
||||
slotDuration: '00:15:00',
|
||||
slotLabelInterval: '01:00:00',
|
||||
slotLabelFormat: getSlotLabelFormat(),
|
||||
snapDuration: '00:01:00',
|
||||
firstDay: getFirstDay(),
|
||||
allDaySlot: false,
|
||||
nowIndicator: true,
|
||||
eventMinHeight: 1,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
editable: true,
|
||||
eventResizableFromStart: true,
|
||||
eventDurationEditable: true,
|
||||
timeZone: getUserTimezone(),
|
||||
eventStartEditable: true,
|
||||
select: handleDateSelect,
|
||||
eventClick: handleEventClick,
|
||||
eventDrop: handleEventDrop,
|
||||
eventResize: handleEventResize,
|
||||
datesSet: emitDatesChange,
|
||||
const calendarOptions = computed(() => {
|
||||
const s = calendarSettings.value;
|
||||
|
||||
events: events.value,
|
||||
activityPeriods: props.activityPeriods || [],
|
||||
}));
|
||||
return {
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
|
||||
initialView: 'timeGridWeek',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'timeGridWeek,timeGridDay',
|
||||
},
|
||||
height: 'parent',
|
||||
slotMinTime: formatDuration(s.startHour * 3600),
|
||||
slotMaxTime: formatDuration(s.endHour * 3600),
|
||||
slotDuration: formatDuration(s.slotMinutes * 60),
|
||||
slotLabelInterval: '01:00:00',
|
||||
slotLabelFormat: getSlotLabelFormat(),
|
||||
snapDuration: '00:01:00',
|
||||
firstDay: getFirstDay(),
|
||||
allDaySlot: false,
|
||||
nowIndicator: true,
|
||||
eventMinHeight: 1,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
editable: true,
|
||||
eventResizableFromStart: true,
|
||||
eventDurationEditable: true,
|
||||
timeZone: getUserTimezone(),
|
||||
eventStartEditable: true,
|
||||
select: handleDateSelect,
|
||||
eventClick: handleEventClick,
|
||||
eventDragStart: startVisualDragSnap,
|
||||
eventDrop: handleEventDrop,
|
||||
eventResizeStart: startVisualResizeSnap,
|
||||
eventResize: handleEventResize,
|
||||
datesSet: emitDatesChange,
|
||||
|
||||
events: events.value,
|
||||
activityPeriods: props.activityPeriods || [],
|
||||
};
|
||||
});
|
||||
|
||||
watch(showCreateTimeEntryModal, (value) => {
|
||||
if (!value) {
|
||||
@@ -376,7 +457,6 @@ onActivated(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up interval
|
||||
if (currentTimeInterval) {
|
||||
clearInterval(currentTimeInterval);
|
||||
currentTimeInterval = null;
|
||||
@@ -424,6 +504,11 @@ onUnmounted(() => {
|
||||
:clients="clients"
|
||||
:currency="currency"
|
||||
:can-create-project="canCreateProject" />
|
||||
<div class="calendar-settings-trigger">
|
||||
<CalendarSettingsPopover
|
||||
:settings="calendarSettings"
|
||||
@update:settings="onSettingsUpdate" />
|
||||
</div>
|
||||
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
||||
<template #eventContent="arg">
|
||||
<FullCalendarEventContent
|
||||
@@ -458,6 +543,13 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-settings-trigger {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.fullcalendar {
|
||||
height: 100%;
|
||||
--fc-border-color: var(--border);
|
||||
@@ -482,6 +574,7 @@ onUnmounted(() => {
|
||||
.fullcalendar :deep(.fc-toolbar) {
|
||||
background-color: var(--background);
|
||||
padding: 0.5rem;
|
||||
padding-right: 2.75rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -580,36 +673,65 @@ onUnmounted(() => {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Enhanced FullCalendar resize handles */
|
||||
/* Resize handle hit areas */
|
||||
.fullcalendar :deep(.fc-event-resizer) {
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
background: '#FFF';
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
height: 12px;
|
||||
left: 0;
|
||||
transition: all 0.2s ease;
|
||||
cursor: row-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizer-start) {
|
||||
top: -2px;
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizer-end) {
|
||||
bottom: -2px;
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
/* Visual grip indicator */
|
||||
.fullcalendar :deep(.fc-event-resizer::after) {
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
border-radius: 1.5px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event:hover .fc-event-resizer) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizer:hover) {
|
||||
background: '#FFF';
|
||||
height: 6px;
|
||||
.fullcalendar :deep(.fc-event-resizer:hover::after) {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* Keep resize cursor during active resize */
|
||||
.fullcalendar :deep(.fc-event-resizing),
|
||||
.fullcalendar :deep(.fc-event-resizing .fc-event-resizer) {
|
||||
cursor: row-resize !important;
|
||||
}
|
||||
|
||||
/* Keep event in hover state while resizing */
|
||||
.fullcalendar :deep(.fc-event-resizing) {
|
||||
opacity: 1;
|
||||
box-shadow: var(--theme-shadow-dropdown);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizing .fc-event-resizer) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-resizing .fc-event-resizer::after) {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* Update the earlier hover rule to include the shadow */
|
||||
@@ -632,6 +754,10 @@ onUnmounted(() => {
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event-mirror) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-scrollgrid) {
|
||||
border: 1px solid var(--border);
|
||||
border-left: 1px solid transparent;
|
||||
@@ -763,3 +889,11 @@ onUnmounted(() => {
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Global cursor override during resize — must be unscoped to affect body */
|
||||
body.fc-resizing-active,
|
||||
body.fc-resizing-active * {
|
||||
cursor: row-resize !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface CalendarSettings {
|
||||
snapMinutes: number;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
slotMinutes: number;
|
||||
}
|
||||
189
resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts
Normal file
189
resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { onActivated, onDeactivated, onMounted, onUnmounted, type Ref } from 'vue';
|
||||
import type FullCalendar from '@fullcalendar/vue3';
|
||||
|
||||
interface VisualSnapOptions {
|
||||
calendarRef: Ref<InstanceType<typeof FullCalendar> | null>;
|
||||
snapMinutes: () => number;
|
||||
slotMinutes: () => number;
|
||||
formatDuration: (durationSeconds: number) => string;
|
||||
}
|
||||
|
||||
export function useVisualSnap({
|
||||
calendarRef,
|
||||
snapMinutes,
|
||||
slotMinutes,
|
||||
formatDuration,
|
||||
}: VisualSnapOptions) {
|
||||
let rafId: number | null = null;
|
||||
|
||||
function getCalendarEl(): HTMLElement | null {
|
||||
return (calendarRef.value?.$el as HTMLElement) ?? null;
|
||||
}
|
||||
|
||||
function getSnapPixels(): number {
|
||||
const calendarEl = getCalendarEl();
|
||||
if (!calendarEl) return 25;
|
||||
const slot = calendarEl.querySelector('.fc-timegrid-slot-lane') as HTMLElement;
|
||||
if (!slot) return 25;
|
||||
const slotHeightPx = slot.getBoundingClientRect().height;
|
||||
return (snapMinutes() / slotMinutes()) * slotHeightPx;
|
||||
}
|
||||
|
||||
function findMirrorHarness(calendarEl: HTMLElement) {
|
||||
const mirror = calendarEl.querySelector('.fc-event-mirror') as HTMLElement | null;
|
||||
const harness = mirror?.closest('.fc-timegrid-event-harness') as HTMLElement | null;
|
||||
if (harness) {
|
||||
harness.style.pointerEvents = 'none';
|
||||
}
|
||||
return { mirror, harness };
|
||||
}
|
||||
|
||||
function updateMirrorDurationLabel(
|
||||
mirror: HTMLElement,
|
||||
snappedTop: number,
|
||||
snappedEnd: number,
|
||||
snapPx: number
|
||||
) {
|
||||
const snappedDurationMin = Math.round((snappedEnd - snappedTop) / snapPx) * snapMinutes();
|
||||
const durationText = formatDuration(snappedDurationMin * 60);
|
||||
const durationEl = mirror.querySelector('.fc-event-main')?.querySelector('div:last-child');
|
||||
if (durationEl) {
|
||||
durationEl.textContent = durationText;
|
||||
}
|
||||
}
|
||||
|
||||
function startLoop(onFrame: (calendarEl: HTMLElement, snapPx: number) => void) {
|
||||
const calendarEl = getCalendarEl();
|
||||
if (!calendarEl) return;
|
||||
const snapPx = getSnapPixels();
|
||||
if (snapPx <= 0) return;
|
||||
|
||||
const loop = () => {
|
||||
onFrame(calendarEl, snapPx);
|
||||
rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
document.body.classList.remove('fc-resizing-active');
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public snap starters ---
|
||||
|
||||
function startSelectSnap() {
|
||||
// Don't start if another snap loop is already running
|
||||
if (rafId !== null) return;
|
||||
startLoop((calendarEl, snapPx) => {
|
||||
const { mirror, harness } = findMirrorHarness(calendarEl);
|
||||
if (!harness || !mirror) return;
|
||||
|
||||
const top = parseFloat(harness.style.top) || 0;
|
||||
const endPos = -(parseFloat(harness.style.bottom) || 0);
|
||||
const snappedTop = Math.floor(top / snapPx) * snapPx;
|
||||
const snappedEnd = Math.ceil(endPos / snapPx) * snapPx;
|
||||
const clampedEnd = Math.max(snappedTop + snapPx, snappedEnd);
|
||||
harness.style.top = snappedTop + 'px';
|
||||
harness.style.bottom = -clampedEnd + 'px';
|
||||
updateMirrorDurationLabel(mirror, snappedTop, clampedEnd, snapPx);
|
||||
});
|
||||
}
|
||||
|
||||
function startDragSnap() {
|
||||
stop();
|
||||
startLoop((calendarEl, snapPx) => {
|
||||
const { harness } = findMirrorHarness(calendarEl);
|
||||
if (!harness) return;
|
||||
|
||||
const top = parseFloat(harness.style.top) || 0;
|
||||
const endPos = -(parseFloat(harness.style.bottom) || 0);
|
||||
const height = endPos - top;
|
||||
const snappedTop = Math.floor(top / snapPx) * snapPx;
|
||||
harness.style.top = snappedTop + 'px';
|
||||
harness.style.bottom = -(snappedTop + height) + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
function startResizeSnap() {
|
||||
stop();
|
||||
document.body.classList.add('fc-resizing-active');
|
||||
|
||||
let initialTop: number | null = null;
|
||||
let initialEnd: number | null = null;
|
||||
let resizeEdge: 'top' | 'bottom' | null = null;
|
||||
|
||||
startLoop((calendarEl, snapPx) => {
|
||||
const { mirror, harness } = findMirrorHarness(calendarEl);
|
||||
if (!harness) return;
|
||||
|
||||
const top = parseFloat(harness.style.top) || 0;
|
||||
const endPos = -(parseFloat(harness.style.bottom) || 0);
|
||||
|
||||
// Detect which edge is being resized
|
||||
if (initialTop === null) {
|
||||
initialTop = top;
|
||||
initialEnd = endPos;
|
||||
} else if (resizeEdge === null) {
|
||||
const topDelta = Math.abs(top - initialTop);
|
||||
const endDelta = Math.abs(endPos - initialEnd!);
|
||||
if (topDelta > 0.5) {
|
||||
resizeEdge = 'top';
|
||||
} else if (endDelta > 0.5) {
|
||||
resizeEdge = 'bottom';
|
||||
}
|
||||
}
|
||||
|
||||
if (resizeEdge === 'bottom') {
|
||||
const snappedEnd = Math.ceil(endPos / snapPx) * snapPx;
|
||||
const clampedEnd = Math.max(top + snapPx, snappedEnd);
|
||||
harness.style.bottom = -clampedEnd + 'px';
|
||||
if (mirror) updateMirrorDurationLabel(mirror, top, clampedEnd, snapPx);
|
||||
} else if (resizeEdge === 'top') {
|
||||
const snappedTop = Math.floor(top / snapPx) * snapPx;
|
||||
const clampedTop = Math.min(endPos - snapPx, snappedTop);
|
||||
harness.style.top = clampedTop + 'px';
|
||||
if (mirror) updateMirrorDurationLabel(mirror, clampedTop, endPos, snapPx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pointerdown handler for starting select snap on timegrid background
|
||||
function handleTimegridPointerDown(e: PointerEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.fc-event')) return;
|
||||
startSelectSnap();
|
||||
}
|
||||
|
||||
// Lifecycle: attach/detach pointerdown listener
|
||||
function attachListener() {
|
||||
const calendarEl = getCalendarEl();
|
||||
calendarEl?.addEventListener('pointerdown', handleTimegridPointerDown);
|
||||
}
|
||||
|
||||
function detachListener() {
|
||||
const calendarEl = getCalendarEl();
|
||||
calendarEl?.removeEventListener('pointerdown', handleTimegridPointerDown);
|
||||
}
|
||||
|
||||
onMounted(attachListener);
|
||||
onActivated(attachListener);
|
||||
onDeactivated(() => {
|
||||
stop();
|
||||
detachListener();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
stop();
|
||||
detachListener();
|
||||
});
|
||||
|
||||
return {
|
||||
startSelectSnap,
|
||||
startDragSnap,
|
||||
startResizeSnap,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
|
||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
||||
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
||||
import CalendarSettingsPopover from './FullCalendar/CalendarSettingsPopover.vue';
|
||||
import DateRangePicker from './Input/DateRangePicker.vue';
|
||||
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
} from './field/index';
|
||||
export type { FieldVariants } from './field/index';
|
||||
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
|
||||
export type { CalendarSettings } from './FullCalendar/calendarSettings';
|
||||
export type {
|
||||
CommandPaletteCommand,
|
||||
CommandPaletteGroup,
|
||||
@@ -92,6 +94,7 @@ export {
|
||||
FullCalendarEventContent,
|
||||
FullCalendarDayHeader,
|
||||
TimeEntryCalendar,
|
||||
CalendarSettingsPopover,
|
||||
DateRangePicker,
|
||||
TimezoneMismatchModal,
|
||||
Tooltip,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
@component('mail::message')
|
||||
|
||||
{{ __('The API token ":token" expired.', ['token' => $tokenName]) }}
|
||||
{{ __('The API token ":token" will expire in 7 days!', ['token' => $tokenName]) }}
|
||||
|
||||
{{ __('Please make sure to create a new API token and use the new one instead before it expires to avoid any disruptions in service.') }}
|
||||
|
||||
{{ __('You can create a new API token in your profile:') }}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@component('mail::message')
|
||||
|
||||
{{ __('The API token ":token" will expire in 7 days!', ['token' => $tokenName]) }}
|
||||
{{ __('The API token ":token" expired.', ['token' => $tokenName]) }}
|
||||
|
||||
{{ __('Please make sure to create a new API token and use the new one instead before it expires to avoid any disruptions in service.') }}
|
||||
|
||||
{{ __('You can create a new API token in your profile:') }}
|
||||
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
@component('mail::message')
|
||||
{{ __('You have been invited to join the :organization organization!', ['organization' => $invitation->organization->name]) }}
|
||||
|
||||
@if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::registration()))
|
||||
{{ __('If you do not have an account, you may create one by clicking the button below. After creating an account, you may click the invitation acceptance button in this email to accept the team invitation:') }}
|
||||
|
||||
@component('mail::button', ['url' => route('register')])
|
||||
{{ __('Create Account') }}
|
||||
@endcomponent
|
||||
|
||||
{{ __('If you already have an account, you may accept this invitation by clicking the button below:') }}
|
||||
|
||||
@else
|
||||
{{ __('You may accept this invitation by clicking the button below:') }}
|
||||
@endif
|
||||
|
||||
|
||||
@component('mail::button', ['url' => $acceptUrl])
|
||||
{{ __('Accept Invitation') }}
|
||||
@endcomponent
|
||||
|
||||
@@ -42,8 +42,10 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
|
||||
])->group(static function (): void {
|
||||
// Organization routes
|
||||
Route::name('organizations.')->group(static function (): void {
|
||||
Route::post('/organizations', [OrganizationController::class, 'store'])->name('store');
|
||||
Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show');
|
||||
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');
|
||||
Route::delete('/organizations/{organization}', [OrganizationController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Member routes
|
||||
@@ -59,6 +61,8 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
|
||||
// User routes
|
||||
Route::name('users.')->group(static function (): void {
|
||||
Route::get('/users/me', [UserController::class, 'me'])->name('me');
|
||||
Route::put('/users/{user}', [UserController::class, 'update'])->name('update');
|
||||
Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Api token routes
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Web\DashboardController;
|
||||
use App\Http\Controllers\Web\HomeController;
|
||||
use App\Http\Controllers\Web\OrganizationInvitationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
@@ -83,3 +84,10 @@ Route::middleware([
|
||||
})->name('import');
|
||||
|
||||
});
|
||||
|
||||
Route::get('/team-invitations/{invitation}', [OrganizationInvitationController::class, 'accept'])
|
||||
->middleware(['signed'])
|
||||
->name('team-invitations.accept'); // Note: legacy naming
|
||||
Route::get('/organization-invitations/{invitation}', [OrganizationInvitationController::class, 'accept'])
|
||||
->middleware(['signed:relative'])
|
||||
->name('organization-invitations.accept');
|
||||
|
||||
@@ -88,7 +88,7 @@ class InviteTeamMemberTest extends TestCase
|
||||
Mail::fake();
|
||||
$placeholder = User::factory()->placeholder()->create();
|
||||
$owner = User::factory()->withPersonalOrganization()->create();
|
||||
$placeholderMember = Member::factory()->forOrganization($owner->currentTeam)->forUser($placeholder)->create();
|
||||
$placeholderMember = Member::factory()->role(Role::Placeholder)->forOrganization($owner->currentTeam)->forUser($placeholder)->create();
|
||||
|
||||
$timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forMember($placeholderMember)->createMany(5);
|
||||
|
||||
|
||||
@@ -8,21 +8,19 @@ use App\Enums\Role;
|
||||
use App\Enums\Weekday;
|
||||
use App\Events\NewsletterRegistered;
|
||||
use App\Models\Member;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use App\Service\IpLookup\IpLookupResponseDto;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Tests\TestCase;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
|
||||
class RegistrationTest extends TestCase
|
||||
class RegistrationTest extends TestCaseWithDatabase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_registration_screen_can_be_rendered(): void
|
||||
{
|
||||
if (! Features::enabled(Features::registration())) {
|
||||
@@ -346,4 +344,37 @@ class RegistrationTest extends TestCase
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||
}
|
||||
|
||||
public function test_registration_does_not_create_private_organization_if_invite_was_accepted_for_the_email_with_the_registration_email(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$organizationInvitation = OrganizationInvitation::factory()
|
||||
->forOrganization($user->organization)
|
||||
->role(Role::Employee)
|
||||
->accepted()
|
||||
->create([
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||
$newUser = User::where('email', 'test@example.com')->first();
|
||||
$this->assertNotNull($newUser);
|
||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
$organizations = $newUser->organizations;
|
||||
$this->assertCount(1, $organizations);
|
||||
$this->assertSame($user->organization->id, $organizations->first()->id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,4 +260,52 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
|
||||
'billable_rate' => $organizationFake->billable_rate,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_endpoint_if_user_does_not_have_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_delete_endpoint_fails_with_not_found_if_id_is_not_uuid(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'organizations:delete',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.organizations.destroy', ['not-uuid']));
|
||||
|
||||
// Assert
|
||||
$response->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_delete_endpoint_can_delete_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'organizations:delete',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertNoContent();
|
||||
$this->assertDatabaseMissing(Organization::class, [
|
||||
'id' => $data->organization->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
// LAST state: organization store, remove update update
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
@@ -40,4 +41,57 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_fails_if_given_user_is_not_the_authenticated_user(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
$otherData = $this->createUserWithPermission();
|
||||
Passport::actingAs($otherData->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_delete_fails_if_not_authenticated(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
|
||||
|
||||
// Assert
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_delete_fails_if_user_does_not_exist(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.users.destroy', 'not-valid'));
|
||||
|
||||
// Assert
|
||||
$response->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_delete_removes_user(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
|
||||
|
||||
// Assert
|
||||
$response->assertNoContent();
|
||||
$this->assertDatabaseMissing(User::class, ['id' => $data->user->getKey()]);
|
||||
}
|
||||
}
|
||||
|
||||
220
tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php
Normal file
220
tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Web;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Http\Controllers\Web\OrganizationInvitationController;
|
||||
use App\Models\Member;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
|
||||
#[CoversClass(OrganizationInvitationController::class)]
|
||||
#[CoversClass(MemberService::class)]
|
||||
class OrganizationInvitationEndpointTest extends EndpointTestAbstract
|
||||
{
|
||||
public function test_legacy_url_still_works(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$invitation = OrganizationInvitation::factory()
|
||||
->forOrganization($user->organization)
|
||||
->create();
|
||||
|
||||
// Act
|
||||
$acceptUrl = URL::temporarySignedRoute(
|
||||
'team-invitations.accept',
|
||||
now()->addMinutes(60),
|
||||
[$invitation->getKey()]
|
||||
);
|
||||
$response = $this->get($acceptUrl);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$response->assertRedirect(route('register', [
|
||||
'bannerStyle' => 'info',
|
||||
'bannerText' => 'Please create an account to finish joining the '.$user->organization->name.' organization.',
|
||||
]));
|
||||
$invitation->refresh();
|
||||
$this->assertNotNull($invitation->accepted_at);
|
||||
}
|
||||
|
||||
public function test_can_accept_invitation_without_an_account_with_the_email_address_and_redirects_to_registration(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$invitation = OrganizationInvitation::factory()
|
||||
->forOrganization($user->organization)
|
||||
->create();
|
||||
|
||||
// Act
|
||||
$acceptUrl = URl::to(URL::temporarySignedRoute(
|
||||
'organization-invitations.accept',
|
||||
now()->addMinutes(60),
|
||||
[$invitation->getKey()],
|
||||
false
|
||||
));
|
||||
$response = $this->get($acceptUrl);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$response->assertRedirect(route('register', [
|
||||
'bannerStyle' => 'info',
|
||||
'bannerText' => 'Please create an account to finish joining the '.$user->organization->name.' organization.',
|
||||
]));
|
||||
$invitation->refresh();
|
||||
$this->assertNotNull($invitation->accepted_at);
|
||||
}
|
||||
|
||||
public function test_can_accept_invitation_with_an_account_with_the_email_address_and_redirects_to_dashboard(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$user2 = $this->createUserWithPermission();
|
||||
$invitation = OrganizationInvitation::factory()
|
||||
->forOrganization($user->organization)
|
||||
->create([
|
||||
'role' => Role::Employee->value,
|
||||
'email' => $user2->user->email,
|
||||
]);
|
||||
$this->actingAs($user2->user);
|
||||
|
||||
// Act
|
||||
$acceptUrl = URl::to(URL::temporarySignedRoute(
|
||||
'organization-invitations.accept',
|
||||
now()->addMinutes(60),
|
||||
[$invitation->getKey()],
|
||||
false
|
||||
));
|
||||
$response = $this->get($acceptUrl);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$response->assertRedirect(route('dashboard', [
|
||||
'bannerStyle' => 'success',
|
||||
'bannerText' => 'Great! You have accepted the invitation to join the '.$user->organization->name.' organization.',
|
||||
]));
|
||||
$this->assertDatabaseHas(Member::class, [
|
||||
'user_id' => $user2->user->getKey(),
|
||||
'organization_id' => $user->organization->getKey(),
|
||||
'role' => Role::Employee->value,
|
||||
]);
|
||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||
'id' => $invitation->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_fails_if_user_is_already_member_of_the_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$user2 = $this->createUserWithPermission();
|
||||
$invitation = OrganizationInvitation::factory()
|
||||
->forOrganization($user->organization)
|
||||
->create([
|
||||
'role' => Role::Employee->value,
|
||||
'email' => $user2->user->email,
|
||||
]);
|
||||
Member::factory()->forOrganization($user->organization)->forUser($user2->user)->create();
|
||||
$this->actingAs($user2->user);
|
||||
|
||||
// Act
|
||||
$acceptUrl = URl::to(URL::temporarySignedRoute(
|
||||
'organization-invitations.accept',
|
||||
now()->addMinutes(60),
|
||||
[$invitation->getKey()],
|
||||
false
|
||||
));
|
||||
$response = $this->get($acceptUrl);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$response->assertRedirect(route('dashboard', [
|
||||
'bannerStyle' => 'danger',
|
||||
'bannerText' => 'You are already a member of the '.$user->organization->name.' organization.',
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_accepting_invitation_with_existing_account_migrates_data_of_placeholder_users_with_same_email_to_new_member(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$user2 = $this->createUserWithPermission();
|
||||
$invitation = OrganizationInvitation::factory()
|
||||
->forOrganization($user->organization)
|
||||
->create([
|
||||
'role' => Role::Employee->value,
|
||||
'email' => $user2->user->email,
|
||||
]);
|
||||
$placeholder1 = User::factory()->placeholder()->create([
|
||||
'email' => $user2->user->email,
|
||||
]);
|
||||
$placeholder1Member = Member::factory()->forOrganization($user->organization)->forUser($placeholder1)->role(Role::Placeholder)->create();
|
||||
$placeholder2 = User::factory()->placeholder()->create([
|
||||
'email' => $user2->user->email,
|
||||
]);
|
||||
$placeholder2Member = Member::factory()->forOrganization($user->organization)->forUser($placeholder2)->role(Role::Placeholder)->create();
|
||||
|
||||
$this->actingAs($user2->user);
|
||||
|
||||
// Act
|
||||
$acceptUrl = URl::to(URL::temporarySignedRoute(
|
||||
'organization-invitations.accept',
|
||||
now()->addMinutes(60),
|
||||
[$invitation->getKey()],
|
||||
false
|
||||
));
|
||||
$response = $this->get($acceptUrl);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$response->assertRedirect(route('dashboard', [
|
||||
'bannerStyle' => 'success',
|
||||
'bannerText' => 'Great! You have accepted the invitation to join the '.$user->organization->name.' organization.',
|
||||
]));
|
||||
$this->assertDatabaseHas(Member::class, [
|
||||
'user_id' => $user2->user->getKey(),
|
||||
'organization_id' => $user->organization->getKey(),
|
||||
'role' => Role::Employee->value,
|
||||
]);
|
||||
$this->assertDatabaseMissing(User::class, [
|
||||
'id' => $placeholder1->getKey(),
|
||||
]);
|
||||
$this->assertDatabaseMissing(User::class, [
|
||||
'id' => $placeholder2->getKey(),
|
||||
]);
|
||||
$this->assertDatabaseMissing(Member::class, [
|
||||
'id' => $placeholder1Member->getKey(),
|
||||
]);
|
||||
$this->assertDatabaseMissing(Member::class, [
|
||||
'id' => $placeholder2Member->getKey(),
|
||||
]);
|
||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||
'id' => $invitation->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_fails_with_invalid_signature(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$invitation = OrganizationInvitation::factory()
|
||||
->forOrganization($user->organization)
|
||||
->create();
|
||||
|
||||
// Act
|
||||
$response = $this->get(URL::temporarySignedRoute(
|
||||
'organization-invitations.accept',
|
||||
now()->addMinutes(60),
|
||||
[$invitation->getKey()]).
|
||||
'?invalid'
|
||||
);
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,6 @@ class AuthApiTokenExpirationReminderMailTest extends TestCaseWithDatabase
|
||||
$rendered = $mail->render();
|
||||
|
||||
// Assert
|
||||
$this->assertStringContainsString('The API token "TEST" expired.', $rendered);
|
||||
$this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,6 @@ class AuthApiTokenExpiredMailTest extends TestCaseWithDatabase
|
||||
$rendered = $mail->render();
|
||||
|
||||
// Assert
|
||||
$this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered);
|
||||
$this->assertStringContainsString('The API token "TEST" expired.', $rendered);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user