mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 05:43:18 +01:00
Compare commits
9 Commits
v0.20.0
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
644c8f958a | ||
|
|
d54296e66a | ||
|
|
95ddbf9ead | ||
|
|
70646a0dd4 | ||
|
|
5b12c09747 | ||
|
|
24023353f2 | ||
|
|
720d20c10e | ||
|
|
82ea9af8b5 | ||
|
|
169d522da0 |
37
app/Auth/ActiveUserProvider.php
Normal file
37
app/Auth/ActiveUserProvider.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ class ImportRequest extends BaseFormRequest
|
|||||||
'data' => [
|
'data' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
|
'max:'.config('import.max_data_size'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ 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
|
||||||
@@ -150,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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
129
app/Service/Import/Importers/ZipImportHelper.php
Normal file
129
app/Service/Import/Importers/ZipImportHelper.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
34
config/import.php
Normal file
34
config/import.php
Normal 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),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -252,19 +252,19 @@ test('test that calendar page loads and displays time entries', async ({ page, c
|
|||||||
test('test that calendar navigation buttons work', async ({ page }) => {
|
test('test that calendar navigation buttons work', async ({ page }) => {
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
||||||
|
|
||||||
// Click the "next" button to navigate forward
|
// Navigate between named periods.
|
||||||
await page.getByRole('button', { name: 'Next' }).click();
|
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
|
||||||
|
|
||||||
// Click the "prev" button to navigate back
|
|
||||||
await page.getByRole('button', { name: 'Previous' }).click();
|
await page.getByRole('button', { name: 'Previous' }).click();
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.getByTestId('calendar-title')).toContainText('Last Week');
|
||||||
|
|
||||||
// Navigate forward first, then click today
|
|
||||||
await page.getByRole('button', { name: 'Next' }).click();
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
||||||
|
|
||||||
|
// Navigate forward first, then use the range button to return to today.
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await expect(page.getByTestId('calendar-title')).toContainText('Next Week');
|
||||||
await page.getByRole('button', { name: 'today' }).click();
|
await page.getByRole('button', { name: 'today' }).click();
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ import {
|
|||||||
// Each test registers a new user and creates test data via API
|
// Each test registers a new user and creates test data via API
|
||||||
test.describe.configure({ timeout: 30000 });
|
test.describe.configure({ timeout: 30000 });
|
||||||
|
|
||||||
|
test('date range picker labels and navigates reporting periods', async ({ page }) => {
|
||||||
|
await goToReporting(page);
|
||||||
|
|
||||||
|
const range = page.getByTestId('date_range_picker_display');
|
||||||
|
const previous = page.getByTestId('date_range_picker_previous');
|
||||||
|
const next = page.getByTestId('date_range_picker_next');
|
||||||
|
|
||||||
|
await expect(range).toContainText('Last 14 Days');
|
||||||
|
await expect(next).toBeDisabled();
|
||||||
|
|
||||||
|
await Promise.all([waitForReportingUpdate(page), previous.click()]);
|
||||||
|
await expect(range).not.toContainText('Last 14 Days');
|
||||||
|
|
||||||
|
// The initial reporting range is still fresh in Vue Query's cache, so returning to it
|
||||||
|
// does not necessarily trigger another aggregate request.
|
||||||
|
await next.click();
|
||||||
|
await expect(range).toContainText('Last 14 Days');
|
||||||
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// Project Multiselect Dropdown Tests
|
// Project Multiselect Dropdown Tests
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -409,20 +409,20 @@ test('navigating to previous week shows entries from that week', async ({ page,
|
|||||||
test('can navigate forward and return to current week', async ({ page }) => {
|
test('can navigate forward and return to current week', async ({ page }) => {
|
||||||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||||||
|
|
||||||
// Should show "This week"
|
// Should show "This Week"
|
||||||
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
|
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
|
||||||
|
|
||||||
// Go to next week — the text assertions below auto-retry until the
|
// Go to next week — the text assertions below auto-retry until the
|
||||||
// header label flips.
|
// header label flips.
|
||||||
await page.getByTestId('timesheet_next_week').click();
|
await page.getByTestId('timesheet_next_week').click();
|
||||||
|
|
||||||
// Should no longer show "This week"
|
// Should no longer show "This Week"
|
||||||
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
|
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This Week');
|
||||||
|
|
||||||
// Go back to this week
|
// Go back to this week
|
||||||
await page.getByTestId('timesheet_week_display').click();
|
await page.getByTestId('timesheet_week_display').click();
|
||||||
|
|
||||||
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
|
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
},
|
},
|
||||||
"Invoicing": {
|
"Invoicing": {
|
||||||
"repository": "solidtime-io/extension-invoicing",
|
"repository": "solidtime-io/extension-invoicing",
|
||||||
"ref": "v0.0.5"
|
"ref": "v0.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -8564,7 +8564,7 @@
|
|||||||
},
|
},
|
||||||
"resources/js/packages/ui": {
|
"resources/js/packages/ui": {
|
||||||
"name": "@solidtime/ui",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.22",
|
"version": "0.0.23",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chroma-js": "^3.1.2",
|
"@types/chroma-js": "^3.1.2",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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 =
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const { handleApiRequestNotifications } = useNotificationsStore();
|
|||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
'reporting-start-date',
|
'reporting-start-date',
|
||||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format()
|
||||||
);
|
);
|
||||||
const endDate = useSessionStorage<string>(
|
const endDate = useSessionStorage<string>(
|
||||||
'reporting-end-date',
|
'reporting-end-date',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button } from '@/packages/ui/src/Buttons';
|
import DateRangeNavigator from '@/packages/ui/src/Input/DateRangeNavigator.vue';
|
||||||
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@heroicons/vue/20/solid';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
isCurrentWeek: boolean;
|
|
||||||
weekNumber: number;
|
weekNumber: number;
|
||||||
weekRangeDisplay: string;
|
rangeLabel: string;
|
||||||
weekTotalFormatted: string;
|
weekTotalFormatted: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -19,33 +17,16 @@ defineEmits<{
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
|
||||||
<!-- Left: Week navigation -->
|
<!-- Left: Week navigation -->
|
||||||
<div class="flex items-center gap-2">
|
<DateRangeNavigator
|
||||||
<Button
|
:label="rangeLabel"
|
||||||
variant="outline"
|
:secondary-label="`W${weekNumber}`"
|
||||||
size="icon"
|
previous-test-id="timesheet_prev_week"
|
||||||
class="h-8 w-8"
|
trigger-test-id="timesheet_week_display"
|
||||||
data-testid="timesheet_prev_week"
|
next-test-id="timesheet_next_week"
|
||||||
@click="$emit('previous')">
|
trigger-aria-label="Return to current week"
|
||||||
<ChevronLeftIcon class="h-4 w-4" />
|
@previous="$emit('previous')"
|
||||||
</Button>
|
@next="$emit('next')"
|
||||||
<button
|
@select="$emit('current')" />
|
||||||
data-testid="timesheet_week_display"
|
|
||||||
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-primary hover:bg-card-background rounded-md transition"
|
|
||||||
@click="$emit('current')">
|
|
||||||
<CalendarIcon class="h-4 w-4 text-icon-default" />
|
|
||||||
<span v-if="isCurrentWeek">This week</span>
|
|
||||||
<span v-else>{{ weekRangeDisplay }}</span>
|
|
||||||
<span class="text-text-tertiary">· W{{ weekNumber }}</span>
|
|
||||||
</button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8"
|
|
||||||
data-testid="timesheet_next_week"
|
|
||||||
@click="$emit('next')">
|
|
||||||
<ChevronRightIcon class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Week total -->
|
<!-- Right: Week total -->
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -61,7 +61,7 @@ type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
|||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
'reporting-start-date',
|
'reporting-start-date',
|
||||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format()
|
||||||
);
|
);
|
||||||
const endDate = useSessionStorage<string>(
|
const endDate = useSessionStorage<string>(
|
||||||
'reporting-end-date',
|
'reporting-end-date',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
getLocalizedDayJs,
|
getLocalizedDayJs,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
|
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
|
||||||
|
import { getDateRangeLabel } from '@/packages/ui/src/utils/dateRange';
|
||||||
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
|
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
|
||||||
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
|
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
|
||||||
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
|
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
|
||||||
@@ -44,7 +45,6 @@ const {
|
|||||||
weekEnd,
|
weekEnd,
|
||||||
weekDays,
|
weekDays,
|
||||||
weekNumber,
|
weekNumber,
|
||||||
isCurrentWeek,
|
|
||||||
todayDate,
|
todayDate,
|
||||||
goToPreviousWeek,
|
goToPreviousWeek,
|
||||||
goToNextWeek,
|
goToNextWeek,
|
||||||
@@ -109,13 +109,9 @@ const weekTotalFormatted = computed(() =>
|
|||||||
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
|
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
const weekRangeDisplay = computed(() => {
|
const weekRangeLabel = computed(() =>
|
||||||
const start = weekStart.value;
|
getDateRangeLabel(weekStart.value, weekStart.value.add(6, 'day'))
|
||||||
const end = start.add(6, 'day');
|
);
|
||||||
return start.month() === end.month()
|
|
||||||
? `${start.format('MMM D')} - ${end.format('D')}`
|
|
||||||
: `${start.format('MMM D')} - ${end.format('MMM D')}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Cell / row mutation handlers ──────────────────────────────────
|
// ── Cell / row mutation handlers ──────────────────────────────────
|
||||||
const {
|
const {
|
||||||
@@ -208,9 +204,8 @@ async function createTag(name: string): Promise<Tag | undefined> {
|
|||||||
<AppLayout title="Timesheet" data-testid="timesheet_view">
|
<AppLayout title="Timesheet" data-testid="timesheet_view">
|
||||||
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
||||||
<TimesheetHeader
|
<TimesheetHeader
|
||||||
:is-current-week="isCurrentWeek"
|
|
||||||
:week-number="weekNumber"
|
:week-number="weekNumber"
|
||||||
:week-range-display="weekRangeDisplay"
|
:range-label="weekRangeLabel"
|
||||||
:week-total-formatted="weekTotalFormatted"
|
:week-total-formatted="weekTotalFormatted"
|
||||||
@previous="goToPreviousWeek"
|
@previous="goToPreviousWeek"
|
||||||
@next="goToNextWeek"
|
@next="goToNextWeek"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button } from '..';
|
|
||||||
import { ChevronLeft, ChevronRight } from '@lucide/vue';
|
|
||||||
import { Tabs, TabsList } from '../tabs';
|
import { Tabs, TabsList } from '../tabs';
|
||||||
import TabBarItem from '../TabBar/TabBarItem.vue';
|
import TabBarItem from '../TabBar/TabBarItem.vue';
|
||||||
|
import DateRangeNavigator from '../Input/DateRangeNavigator.vue';
|
||||||
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
viewTitle: string;
|
rangeLabel: string;
|
||||||
activeView: string;
|
activeView: string;
|
||||||
settings: CalendarSettings;
|
settings: CalendarSettings;
|
||||||
}>();
|
}>();
|
||||||
@@ -23,31 +22,13 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
|
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
|
||||||
<!-- Left: Navigation -->
|
<DateRangeNavigator
|
||||||
<div class="flex items-center gap-1">
|
:label="rangeLabel"
|
||||||
<Button
|
trigger-test-id="calendar-title"
|
||||||
variant="outline"
|
trigger-aria-label="today"
|
||||||
size="sm"
|
@previous="emit('prev')"
|
||||||
class="h-8 w-8 p-0"
|
@next="emit('next')"
|
||||||
aria-label="Previous"
|
@select="emit('today')" />
|
||||||
@click="emit('prev')">
|
|
||||||
<ChevronLeft class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="h-8 w-8 p-0"
|
|
||||||
aria-label="Next"
|
|
||||||
@click="emit('next')">
|
|
||||||
<ChevronRight class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Center: Title -->
|
|
||||||
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
|
|
||||||
viewTitle
|
|
||||||
}}</span>
|
|
||||||
|
|
||||||
<!-- Right: View switcher + Settings -->
|
<!-- Right: View switcher + Settings -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const {
|
|||||||
const {
|
const {
|
||||||
activeView,
|
activeView,
|
||||||
viewDays,
|
viewDays,
|
||||||
viewTitle,
|
rangeLabel,
|
||||||
emitDatesChange,
|
emitDatesChange,
|
||||||
handlePrev,
|
handlePrev,
|
||||||
handleNext,
|
handleNext,
|
||||||
@@ -514,7 +514,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
|
|
||||||
<template v-if="!loading">
|
<template v-if="!loading">
|
||||||
<CalendarToolbar
|
<CalendarToolbar
|
||||||
:view-title="viewTitle"
|
:range-label="rangeLabel"
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
:settings="calendarSettings"
|
:settings="calendarSettings"
|
||||||
@prev="handlePrev"
|
@prev="handlePrev"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue';
|
|||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { getLocalizedDayJs } from '../utils/time';
|
import { getLocalizedDayJs } from '../utils/time';
|
||||||
import { getWeekStartDayNumber } from '../utils/settings';
|
import { getWeekStartDayNumber } from '../utils/settings';
|
||||||
|
import { getDateRangeLabel } from '../utils/dateRange';
|
||||||
|
|
||||||
export function useCalendarNavigation(callbacks: {
|
export function useCalendarNavigation(callbacks: {
|
||||||
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
||||||
@@ -34,24 +35,10 @@ export function useCalendarNavigation(callbacks: {
|
|||||||
return days;
|
return days;
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewTitle = computed(() => {
|
const rangeLabel = computed(() => {
|
||||||
if (activeView.value === 'timeGridDay') {
|
|
||||||
return currentDate.value.format('MMMM YYYY');
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = viewDays.value;
|
const days = viewDays.value;
|
||||||
if (days.length === 0) return '';
|
if (days.length === 0) return '';
|
||||||
|
return getDateRangeLabel(days[0]!, days[days.length - 1]!);
|
||||||
const first = days[0]!;
|
|
||||||
const last = days[days.length - 1]!;
|
|
||||||
|
|
||||||
if (first.year() !== last.year()) {
|
|
||||||
return `${first.format('MMM YYYY')} \u2013 ${last.format('MMM YYYY')}`;
|
|
||||||
}
|
|
||||||
if (first.month() !== last.month()) {
|
|
||||||
return `${first.format('MMM')} \u2013 ${last.format('MMM YYYY')}`;
|
|
||||||
}
|
|
||||||
return first.format('MMMM YYYY');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function emitDatesChange() {
|
function emitDatesChange() {
|
||||||
@@ -99,7 +86,7 @@ export function useCalendarNavigation(callbacks: {
|
|||||||
activeView,
|
activeView,
|
||||||
currentDate,
|
currentDate,
|
||||||
viewDays,
|
viewDays,
|
||||||
viewTitle,
|
rangeLabel,
|
||||||
emitDatesChange,
|
emitDatesChange,
|
||||||
handlePrev,
|
handlePrev,
|
||||||
handleNext,
|
handleNext,
|
||||||
|
|||||||
81
resources/js/packages/ui/src/Input/DateRangeNavigator.vue
Normal file
81
resources/js/packages/ui/src/Input/DateRangeNavigator.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronLeft, ChevronRight, CalendarIcon } from '@lucide/vue';
|
||||||
|
import { Button } from '../Buttons';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string;
|
||||||
|
secondaryLabel?: string;
|
||||||
|
previousDisabled?: boolean;
|
||||||
|
nextDisabled?: boolean;
|
||||||
|
previousTestId?: string;
|
||||||
|
triggerTestId?: string;
|
||||||
|
nextTestId?: string;
|
||||||
|
triggerAriaLabel?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
secondaryLabel: undefined,
|
||||||
|
previousTestId: undefined,
|
||||||
|
triggerTestId: undefined,
|
||||||
|
nextTestId: undefined,
|
||||||
|
triggerAriaLabel: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
previous: [];
|
||||||
|
next: [];
|
||||||
|
select: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const triggerClass =
|
||||||
|
'h-8 w-[13.5rem] justify-center rounded-none border-l border-r border-border-secondary px-2.5 text-text-primary tabular-nums hover:bg-card-background-active';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="tabular-nums inline-flex items-stretch overflow-hidden rounded-md border border-input bg-card-background shadow-xs dark:bg-transparent"
|
||||||
|
role="group"
|
||||||
|
aria-label="Date range navigation">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
|
||||||
|
aria-label="Previous"
|
||||||
|
:disabled="previousDisabled"
|
||||||
|
:data-testid="previousTestId"
|
||||||
|
@click="emit('previous')">
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<slot name="trigger" :trigger-class="triggerClass">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:class="triggerClass"
|
||||||
|
:aria-label="triggerAriaLabel"
|
||||||
|
:data-testid="triggerTestId"
|
||||||
|
@click="emit('select')">
|
||||||
|
<CalendarIcon class="h-4 w-4 shrink-0 text-icon-default" />
|
||||||
|
<span class="min-w-0 truncate">{{ label }}</span>
|
||||||
|
<span v-if="secondaryLabel" class="shrink-0 text-text-tertiary">
|
||||||
|
· {{ secondaryLabel }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
|
||||||
|
aria-label="Next"
|
||||||
|
:disabled="nextDisabled"
|
||||||
|
:data-testid="nextTestId"
|
||||||
|
@click="emit('next')">
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -6,22 +6,22 @@ import { CalendarDate } from '@internationalized/date';
|
|||||||
import { CalendarIcon } from '@lucide/vue';
|
import { CalendarIcon } from '@lucide/vue';
|
||||||
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import {
|
import DateRangeNavigator from './DateRangeNavigator.vue';
|
||||||
getDayJsInstance,
|
import { getLocalizedDayJs, firstDayIndex, type WeekStartDay } from '@/packages/ui/src/utils/time';
|
||||||
getLocalizedDayJs,
|
import { getDateRangeLabel, shiftDateRange, type DateRangeDirection } from '../utils/dateRange';
|
||||||
firstDayIndex,
|
|
||||||
type WeekStartDay,
|
|
||||||
} from '@/packages/ui/src/utils/time';
|
|
||||||
import { type Organization } from '@/packages/api/src';
|
import { type Organization } from '@/packages/api/src';
|
||||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||||
import { formatDate } from '@/packages/ui/src/utils/time';
|
|
||||||
|
|
||||||
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
|
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
start: string;
|
defineProps<{
|
||||||
end: string;
|
start: string;
|
||||||
}>();
|
end: string;
|
||||||
|
allowFuture?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ allowFuture: false }
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:start', value: string): void;
|
(e: 'update:start', value: string): void;
|
||||||
@@ -35,10 +35,13 @@ interface CalendarDateRange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const today = computed(() => {
|
const today = computed(() => {
|
||||||
const now = getDayJsInstance()();
|
const now = getLocalizedDayJs();
|
||||||
return new CalendarDate(now.year(), now.month() + 1, now.date());
|
return new CalendarDate(now.year(), now.month() + 1, now.date());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const startDay = computed(() => (props.start ? getLocalizedDayJs(props.start) : undefined));
|
||||||
|
const endDay = computed(() => (props.end ? getLocalizedDayJs(props.end) : undefined));
|
||||||
|
|
||||||
const modelValue = computed<CalendarDateRange>({
|
const modelValue = computed<CalendarDateRange>({
|
||||||
get: () => ({
|
get: () => ({
|
||||||
start: props.start
|
start: props.start
|
||||||
@@ -91,7 +94,7 @@ function setLastWeek() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setLast14Days() {
|
function setLast14Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());
|
emit('update:start', getLocalizedDayJs().subtract(13, 'days').startOf('day').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getLocalizedDayJs().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
@@ -109,14 +112,14 @@ function setLastMonth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setLast30Days() {
|
function setLast30Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());
|
emit('update:start', getLocalizedDayJs().subtract(29, 'days').startOf('day').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getLocalizedDayJs().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLast90Days() {
|
function setLast90Days() {
|
||||||
emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
|
emit('update:start', getLocalizedDayJs().subtract(89, 'days').startOf('day').format());
|
||||||
emit('update:end', getDayJsInstance()().format());
|
emit('update:end', getLocalizedDayJs().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +143,38 @@ function setLastYear() {
|
|||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
|
const displayLabel = computed(() => {
|
||||||
|
if (!startDay.value) return 'Pick a date';
|
||||||
|
if (!endDay.value) {
|
||||||
|
return getDateRangeLabel(startDay.value, startDay.value, {
|
||||||
|
dateFormat: organization?.value?.date_format,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return getDateRangeLabel(startDay.value, endDay.value, {
|
||||||
|
dateFormat: organization?.value?.date_format,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextRange = computed(() => {
|
||||||
|
if (!startDay.value || !endDay.value) return undefined;
|
||||||
|
return shiftDateRange(startDay.value, endDay.value, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextDisabled = computed(
|
||||||
|
() =>
|
||||||
|
!props.allowFuture &&
|
||||||
|
!!nextRange.value &&
|
||||||
|
nextRange.value.start.isAfter(getLocalizedDayJs().endOf('day'))
|
||||||
|
);
|
||||||
|
|
||||||
|
function navigate(direction: DateRangeDirection) {
|
||||||
|
if (!startDay.value || !endDay.value) return;
|
||||||
|
const shifted = shiftDateRange(startDay.value, endDay.value, direction);
|
||||||
|
emit('update:start', shifted.start.format());
|
||||||
|
emit('update:end', shifted.end.format());
|
||||||
|
emit('submit');
|
||||||
|
}
|
||||||
|
|
||||||
watch(open, (value) => {
|
watch(open, (value) => {
|
||||||
if (value === false) {
|
if (value === false) {
|
||||||
emit('submit');
|
emit('submit');
|
||||||
@@ -148,77 +183,120 @@ watch(open, (value) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Popover v-model:open="open">
|
<DateRangeNavigator
|
||||||
<PopoverTrigger as-child>
|
:label="displayLabel"
|
||||||
<Button
|
:next-disabled="nextDisabled"
|
||||||
variant="outline"
|
previous-test-id="date_range_picker_previous"
|
||||||
:class="
|
trigger-test-id="date_range_picker_display"
|
||||||
twMerge(
|
next-test-id="date_range_picker_next"
|
||||||
'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
|
@previous="navigate(-1)"
|
||||||
!modelValue && 'text-muted-foreground'
|
@next="navigate(1)">
|
||||||
)
|
<template #trigger="{ triggerClass }">
|
||||||
">
|
<Popover v-model:open="open">
|
||||||
<CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
|
<PopoverTrigger as-child>
|
||||||
<template v-if="modelValue.start">
|
<Button
|
||||||
<template v-if="modelValue.end">
|
type="button"
|
||||||
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
|
variant="ghost"
|
||||||
-
|
size="sm"
|
||||||
{{ formatDate(modelValue.end.toString(), organization?.date_format) }}
|
:class="twMerge(triggerClass, !modelValue.start && 'text-muted-foreground')"
|
||||||
</template>
|
data-testid="date_range_picker_display">
|
||||||
<template v-else>
|
<CalendarIcon class="text-text-quaternary h-4 w-4 shrink-0" />
|
||||||
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
|
<span class="min-w-0 truncate">{{ displayLabel }}</span>
|
||||||
</template>
|
</Button>
|
||||||
</template>
|
</PopoverTrigger>
|
||||||
<template v-else> Pick a date </template>
|
<PopoverContent class="w-auto p-0">
|
||||||
</Button>
|
<div class="flex divide-x divide-border-secondary">
|
||||||
</PopoverTrigger>
|
<div
|
||||||
<PopoverContent class="w-auto p-0">
|
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
|
||||||
<div class="flex divide-x divide-border-secondary">
|
<Button
|
||||||
<div
|
variant="ghost"
|
||||||
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
|
size="sm"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setToday"
|
class="justify-start"
|
||||||
>Today</Button
|
@click="setToday"
|
||||||
>
|
>Today</Button
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
|
>
|
||||||
>This Week</Button
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
|
size="sm"
|
||||||
>Last Week</Button
|
class="justify-start"
|
||||||
>
|
@click="setThisWeek"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
|
>This Week</Button
|
||||||
>Last 14 days</Button
|
>
|
||||||
>
|
<Button
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
|
variant="ghost"
|
||||||
>This Month</Button
|
size="sm"
|
||||||
>
|
class="justify-start"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
|
@click="setLastWeek"
|
||||||
>Last Month</Button
|
>Last Week</Button
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
|
<Button
|
||||||
>Last 30 days</Button
|
variant="ghost"
|
||||||
>
|
size="sm"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days"
|
class="justify-start"
|
||||||
>Last 90 days</Button
|
@click="setLast14Days"
|
||||||
>
|
>Last 14 Days</Button
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months"
|
>
|
||||||
>Last 12 months</Button
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear"
|
size="sm"
|
||||||
>This year</Button
|
class="justify-start"
|
||||||
>
|
@click="setThisMonth"
|
||||||
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear"
|
>This Month</Button
|
||||||
>Last year</Button
|
>
|
||||||
>
|
<Button
|
||||||
</div>
|
variant="ghost"
|
||||||
<div class="pl-2">
|
size="sm"
|
||||||
<RangeCalendar
|
class="justify-start"
|
||||||
v-model="modelValue"
|
@click="setLastMonth"
|
||||||
initial-focus
|
>Last Month</Button
|
||||||
:number-of-months="2"
|
>
|
||||||
:max-value="today"
|
<Button
|
||||||
:week-starts-on="weekStartsOn" />
|
variant="ghost"
|
||||||
</div>
|
size="sm"
|
||||||
</div>
|
class="justify-start"
|
||||||
</PopoverContent>
|
@click="setLast30Days"
|
||||||
</Popover>
|
>Last 30 Days</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="justify-start"
|
||||||
|
@click="setLast90Days"
|
||||||
|
>Last 90 Days</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="justify-start"
|
||||||
|
@click="setLast12Months"
|
||||||
|
>Last 12 Months</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="justify-start"
|
||||||
|
@click="setThisYear"
|
||||||
|
>This Year</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="justify-start"
|
||||||
|
@click="setLastYear"
|
||||||
|
>Last Year</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2">
|
||||||
|
<RangeCalendar
|
||||||
|
v-model="modelValue"
|
||||||
|
initial-focus
|
||||||
|
:number-of-months="2"
|
||||||
|
:max-value="allowFuture ? undefined : today"
|
||||||
|
:week-starts-on="weekStartsOn" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
</DateRangeNavigator>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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 });
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -131,6 +132,7 @@ import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
|||||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
||||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
||||||
import DateRangePicker from './Input/DateRangePicker.vue';
|
import DateRangePicker from './Input/DateRangePicker.vue';
|
||||||
|
import DateRangeNavigator from './Input/DateRangeNavigator.vue';
|
||||||
import { Label } from './label/index';
|
import { Label } from './label/index';
|
||||||
import {
|
import {
|
||||||
NumberField,
|
NumberField,
|
||||||
@@ -210,6 +212,7 @@ export {
|
|||||||
ContextMenuSubTrigger,
|
ContextMenuSubTrigger,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
DateRangePicker,
|
DateRangePicker,
|
||||||
|
DateRangeNavigator,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -257,6 +260,7 @@ export {
|
|||||||
NumberFieldDecrement,
|
NumberFieldDecrement,
|
||||||
NumberFieldIncrement,
|
NumberFieldIncrement,
|
||||||
NumberFieldInput,
|
NumberFieldInput,
|
||||||
|
Pagination,
|
||||||
Popover,
|
Popover,
|
||||||
PopoverAnchor,
|
PopoverAnchor,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
|
|||||||
124
resources/js/packages/ui/src/utils/dateRange.test.ts
Normal file
124
resources/js/packages/ui/src/utils/dateRange.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { getDayJsInstance, getLocalizedDayJs } from './time';
|
||||||
|
import { getDateRangeLabel, shiftDateRange } from './dateRange';
|
||||||
|
|
||||||
|
const reference = dayjs('2026-09-21');
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.mocked(window.getTimezoneSetting).mockReturnValue('UTC');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDateRangeLabel', () => {
|
||||||
|
test('labels relative weeks', () => {
|
||||||
|
getDayJsInstance();
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(reference.startOf('week'), reference.endOf('week'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('This Week');
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(
|
||||||
|
reference.subtract(1, 'week').startOf('week'),
|
||||||
|
reference.subtract(1, 'week').endOf('week'),
|
||||||
|
{ referenceDate: reference }
|
||||||
|
)
|
||||||
|
).toBe('Last Week');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('labels common rolling ranges', () => {
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(reference.subtract(13, 'day'), reference.endOf('day'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('Last 14 Days');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('labels full calendar months and years', () => {
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(dayjs('2025-09-01'), dayjs('2025-09-30'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('September 2025');
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(dayjs('2024-01-01'), dayjs('2024-12-31'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps relative labels for the current periods', () => {
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(reference.startOf('month'), reference.endOf('month'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('This Month');
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(reference.startOf('year'), reference.endOf('year'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('This Year');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the organization date format for custom ranges', () => {
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-12'), {
|
||||||
|
dateFormat: 'slash-separated-dd-mm-yyyy',
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('03/08/2026 – 12/08/2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders a single historical day without duplicating it', () => {
|
||||||
|
expect(
|
||||||
|
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-03'), {
|
||||||
|
referenceDate: reference,
|
||||||
|
})
|
||||||
|
).toBe('Aug 3, 2026');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shiftDateRange', () => {
|
||||||
|
test('moves full weeks as calendar periods', () => {
|
||||||
|
getDayJsInstance();
|
||||||
|
const shifted = shiftDateRange(reference.startOf('week'), reference.endOf('week'), -1);
|
||||||
|
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-09-14');
|
||||||
|
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-09-20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moves full months without truncating longer months', () => {
|
||||||
|
const shifted = shiftDateRange(dayjs('2026-02-01'), dayjs('2026-02-28'), 1);
|
||||||
|
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-03-01');
|
||||||
|
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-03-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moves custom ranges by their inclusive duration', () => {
|
||||||
|
const shifted = shiftDateRange(dayjs('2026-09-01'), dayjs('2026-09-14'), -1);
|
||||||
|
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-08-18');
|
||||||
|
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-08-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes custom range offsets across the spring DST transition', () => {
|
||||||
|
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
|
||||||
|
const shifted = shiftDateRange(
|
||||||
|
getLocalizedDayJs('2026-03-14T23:00:00Z'),
|
||||||
|
getLocalizedDayJs('2026-03-27T23:00:00Z'),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shifted.start.format()).toBe('2026-03-29T00:00:00+01:00');
|
||||||
|
expect(shifted.end.format()).toBe('2026-04-11T23:59:59+02:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes custom range offsets across the autumn DST transition', () => {
|
||||||
|
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
|
||||||
|
const shifted = shiftDateRange(
|
||||||
|
getLocalizedDayJs('2026-10-17T22:00:00Z'),
|
||||||
|
getLocalizedDayJs('2026-10-30T23:00:00Z'),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shifted.start.format()).toBe('2026-11-01T00:00:00+01:00');
|
||||||
|
expect(shifted.end.format()).toBe('2026-11-14T23:59:59+01:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
135
resources/js/packages/ui/src/utils/dateRange.ts
Normal file
135
resources/js/packages/ui/src/utils/dateRange.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import { formatDate, getDayJsInstance, getLocalizedDayJs, type DateFormat } from './time';
|
||||||
|
|
||||||
|
export type DateRangeDirection = -1 | 1;
|
||||||
|
|
||||||
|
function isSameDay(left: Dayjs, right: Dayjs): boolean {
|
||||||
|
return left.isSame(right, 'day');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRange(start: Dayjs, end: Dayjs, expectedStart: Dayjs, expectedEnd: Dayjs): boolean {
|
||||||
|
return isSameDay(start, expectedStart) && isSameDay(end, expectedEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactRange(start: Dayjs, end: Dayjs): string {
|
||||||
|
if (start.year() !== end.year()) {
|
||||||
|
return `${start.format('MMM D, YYYY')} – ${end.format('MMM D, YYYY')}`;
|
||||||
|
}
|
||||||
|
if (start.month() !== end.month()) {
|
||||||
|
return `${start.format('MMM D')} – ${end.format('MMM D, YYYY')}`;
|
||||||
|
}
|
||||||
|
return `${start.format('MMM D')} – ${end.format('D, YYYY')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-friendly label for an inclusive date range. Relative labels
|
||||||
|
* are intentionally shared by reporting, the timesheet, and the calendar.
|
||||||
|
*/
|
||||||
|
export function getDateRangeLabel(
|
||||||
|
start: Dayjs,
|
||||||
|
end: Dayjs,
|
||||||
|
options: { dateFormat?: DateFormat; referenceDate?: Dayjs } = {}
|
||||||
|
): string {
|
||||||
|
// Ensure the configured first day of the week is applied before using startOf('week').
|
||||||
|
getDayJsInstance();
|
||||||
|
|
||||||
|
const reference = (options.referenceDate ?? getLocalizedDayJs()).startOf('day');
|
||||||
|
const rangeStart = start.startOf('day');
|
||||||
|
const rangeEnd = end.startOf('day');
|
||||||
|
|
||||||
|
const relativeDays: Array<[number, string]> = [
|
||||||
|
[0, 'Today'],
|
||||||
|
[-1, 'Yesterday'],
|
||||||
|
[1, 'Tomorrow'],
|
||||||
|
];
|
||||||
|
for (const [offset, label] of relativeDays) {
|
||||||
|
const day = reference.add(offset, 'day');
|
||||||
|
if (isRange(rangeStart, rangeEnd, day, day)) return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePeriods: Array<['week' | 'month' | 'year', number, string]> = [
|
||||||
|
['week', 0, 'This Week'],
|
||||||
|
['week', -1, 'Last Week'],
|
||||||
|
['week', 1, 'Next Week'],
|
||||||
|
['month', 0, 'This Month'],
|
||||||
|
['month', -1, 'Last Month'],
|
||||||
|
['month', 1, 'Next Month'],
|
||||||
|
['year', 0, 'This Year'],
|
||||||
|
['year', -1, 'Last Year'],
|
||||||
|
['year', 1, 'Next Year'],
|
||||||
|
];
|
||||||
|
for (const [unit, offset, label] of relativePeriods) {
|
||||||
|
const period = reference.add(offset, unit);
|
||||||
|
if (isRange(rangeStart, rangeEnd, period.startOf(unit), period.endOf(unit))) return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named calendar periods outside the relative windows above, e.g. "May 2025"
|
||||||
|
// for a full month and "2025" for a full year.
|
||||||
|
const monthStart = rangeStart.startOf('month');
|
||||||
|
if (isRange(rangeStart, rangeEnd, monthStart, monthStart.endOf('month'))) {
|
||||||
|
return rangeStart.format('MMMM YYYY');
|
||||||
|
}
|
||||||
|
const yearStart = rangeStart.startOf('year');
|
||||||
|
if (isRange(rangeStart, rangeEnd, yearStart, yearStart.endOf('year'))) {
|
||||||
|
return rangeStart.format('YYYY');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollingRanges: Array<[number, string]> = [
|
||||||
|
[14, 'Last 14 Days'],
|
||||||
|
[30, 'Last 30 Days'],
|
||||||
|
[90, 'Last 90 Days'],
|
||||||
|
];
|
||||||
|
for (const [days, label] of rollingRanges) {
|
||||||
|
if (isRange(rangeStart, rangeEnd, reference.subtract(days - 1, 'day'), reference)) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isRange(rangeStart, rangeEnd, reference.subtract(12, 'month'), reference)) {
|
||||||
|
return 'Last 12 Months';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSameDay(rangeStart, rangeEnd)) {
|
||||||
|
return options.dateFormat
|
||||||
|
? formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)
|
||||||
|
: rangeStart.format('MMM D, YYYY');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.dateFormat) {
|
||||||
|
return `${formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)} – ${formatDate(
|
||||||
|
rangeEnd.format('YYYY-MM-DD'),
|
||||||
|
options.dateFormat
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
return formatCompactRange(rangeStart, rangeEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shift an inclusive range by one period, preserving full calendar periods. */
|
||||||
|
export function shiftDateRange(
|
||||||
|
start: Dayjs,
|
||||||
|
end: Dayjs,
|
||||||
|
direction: DateRangeDirection
|
||||||
|
): { start: Dayjs; end: Dayjs } {
|
||||||
|
getDayJsInstance();
|
||||||
|
|
||||||
|
const rangeStart = start.startOf('day');
|
||||||
|
const rangeEnd = end.endOf('day');
|
||||||
|
|
||||||
|
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('year'), rangeStart.endOf('year'))) {
|
||||||
|
const shiftedStart = rangeStart.add(direction, 'year').startOf('year');
|
||||||
|
return { start: shiftedStart, end: shiftedStart.endOf('year') };
|
||||||
|
}
|
||||||
|
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('month'), rangeStart.endOf('month'))) {
|
||||||
|
const shiftedStart = rangeStart.add(direction, 'month').startOf('month');
|
||||||
|
return { start: shiftedStart, end: shiftedStart.endOf('month') };
|
||||||
|
}
|
||||||
|
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('week'), rangeStart.endOf('week'))) {
|
||||||
|
const shiftedStart = rangeStart.add(direction, 'week').startOf('week');
|
||||||
|
return { start: shiftedStart, end: shiftedStart.endOf('week') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = rangeEnd.startOf('day').diff(rangeStart, 'day') + 1;
|
||||||
|
return {
|
||||||
|
start: rangeStart.add(days * direction, 'day').startOf('day'),
|
||||||
|
end: rangeEnd.add(days * direction, 'day').endOf('day'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useDialogFocusRestore } from './useDialogFocusRestore';
|
||||||
|
|
||||||
|
function closeEvent() {
|
||||||
|
return new CustomEvent('focusScope.autoFocusOnUnmount', { cancelable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useDialogFocusRestore', () => {
|
||||||
|
it('restores focus to the element focused when the dialog opened', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const button = document.createElement('button');
|
||||||
|
document.body.appendChild(button);
|
||||||
|
button.focus();
|
||||||
|
|
||||||
|
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||||
|
onOpenAutoFocus();
|
||||||
|
button.blur();
|
||||||
|
|
||||||
|
const event = closeEvent();
|
||||||
|
onCloseAutoFocus(event);
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(document.activeElement).toBe(button);
|
||||||
|
|
||||||
|
button.remove();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses nothing when the dialog was opened with nothing focused', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const stale = document.createElement('button');
|
||||||
|
document.body.appendChild(stale);
|
||||||
|
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||||
|
|
||||||
|
// First open from the button, then close
|
||||||
|
stale.focus();
|
||||||
|
onOpenAutoFocus();
|
||||||
|
onCloseAutoFocus(closeEvent());
|
||||||
|
vi.runAllTimers();
|
||||||
|
stale.blur();
|
||||||
|
|
||||||
|
// Second open from the body must not refocus the stale button
|
||||||
|
onOpenAutoFocus();
|
||||||
|
const event = closeEvent();
|
||||||
|
onCloseAutoFocus(event);
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
|
||||||
|
stale.remove();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not restore focus to an element that was removed', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const button = document.createElement('button');
|
||||||
|
document.body.appendChild(button);
|
||||||
|
button.focus();
|
||||||
|
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||||
|
onOpenAutoFocus();
|
||||||
|
button.remove();
|
||||||
|
|
||||||
|
onCloseAutoFocus(closeEvent());
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves control to a consumer that already prevented the event', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const button = document.createElement('button');
|
||||||
|
document.body.appendChild(button);
|
||||||
|
button.focus();
|
||||||
|
const { onOpenAutoFocus, onCloseAutoFocus } = useDialogFocusRestore();
|
||||||
|
onOpenAutoFocus();
|
||||||
|
button.blur();
|
||||||
|
|
||||||
|
const event = closeEvent();
|
||||||
|
event.preventDefault();
|
||||||
|
onCloseAutoFocus(event);
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
|
||||||
|
button.remove();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
41
resources/js/packages/ui/src/utils/useDialogFocusRestore.ts
Normal file
41
resources/js/packages/ui/src/utils/useDialogFocusRestore.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Restores focus to the element that was focused when a dialog opened.
|
||||||
|
*
|
||||||
|
* reka-ui remembers the active element at content mount as the dialog's
|
||||||
|
* "trigger" (only when it is not the body) and refocuses it on every close,
|
||||||
|
* but it never clears that value. A dialog opened while nothing is focused
|
||||||
|
* (e.g. the command palette via Cmd+K from the body) therefore refocuses
|
||||||
|
* whatever triggered a *previous* open. Bind these handlers to
|
||||||
|
* `DialogContent`'s `open-auto-focus` / `close-auto-focus` events to restore
|
||||||
|
* exactly the previously focused element, or nothing.
|
||||||
|
*
|
||||||
|
* A consumer handler that already called `preventDefault()` on
|
||||||
|
* `close-auto-focus` keeps control; this composable then does nothing.
|
||||||
|
*/
|
||||||
|
export function useDialogFocusRestore() {
|
||||||
|
let previouslyFocused: HTMLElement | null = null;
|
||||||
|
|
||||||
|
function onOpenAutoFocus() {
|
||||||
|
const active = document.activeElement;
|
||||||
|
previouslyFocused =
|
||||||
|
active instanceof HTMLElement && active !== document.body ? active : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloseAutoFocus(event: Event) {
|
||||||
|
const target = previouslyFocused;
|
||||||
|
previouslyFocused = null;
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Prevents both FocusScope's default restore and reka-ui's trigger refocus
|
||||||
|
event.preventDefault();
|
||||||
|
// Same tick reka-ui uses, so the dialog content is fully gone first
|
||||||
|
setTimeout(() => {
|
||||||
|
if (target?.isConnected) {
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { onOpenAutoFocus, onCloseAutoFocus };
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import type { TimeEntry } from '@/packages/api/src';
|
import type { TimeEntry } from '@/packages/api/src';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
@@ -57,7 +57,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
const currentTimeEntry = ref<TimeEntry>({ ...emptyTimeEntry });
|
const currentTimeEntry = ref<TimeEntry>({ ...emptyTimeEntry });
|
||||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
|
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
|
||||||
deep: true,
|
deep: true,
|
||||||
});
|
});
|
||||||
@@ -89,23 +88,12 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
try {
|
try {
|
||||||
const timeEntriesResponse = await api.getMyActiveTimeEntry({});
|
const timeEntriesResponse = await api.getMyActiveTimeEntry({});
|
||||||
if (timeEntriesResponse?.data) {
|
if (timeEntriesResponse?.data) {
|
||||||
if (timeEntriesResponse.data) {
|
currentTimeEntry.value = timeEntriesResponse.data;
|
||||||
currentTimeEntry.value = timeEntriesResponse.data;
|
} else if (currentTimeEntry.value.id !== '') {
|
||||||
if (
|
// No active time entry on server
|
||||||
currentTimeEntry.value.start !== '' &&
|
// Only reset if we had a previously started timer (has an ID)
|
||||||
currentTimeEntry.value.end === null
|
// Don't reset if user is preparing a new time entry (no ID yet)
|
||||||
) {
|
currentTimeEntry.value = { ...emptyTimeEntry };
|
||||||
startLiveTimer();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No active time entry on server
|
|
||||||
// Only reset if we had a previously started timer (has an ID)
|
|
||||||
// Don't reset if user is preparing a new time entry (no ID yet)
|
|
||||||
if (currentTimeEntry.value.id !== '') {
|
|
||||||
currentTimeEntry.value = { ...emptyTimeEntry };
|
|
||||||
stopLiveTimer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// API error (e.g., 404 when no active time entry)
|
// API error (e.g., 404 when no active time entry)
|
||||||
@@ -113,7 +101,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
// Don't reset if user is preparing a new time entry (no ID yet)
|
// Don't reset if user is preparing a new time entry (no ID yet)
|
||||||
if (currentTimeEntry.value.id !== '') {
|
if (currentTimeEntry.value.id !== '') {
|
||||||
currentTimeEntry.value = { ...emptyTimeEntry };
|
currentTimeEntry.value = { ...emptyTimeEntry };
|
||||||
stopLiveTimer();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -294,6 +281,18 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
return isActive.value && currentTimeEntry.value.type === 'break';
|
return isActive.value && currentTimeEntry.value.type === 'break';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
isActive,
|
||||||
|
(active) => {
|
||||||
|
if (active) {
|
||||||
|
startLiveTimer();
|
||||||
|
} else {
|
||||||
|
stopLiveTimer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
async function setActiveState(newState: boolean) {
|
async function setActiveState(newState: boolean) {
|
||||||
if (newState) {
|
if (newState) {
|
||||||
startLiveTimer();
|
startLiveTimer();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class PasswordConfirmationTest extends TestCase
|
class PasswordConfirmationTest extends TestCase
|
||||||
@@ -43,4 +44,43 @@ class PasswordConfirmationTest extends TestCase
|
|||||||
|
|
||||||
$response->assertSessionHasErrors();
|
$response->assertSessionHasErrors();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_password_can_be_confirmed_if_a_placeholder_user_with_the_same_email_exists(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// Placeholders created by an import have no password at all. The placeholder is created
|
||||||
|
// first so that it would be returned by an unordered lookup by email.
|
||||||
|
$email = 'shared@example.com';
|
||||||
|
User::factory()->placeholder()->create(['email' => $email, 'password' => null]);
|
||||||
|
$user = User::factory()->create(['email' => $email, 'password' => Hash::make('secret-password')]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->actingAs($user)->post('/user/confirm-password', [
|
||||||
|
'password' => 'secret-password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertRedirect();
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
$this->assertTrue($this->app['session']->has('auth.password_confirmed_at'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_password_confirmation_ignores_the_password_of_a_placeholder_user_with_the_same_email(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// Placeholders created by removing a member copy the password hash as of the removal,
|
||||||
|
// so the placeholder holds a password that the real user has since replaced.
|
||||||
|
$email = 'shared@example.com';
|
||||||
|
User::factory()->placeholder()->create(['email' => $email, 'password' => Hash::make('outdated-password')]);
|
||||||
|
$user = User::factory()->create(['email' => $email, 'password' => Hash::make('current-password')]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->actingAs($user)->post('/user/confirm-password', [
|
||||||
|
'password' => 'outdated-password',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertSessionHasErrors();
|
||||||
|
$this->assertFalse($this->app['session']->has('auth.password_confirmed_at'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Tests\Feature;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Notifications\ResetPassword;
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -93,4 +94,62 @@ class PasswordResetTest extends TestCase
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_password_reset_targets_the_real_user_when_a_placeholder_user_with_the_same_email_exists(): void
|
||||||
|
{
|
||||||
|
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
// The placeholder is created first so that it would be returned by an unordered lookup by email
|
||||||
|
$email = 'shared@example.com';
|
||||||
|
$placeholder = User::factory()->placeholder()->create(['email' => $email]);
|
||||||
|
$user = User::factory()->create(['email' => $email]);
|
||||||
|
$placeholderPasswordBefore = $placeholder->password;
|
||||||
|
|
||||||
|
$response = $this->post('/forgot-password', [
|
||||||
|
'email' => $email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
Notification::assertNotSentTo($placeholder, ResetPassword::class);
|
||||||
|
Notification::assertSentTo($user, ResetPassword::class, function (ResetPassword $notification) use ($email) {
|
||||||
|
$response = $this->post('/reset-password', [
|
||||||
|
'token' => $notification->token,
|
||||||
|
'email' => $email,
|
||||||
|
'password' => 'new-password-123',
|
||||||
|
'password_confirmation' => 'new-password-123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$placeholder->refresh();
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertSame($placeholderPasswordBefore, $placeholder->password);
|
||||||
|
$this->assertTrue(Hash::check('new-password-123', $user->password));
|
||||||
|
|
||||||
|
$response = $this->post('/login', [
|
||||||
|
'email' => $email,
|
||||||
|
'password' => 'new-password-123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
$this->assertAuthenticatedAs($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_password_reset_link_is_not_sent_if_only_a_placeholder_user_with_the_email_exists(): void
|
||||||
|
{
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$placeholder = User::factory()->placeholder()->create();
|
||||||
|
|
||||||
|
$response = $this->post('/forgot-password', [
|
||||||
|
'email' => $placeholder->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasErrors('email');
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
tests/Unit/Auth/ActiveUserProviderTest.php
Normal file
90
tests/Unit/Auth/ActiveUserProviderTest.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use App\Auth\ActiveUserProvider;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Tests\TestCaseWithDatabase;
|
||||||
|
|
||||||
|
#[CoversClass(ActiveUserProvider::class)]
|
||||||
|
class ActiveUserProviderTest extends TestCaseWithDatabase
|
||||||
|
{
|
||||||
|
public function test_password_broker_uses_the_active_user_provider(): void
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
$brokerProvider = Auth::createUserProvider(config('auth.passwords.users.provider'));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertInstanceOf(ActiveUserProvider::class, $brokerProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_guard_uses_the_active_user_provider(): void
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
$guardProvider = Auth::createUserProvider(config('auth.guards.api.provider'));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertInstanceOf(ActiveUserProvider::class, $guardProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_web_guard_uses_the_active_user_provider(): void
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
$guardProvider = Auth::createUserProvider(config('auth.guards.web.provider'));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertInstanceOf(ActiveUserProvider::class, $guardProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retrieve_by_credentials_ignores_placeholder_users_with_the_same_email(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$email = 'shared@example.com';
|
||||||
|
$placeholder = User::factory()->placeholder()->create(['email' => $email]);
|
||||||
|
$user = User::factory()->create(['email' => $email]);
|
||||||
|
$provider = Auth::createUserProvider('users');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$result = $provider->retrieveByCredentials(['email' => $email]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertInstanceOf(User::class, $result);
|
||||||
|
$this->assertTrue($user->is($result));
|
||||||
|
$this->assertFalse($placeholder->is($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retrieve_by_credentials_returns_null_if_only_a_placeholder_user_exists(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$email = 'placeholder-only@example.com';
|
||||||
|
User::factory()->placeholder()->create(['email' => $email]);
|
||||||
|
$provider = Auth::createUserProvider('users');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$result = $provider->retrieveByCredentials(['email' => $email]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retrieve_by_id_returns_null_for_placeholder_users(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$placeholder = User::factory()->placeholder()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$provider = Auth::createUserProvider('users');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$placeholderResult = $provider->retrieveById($placeholder->getKey());
|
||||||
|
$userResult = $provider->retrieveById($user->getKey());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertNull($placeholderResult);
|
||||||
|
$this->assertInstanceOf(User::class, $userResult);
|
||||||
|
$this->assertTrue($user->is($userResult));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,29 @@ class ImportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_fails_if_data_exceeds_maximum_size(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config(['import.max_data_size' => 16]);
|
||||||
|
$user = $this->createUserWithPermission([
|
||||||
|
'import',
|
||||||
|
]);
|
||||||
|
$this->mock(ImportService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldNotReceive('import');
|
||||||
|
});
|
||||||
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->getKey()]), [
|
||||||
|
'type' => 'toggl_time_entries',
|
||||||
|
'data' => base64_encode(str_repeat('a', 15)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJsonValidationErrors(['data']);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_import_return_error_message_if_import_fails(): void
|
public function test_import_return_error_message_if_import_fails(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
|
|||||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||||
'id' => $invitation->getKey(),
|
'id' => $invitation->getKey(),
|
||||||
]);
|
]);
|
||||||
|
// Joining sets the organization as the current one for the user, independently of the
|
||||||
|
// placeholders that were merged into them
|
||||||
|
$this->assertSame($user->organization->getKey(), $user2->user->fresh()->current_team_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_accepting_invitation_while_logged_out_redirects_to_login(): void
|
public function test_accepting_invitation_while_logged_out_redirects_to_login(): void
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ class UserModelTest extends ModelTestAbstract
|
|||||||
$this->assertTrue($canAccess);
|
$this->assertTrue($canAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_placeholder_user_with_a_super_admin_email_can_not_access_admin_panel(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Config::set('auth.super_admins', ['some@email.test', 'other@email.test']);
|
||||||
|
$user = User::factory()->placeholder()->create([
|
||||||
|
'email' => 'some@email.test',
|
||||||
|
]);
|
||||||
|
$panelProvider = new AdminPanelProvider(app());
|
||||||
|
$mainPanel = $panelProvider->panel(Panel::make());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$canAccess = $user->canAccessPanel($mainPanel);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertFalse($canAccess);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_scope_belongs_to_organization_returns_only_users_of_organization_including_owners(): void
|
public function test_scope_belongs_to_organization_returns_only_users_of_organization_including_owners(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -412,10 +412,11 @@ class DeletionServiceTest extends TestCaseWithDatabase
|
|||||||
$this->assertDatabaseHas(Organization::class, [
|
$this->assertDatabaseHas(Organization::class, [
|
||||||
'id' => $organizationOfA->getKey(),
|
'id' => $organizationOfA->getKey(),
|
||||||
]);
|
]);
|
||||||
// The placeholder user should exist with current_team_id set to the org where they are a placeholder
|
// The placeholder user should exist and must not reference the deleted organization,
|
||||||
|
// which is what caused the foreign key violation in #989
|
||||||
$placeholderUser = User::query()->where('is_placeholder', true)->first();
|
$placeholderUser = User::query()->where('is_placeholder', true)->first();
|
||||||
$this->assertNotNull($placeholderUser);
|
$this->assertNotNull($placeholderUser);
|
||||||
$this->assertSame($organizationOfA->getKey(), $placeholderUser->current_team_id);
|
$this->assertNull($placeholderUser->current_team_id);
|
||||||
$this->assertDatabaseHas(Member::class, [
|
$this->assertDatabaseHas(Member::class, [
|
||||||
'id' => $memberBInOrgA->getKey(),
|
'id' => $memberBInOrgA->getKey(),
|
||||||
'user_id' => $placeholderUser->getKey(),
|
'user_id' => $placeholderUser->getKey(),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class ImportServiceTest extends TestCase
|
|||||||
$this->assertSame(1, $report->usersCreated);
|
$this->assertSame(1, $report->usersCreated);
|
||||||
$this->assertSame(2, $report->projectsCreated);
|
$this->assertSame(2, $report->projectsCreated);
|
||||||
$this->assertSame(1, $report->clientsCreated);
|
$this->assertSame(1, $report->clientsCreated);
|
||||||
|
Storage::disk(config('filesystems.default'))->assertDirectoryEmpty('import');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_releases_lock_if_an_exception_happens_during_the_import(): void
|
public function test_import_releases_lock_if_an_exception_happens_during_the_import(): void
|
||||||
|
|||||||
@@ -42,6 +42,31 @@ class SolidtimeImporterTest extends ImporterTestAbstract
|
|||||||
$this->fail();
|
$this->fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_throws_exception_if_zip_exceeds_uncompressed_size_limit(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config(['import.zip_max_uncompressed_size' => 10]);
|
||||||
|
$zipPath = $this->createTestZip('solidtime_import_test_1');
|
||||||
|
$timezone = 'Europe/Vienna';
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$importer = new SolidtimeImporter;
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = file_get_contents($zipPath);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$importer->importData($data, $timezone);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertInstanceOf(ImportException::class, $e);
|
||||||
|
$this->assertSame('ZIP uncompressed size exceeds the maximum of 10 bytes', $e->getMessage());
|
||||||
|
$this->assertSame(0, $importer->getReport()->timeEntriesCreated);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_import_of_test_file_succeeds(): void
|
public function test_import_of_test_file_succeeds(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ class TogglDataImporterTest extends ImporterTestAbstract
|
|||||||
$this->fail();
|
$this->fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_throws_exception_if_zip_contains_too_many_files(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config(['import.zip_max_files' => 1]);
|
||||||
|
$zipPath = $this->createTestZip('toggl_data_import_test_1');
|
||||||
|
$timezone = 'Europe/Vienna';
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$importer = new TogglDataImporter;
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = file_get_contents($zipPath);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
$importer->importData($data, $timezone);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertInstanceOf(ImportException::class, $e);
|
||||||
|
$this->assertSame('ZIP contains too many files, maximum is 1', $e->getMessage());
|
||||||
|
$this->assertSame(0, $importer->getReport()->projectsCreated);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_import_of_test_file_succeeds(): void
|
public function test_import_of_test_file_succeeds(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
254
tests/Unit/Service/Import/Importers/ZipImportHelperTest.php
Normal file
254
tests/Unit/Service/Import/Importers/ZipImportHelperTest.php
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Service\Import\Importers;
|
||||||
|
|
||||||
|
use App\Service\Import\Importers\ImportException;
|
||||||
|
use App\Service\Import\Importers\ZipImportHelper;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||||
|
use Tests\TestCase;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
#[CoversClass(ZipImportHelper::class)]
|
||||||
|
class ZipImportHelperTest extends TestCase
|
||||||
|
{
|
||||||
|
private TemporaryDirectory $sourceDirectory;
|
||||||
|
|
||||||
|
private TemporaryDirectory $targetDirectory;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->sourceDirectory = TemporaryDirectory::make();
|
||||||
|
$this->targetDirectory = TemporaryDirectory::make();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->sourceDirectory->delete();
|
||||||
|
$this->targetDirectory->delete();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $files
|
||||||
|
*/
|
||||||
|
private function createZip(array $files): string
|
||||||
|
{
|
||||||
|
$zipPath = $this->sourceDirectory->path('test.zip');
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
$zip->open($zipPath, ZipArchive::CREATE);
|
||||||
|
foreach ($files as $name => $content) {
|
||||||
|
$zip->addFromString($name, $content);
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return $zipPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertNothingExtracted(): void
|
||||||
|
{
|
||||||
|
$this->assertSame([], array_values(array_diff(scandir($this->targetDirectory->path()), ['.', '..'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_extracts_files_and_nested_directories(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'meta.json' => '{"version":"1.0"}',
|
||||||
|
'nested/dir/file.csv' => 'a,b',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('{"version":"1.0"}', file_get_contents($this->targetDirectory->path('meta.json')));
|
||||||
|
$this->assertSame('a,b', file_get_contents($this->targetDirectory->path('nested/dir/file.csv')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_file_is_not_a_zip(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$path = $this->sourceDirectory->path('not-a-zip.txt');
|
||||||
|
file_put_contents($path, 'not a zip');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($path, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('Invalid ZIP, error code: 19', $e->getMessage());
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_zip_contains_too_many_files(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config(['import.zip_max_files' => 2]);
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'a.txt' => 'a',
|
||||||
|
'b.txt' => 'b',
|
||||||
|
'c.txt' => 'c',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP contains too many files, maximum is 2', $e->getMessage());
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_before_writing_if_declared_uncompressed_size_exceeds_limit(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config(['import.zip_max_uncompressed_size' => 100]);
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'a.txt' => str_repeat('a', 60),
|
||||||
|
'b.txt' => str_repeat('b', 60),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP uncompressed size exceeds the maximum of 100 bytes', $e->getMessage());
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_actual_uncompressed_size_exceeds_limit_despite_forged_headers(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
config(['import.zip_max_uncompressed_size' => 1000]);
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'bomb.bin' => str_repeat("\0", 100000),
|
||||||
|
]);
|
||||||
|
// Forge the uncompressed size in the local file header (offset 22) and central directory header (offset 24)
|
||||||
|
$content = file_get_contents($zipPath);
|
||||||
|
$forgedSize = pack('V', 10);
|
||||||
|
$localHeaderOffset = strpos($content, "PK\x03\x04");
|
||||||
|
$centralHeaderOffset = strpos($content, "PK\x01\x02");
|
||||||
|
$this->assertNotFalse($localHeaderOffset);
|
||||||
|
$this->assertNotFalse($centralHeaderOffset);
|
||||||
|
$content = substr_replace($content, $forgedSize, $localHeaderOffset + 22, 4);
|
||||||
|
$content = substr_replace($content, $forgedSize, $centralHeaderOffset + 24, 4);
|
||||||
|
file_put_contents($zipPath, $content);
|
||||||
|
$zip = new ZipArchive;
|
||||||
|
$this->assertTrue($zip->open($zipPath, ZipArchive::RDONLY));
|
||||||
|
$this->assertSame(10, $zip->statIndex(0)['size']);
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP uncompressed size exceeds the maximum of 1000 bytes', $e->getMessage());
|
||||||
|
$extracted = $this->targetDirectory->path('bomb.bin');
|
||||||
|
if (file_exists($extracted)) {
|
||||||
|
$this->assertLessThanOrEqual(1000, filesize($extracted));
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_zip_contains_path_traversal(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'../evil.txt' => 'evil',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP contains an invalid file path: "../evil.txt"', $e->getMessage());
|
||||||
|
$this->assertFileDoesNotExist(dirname($this->targetDirectory->path()).'/evil.txt');
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_zip_contains_nested_path_traversal(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'sub/../../evil.txt' => 'evil',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP contains an invalid file path: "sub/../../evil.txt"', $e->getMessage());
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_zip_contains_absolute_path(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'/tmp/evil.txt' => 'evil',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP contains an invalid file path: "/tmp/evil.txt"', $e->getMessage());
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extract_throws_exception_if_zip_contains_backslash_path(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$zipPath = $this->createZip([
|
||||||
|
'..\\evil.txt' => 'evil',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try {
|
||||||
|
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
|
||||||
|
} catch (ImportException $e) {
|
||||||
|
// Assert
|
||||||
|
$this->assertSame('ZIP contains an invalid file path: "..\\evil.txt"', $e->getMessage());
|
||||||
|
$this->assertNothingExtracted();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Models\TimeEntry;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\MemberService;
|
use App\Service\MemberService;
|
||||||
use App\Service\UserService;
|
use App\Service\UserService;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use Tests\TestCaseWithDatabase;
|
use Tests\TestCaseWithDatabase;
|
||||||
@@ -64,6 +65,48 @@ class MemberServiceTest extends TestCaseWithDatabase
|
|||||||
$this->assertSame(Role::Admin->value, $oldOwnerMember->refresh()->role);
|
$this->assertSame(Role::Admin->value, $oldOwnerMember->refresh()->role);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_make_member_to_placeholder_does_not_copy_the_credentials_and_account_state_of_the_user(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'password' => Hash::make('secret-password'),
|
||||||
|
'remember_token' => 'remember-me-token',
|
||||||
|
'two_factor_secret' => 'two-factor-secret',
|
||||||
|
'two_factor_recovery_codes' => 'two-factor-recovery-codes',
|
||||||
|
'two_factor_confirmed_at' => '2026-09-16 10:00:00',
|
||||||
|
'email_verified_at' => '2026-09-16 09:00:00',
|
||||||
|
'pending_email' => 'pending@example.com',
|
||||||
|
'profile_photo_path' => 'profile-photos/photo.png',
|
||||||
|
]);
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$this->memberService->makeMemberToPlaceholder($member);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$member->refresh();
|
||||||
|
$placeholderUser = $member->user;
|
||||||
|
$this->assertTrue($placeholderUser->is_placeholder);
|
||||||
|
$this->assertSame($user->email, $placeholderUser->email);
|
||||||
|
$this->assertNull($placeholderUser->password);
|
||||||
|
$this->assertNull($placeholderUser->remember_token);
|
||||||
|
$this->assertNull($placeholderUser->two_factor_secret);
|
||||||
|
$this->assertNull($placeholderUser->two_factor_recovery_codes);
|
||||||
|
$this->assertNull($placeholderUser->two_factor_confirmed_at);
|
||||||
|
$this->assertNull($placeholderUser->email_verified_at);
|
||||||
|
$this->assertNull($placeholderUser->pending_email);
|
||||||
|
$this->assertNull($placeholderUser->current_team_id);
|
||||||
|
$this->assertNull($placeholderUser->profile_photo_path);
|
||||||
|
// the user the placeholder was created from keeps their own credentials and state
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertTrue(Hash::check('secret-password', (string) $user->password));
|
||||||
|
$this->assertSame('two-factor-secret', $user->two_factor_secret);
|
||||||
|
$this->assertNotNull($user->email_verified_at);
|
||||||
|
$this->assertSame('pending@example.com', $user->pending_email);
|
||||||
|
$this->assertSame('profile-photos/photo.png', $user->profile_photo_path);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_make_member_to_placeholder_creates_new_user_based_on_member_and_changes_member_to_placeholder(): void
|
public function test_make_member_to_placeholder_creates_new_user_based_on_member_and_changes_member_to_placeholder(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
Reference in New Issue
Block a user