mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
1 Commits
feature/fr
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbc927b6a9 |
@@ -5,8 +5,12 @@ declare(strict_types=1);
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Providers\FortifyServiceProvider;
|
||||||
|
use Illuminate\Auth\Passwords\PasswordBroker;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||||
|
|
||||||
class ResetUserPassword implements ResetsUserPasswords
|
class ResetUserPassword implements ResetsUserPasswords
|
||||||
@@ -20,6 +24,16 @@ class ResetUserPassword implements ResetsUserPasswords
|
|||||||
*/
|
*/
|
||||||
public function reset(User $user, array $input): void
|
public function reset(User $user, array $input): void
|
||||||
{
|
{
|
||||||
|
if (! FortifyServiceProvider::canResetPassword($user, $input)) {
|
||||||
|
/** @var PasswordBroker $broker */
|
||||||
|
$broker = Password::broker(config('fortify.passwords'));
|
||||||
|
$broker->deleteToken($user);
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [__('This password reset link is invalid.')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ class ClientController extends Controller
|
|||||||
|
|
||||||
$clientsQuery = Client::query()
|
$clientsQuery = Client::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc');
|
||||||
->orderBy('id');
|
|
||||||
|
|
||||||
if (! $canViewAllClients) {
|
if (! $canViewAllClients) {
|
||||||
$clientsQuery->visibleByEmployee($user);
|
$clientsQuery->visibleByEmployee($user);
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class InvitationController extends Controller
|
|||||||
|
|
||||||
$invitations = $organization->organizationInvitations()
|
$invitations = $organization->organizationInvitations()
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return InvitationCollection::make($invitations);
|
return InvitationCollection::make($invitations);
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ class MemberController extends Controller
|
|||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->with(['user'])
|
->with(['user'])
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return MemberCollection::make($members);
|
return MemberCollection::make($members);
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$projects = $projectsQuery
|
$projects = $projectsQuery
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class ProjectMemberController extends Controller
|
|||||||
$projectMembers = ProjectMember::query()
|
$projectMembers = ProjectMember::query()
|
||||||
->whereBelongsTo($project, 'project')
|
->whereBelongsTo($project, 'project')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new ProjectMemberCollection($projectMembers);
|
return new ProjectMemberCollection($projectMembers);
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ class ReportController extends Controller
|
|||||||
|
|
||||||
$reports = Report::query()
|
$reports = Report::query()
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class TagController extends Controller
|
|||||||
$tags = Tag::query()
|
$tags = Tag::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new TagCollection($tags);
|
return new TagCollection($tags);
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
$tasks = $query
|
$tasks = $query
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->orderBy('id')
|
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
return new TaskCollection($tasks);
|
return new TaskCollection($tasks);
|
||||||
|
|||||||
@@ -194,8 +194,7 @@ class TimeEntryController extends Controller
|
|||||||
$timeEntriesQuery = TimeEntry::query()
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
->select($select)
|
->select($select)
|
||||||
->orderBy('time_entries.start', 'desc')
|
->orderBy('start', 'desc');
|
||||||
->orderBy('time_entries.id');
|
|
||||||
|
|
||||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||||
$filter->addStartFilter($request->input('start'));
|
$filter->addStartFilter($request->input('start'));
|
||||||
|
|||||||
@@ -145,11 +145,21 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
|
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
public function isSuperAdmin(): bool
|
||||||
{
|
{
|
||||||
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
|
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasLocalPassword(): bool
|
||||||
|
{
|
||||||
|
return is_string($this->password) && $this->password !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAccessPanel(Panel $panel): bool
|
||||||
|
{
|
||||||
|
return $this->isSuperAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
public function isMemberOfOrganization(Organization $organization): bool
|
public function isMemberOfOrganization(Organization $organization): bool
|
||||||
{
|
{
|
||||||
if ($this->relationLoaded('organizations')) {
|
if ($this->relationLoaded('organizations')) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use Illuminate\Session\Middleware\StartSession;
|
|||||||
use Illuminate\Support\Facades\App;
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
use Nwidart\Modules\Facades\Module;
|
use Nwidart\Modules\Facades\Module;
|
||||||
|
use Nwidart\Modules\Laravel\Module as LaravelModule;
|
||||||
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
|
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
|
||||||
|
|
||||||
class AdminPanelProvider extends PanelProvider
|
class AdminPanelProvider extends PanelProvider
|
||||||
@@ -91,22 +92,77 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
$modules = Module::allEnabled();
|
$modules = Module::allEnabled();
|
||||||
|
|
||||||
foreach ($modules as $module) {
|
foreach ($modules as $module) {
|
||||||
|
$moduleNamespace = $this->getModuleAppNamespace($module);
|
||||||
|
|
||||||
$panel->discoverResources(
|
$panel->discoverResources(
|
||||||
in: module_path($module->getName(), 'app/Filament/Resources'),
|
in: module_path($module->getName(), 'app/Filament/Resources'),
|
||||||
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources'
|
for: $moduleNamespace.'\\Filament\\Resources'
|
||||||
);
|
);
|
||||||
|
|
||||||
$panel->discoverPages(
|
$panel->discoverPages(
|
||||||
in: module_path($module->getName(), 'app/Filament/Pages'),
|
in: module_path($module->getName(), 'app/Filament/Pages'),
|
||||||
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages'
|
for: $moduleNamespace.'\\Filament\\Pages'
|
||||||
);
|
);
|
||||||
|
|
||||||
$panel->discoverWidgets(
|
$panel->discoverWidgets(
|
||||||
in: module_path($module->getName(), 'app/Filament/Widgets'),
|
in: module_path($module->getName(), 'app/Filament/Widgets'),
|
||||||
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets'
|
for: $moduleNamespace.'\\Filament\\Widgets'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var array<string, string> Cache of module name => resolved app namespace. */
|
||||||
|
private static array $moduleAppNamespaces = [];
|
||||||
|
|
||||||
|
private function getModuleAppNamespace(LaravelModule $module): string
|
||||||
|
{
|
||||||
|
return self::$moduleAppNamespaces[$module->getName()] ??= $this->resolveModuleAppNamespace($module);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the PHP namespace mapped to a module's app/ directory so the
|
||||||
|
* Filament panel can discover its Resources/Pages/Widgets under the right
|
||||||
|
* namespace.
|
||||||
|
*
|
||||||
|
* Two module layouts currently coexist in this repo:
|
||||||
|
* - laravel-modules v12 (app_folder enabled): a bare namespace maps to
|
||||||
|
* app/ — e.g. "Extensions\SSO\" => app/, so classes are
|
||||||
|
* Extensions\SSO\Filament\... (this is the current convention).
|
||||||
|
* - the older layout: an "...\App" namespace maps to app/ — e.g.
|
||||||
|
* "Extensions\Billing\App\" => app/, so classes are
|
||||||
|
* Extensions\Billing\App\Filament\...
|
||||||
|
*
|
||||||
|
* The package's own namespace derivation assumes the v12 (bare) layout and
|
||||||
|
* would mis-resolve the legacy modules, so we read each module's composer
|
||||||
|
* PSR-4 map and use whichever namespace actually points at app/. The legacy
|
||||||
|
* "...\App" shape is only a fallback for when composer is missing/unreadable.
|
||||||
|
* Once every module adopts the bare layout this collapses to
|
||||||
|
* config('modules.namespace').'\\'.$module->getName().
|
||||||
|
*/
|
||||||
|
private function resolveModuleAppNamespace(LaravelModule $module): string
|
||||||
|
{
|
||||||
|
$fallback = 'Extensions\\'.$module->getName().'\\App';
|
||||||
|
|
||||||
|
$composerPath = module_path($module->getName(), 'composer.json');
|
||||||
|
$psr4 = [];
|
||||||
|
if (is_file($composerPath)) {
|
||||||
|
$composer = json_decode((string) file_get_contents($composerPath), true);
|
||||||
|
$psr4 = is_array($composer) ? ($composer['autoload']['psr-4'] ?? []) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((array) $psr4 as $namespace => $path) {
|
||||||
|
if (is_string($namespace) && $this->normalizeComposerPath($path) === 'app') {
|
||||||
|
return rtrim($namespace, '\\');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeComposerPath(mixed $path): string
|
||||||
|
{
|
||||||
|
return trim(str_replace('\\', '/', (string) $path), '/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,73 @@ use Laravel\Fortify\Fortify;
|
|||||||
|
|
||||||
class FortifyServiceProvider extends ServiceProvider
|
class FortifyServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Dummy bcrypt hash compared against when no user matches the submitted
|
||||||
|
* email. Hash::check is run against it so login takes the same time whether
|
||||||
|
* or not the email exists — otherwise an unknown email would skip the
|
||||||
|
* (deliberately slow) hash and return faster, letting an attacker enumerate
|
||||||
|
* registered accounts by timing the response. The plaintext is irrelevant:
|
||||||
|
* it is only ever checked against attacker-supplied input and never matches.
|
||||||
|
*/
|
||||||
|
private const ABSENT_USER_PASSWORD_HASH = '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorization rules applied AFTER the password is verified. Each rule
|
||||||
|
* receives the authenticated user + request and returns whether the login
|
||||||
|
* may proceed; any rule returning false denies it. This is an extension
|
||||||
|
* point: modules (e.g. SSO enforcement) add a rule to veto a password login
|
||||||
|
* instead of replacing this credential check — which would silently drift
|
||||||
|
* from the host logic the next time it changes.
|
||||||
|
*
|
||||||
|
* @var array<int, \Closure(User, Request): bool>
|
||||||
|
*/
|
||||||
|
protected static array $loginRules = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorization rules applied before a password reset is completed. Rules
|
||||||
|
* receive the user being reset + submitted input and return whether the
|
||||||
|
* local reset flow may set a new password for that account.
|
||||||
|
*
|
||||||
|
* @var array<int, \Closure(User, array<string, mixed>): bool>
|
||||||
|
*/
|
||||||
|
protected static array $passwordResetRules = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an additional rule that gates password login (see $loginRules).
|
||||||
|
*
|
||||||
|
* @param \Closure(User, Request): bool $rule
|
||||||
|
*/
|
||||||
|
public static function authenticateUsingRule(\Closure $rule): void
|
||||||
|
{
|
||||||
|
static::$loginRules[] = $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an additional rule that gates password reset completion.
|
||||||
|
*
|
||||||
|
* @param \Closure(User, array<string, mixed>): bool $rule
|
||||||
|
*/
|
||||||
|
public static function resetPasswordUsingRule(\Closure $rule): void
|
||||||
|
{
|
||||||
|
static::$passwordResetRules[] = $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the given user may complete the local password reset flow.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
*/
|
||||||
|
public static function canResetPassword(User $user, array $input = []): bool
|
||||||
|
{
|
||||||
|
foreach (static::$passwordResetRules as $rule) {
|
||||||
|
if (! $rule($user, $input)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register any application services.
|
* Register any application services.
|
||||||
*/
|
*/
|
||||||
@@ -92,7 +159,23 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
->where('is_placeholder', '=', false)
|
->where('is_placeholder', '=', false)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($user !== null && Hash::check($request->password, $user->password)) {
|
// Always run the hash check — against the real hash, or a dummy when
|
||||||
|
// there is no user — so login timing is identical either way (see
|
||||||
|
// ABSENT_USER_PASSWORD_HASH). Passwordless accounts (SSO-only users
|
||||||
|
// have password = null) fail here, so they cannot password-login.
|
||||||
|
$existingPasswordHash = $user->password ?? self::ABSENT_USER_PASSWORD_HASH;
|
||||||
|
|
||||||
|
$passwordIsValid = Hash::check((string) $request->password, $existingPasswordHash);
|
||||||
|
|
||||||
|
if ($user !== null && $passwordIsValid) {
|
||||||
|
// Credentials are valid; now apply any registered authorization
|
||||||
|
// rules (e.g. SSO enforcement may still block password login).
|
||||||
|
foreach (static::$loginRules as $rule) {
|
||||||
|
if (! $rule($user, $request)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Service\Import\Importers;
|
namespace App\Service\Import\Importers;
|
||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use League\Csv\Exception as CsvException;
|
use League\Csv\Exception as CsvException;
|
||||||
use League\Csv\Reader;
|
use League\Csv\Reader;
|
||||||
@@ -25,7 +24,6 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
$header = $reader->getHeader();
|
$header = $reader->getHeader();
|
||||||
$this->validateHeader($header);
|
$this->validateHeader($header);
|
||||||
$billableRateKey = $this->getBillableRateKey($header);
|
$billableRateKey = $this->getBillableRateKey($header);
|
||||||
$tasksKey = $this->getTasksKey($header);
|
|
||||||
$records = $reader->getRecords();
|
$records = $reader->getRecords();
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
@@ -46,12 +44,11 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
'is_billable' => $record['Billability'] === 'Yes',
|
'is_billable' => $record['Billability'] === 'Yes',
|
||||||
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
|
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
|
||||||
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
|
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
|
||||||
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tasksKey !== null && $record[$tasksKey] !== '') {
|
if ($record['Task'] !== '') {
|
||||||
$tasks = explode(', ', $record[$tasksKey]);
|
$tasks = explode(', ', $record['Task']);
|
||||||
foreach ($tasks as $task) {
|
foreach ($tasks as $task) {
|
||||||
$this->taskImportHelper->getKey([
|
$this->taskImportHelper->getKey([
|
||||||
'name' => $task,
|
'name' => $task,
|
||||||
@@ -84,33 +81,13 @@ class ClockifyProjectsImporter extends DefaultImporter
|
|||||||
'Status',
|
'Status',
|
||||||
'Visibility',
|
'Visibility',
|
||||||
'Billability',
|
'Billability',
|
||||||
|
'Task',
|
||||||
];
|
];
|
||||||
foreach ($requiredFields as $requiredField) {
|
foreach ($requiredFields as $requiredField) {
|
||||||
if (! in_array($requiredField, $header, true)) {
|
if (! in_array($requiredField, $header, true)) {
|
||||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clockify names the tasks column "Task", "Tasks" or "Activities" depending on the export; accept any.
|
|
||||||
if ($this->getTasksKey($header) === null) {
|
|
||||||
throw new ImportException('Invalid CSV header, missing field: Tasks');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clockify names the tasks column differently depending on the export
|
|
||||||
* version: "Task" (older), "Tasks" (newer) or "Activities".
|
|
||||||
*
|
|
||||||
* @param array<string> $header
|
|
||||||
*/
|
|
||||||
private function getTasksKey(array $header): ?string
|
|
||||||
{
|
|
||||||
foreach (['Tasks', 'Task', 'Activities'] as $field) {
|
|
||||||
if (in_array($field, $header, true)) {
|
|
||||||
return $field;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
$reader->setEscape('');
|
$reader->setEscape('');
|
||||||
$header = $reader->getHeader();
|
$header = $reader->getHeader();
|
||||||
$this->validateHeader($header);
|
$this->validateHeader($header);
|
||||||
$taskKey = $this->getTaskKey($header);
|
|
||||||
$records = $reader->getRecords();
|
$records = $reader->getRecords();
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
$userId = $this->userImportHelper->getKey([
|
$userId = $this->userImportHelper->getKey([
|
||||||
@@ -97,9 +96,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$taskId = null;
|
$taskId = null;
|
||||||
if ($taskKey !== null && $record[$taskKey] !== '') {
|
if ($record['Task'] !== '') {
|
||||||
$taskId = $this->taskImportHelper->getKey([
|
$taskId = $this->taskImportHelper->getKey([
|
||||||
'name' => $record[$taskKey],
|
'name' => $record['Task'],
|
||||||
'project_id' => $projectId,
|
'project_id' => $projectId,
|
||||||
'organization_id' => $this->organization->id,
|
'organization_id' => $this->organization->id,
|
||||||
]);
|
]);
|
||||||
@@ -117,12 +116,10 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
throw new ImportException('Time entry description is too long');
|
throw new ImportException('Time entry description is too long');
|
||||||
}
|
}
|
||||||
$timeEntry->description = $record['Description'];
|
$timeEntry->description = $record['Description'];
|
||||||
if (isset($record['Billable'])) {
|
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
throw new ImportException('Invalid billable value');
|
||||||
throw new ImportException('Invalid billable value');
|
|
||||||
}
|
|
||||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
|
||||||
}
|
}
|
||||||
|
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||||
$timeEntry->tags = $this->getTags($record['Tags']);
|
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||||
$timeEntry->is_imported = true;
|
$timeEntry->is_imported = true;
|
||||||
|
|
||||||
@@ -217,10 +214,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
'Project',
|
'Project',
|
||||||
'Client',
|
'Client',
|
||||||
'Description',
|
'Description',
|
||||||
|
'Task',
|
||||||
'User',
|
'User',
|
||||||
'Group',
|
'Group',
|
||||||
'Email',
|
'Email',
|
||||||
'Tags',
|
'Tags',
|
||||||
|
'Billable',
|
||||||
'Start Date',
|
'Start Date',
|
||||||
'Start Time',
|
'Start Time',
|
||||||
'End Date',
|
'End Date',
|
||||||
@@ -231,26 +230,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clockify names the task column "Task" or "Activity" depending on the export; accept either.
|
|
||||||
if ($this->getTaskKey($header) === null) {
|
|
||||||
throw new ImportException('Invalid CSV header, missing field: Task');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clockify names the task column "Task" or "Activity" depending on the export version.
|
|
||||||
*
|
|
||||||
* @param array<string> $header
|
|
||||||
*/
|
|
||||||
private function getTaskKey(array $header): ?string
|
|
||||||
{
|
|
||||||
foreach (['Task', 'Activity'] as $field) {
|
|
||||||
if (in_array($field, $header, true)) {
|
|
||||||
return $field;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\Override]
|
#[\Override]
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($projects as $project) {
|
foreach ($projects as $project) {
|
||||||
$projectExternalId = $this->guardExternalIdentifier($project->id);
|
|
||||||
$clientId = null;
|
$clientId = null;
|
||||||
if ($project->client_id !== null) {
|
if ($project->client_id !== null) {
|
||||||
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
|
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
|
||||||
@@ -147,16 +146,16 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
|
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
|
||||||
], (string) $project->id);
|
], (string) $project->id);
|
||||||
|
|
||||||
if (! file_exists($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'))) {
|
if (! file_exists($temporaryDirectory->path('projects_users/'.$project->id.'.json'))) {
|
||||||
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" missing in ZIP');
|
throw new ImportException('File "projects_users/'.$project->id.'.json" missing in ZIP');
|
||||||
}
|
}
|
||||||
$projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'));
|
$projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$project->id.'.json'));
|
||||||
if ($projectMembersFileContent === false) {
|
if ($projectMembersFileContent === false) {
|
||||||
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" can not be opened');
|
throw new ImportException('File "projects_users/'.$project->id.'.json" can not be opened');
|
||||||
}
|
}
|
||||||
$projectMembers = json_decode($projectMembersFileContent);
|
$projectMembers = json_decode($projectMembersFileContent);
|
||||||
if ($projectMembers === null) {
|
if ($projectMembers === null) {
|
||||||
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" is empty');
|
throw new ImportException('File "projects_users/'.$project->id.'.json" is empty');
|
||||||
}
|
}
|
||||||
foreach ($projectMembers as $projectMember) {
|
foreach ($projectMembers as $projectMember) {
|
||||||
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
|
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
|
||||||
@@ -171,7 +170,6 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
$projectIds = $this->projectImportHelper->getExternalIds();
|
$projectIds = $this->projectImportHelper->getExternalIds();
|
||||||
foreach ($projectIds as $projectIdExternal) {
|
foreach ($projectIds as $projectIdExternal) {
|
||||||
$projectIdExternal = $this->guardExternalIdentifier($projectIdExternal);
|
|
||||||
if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) {
|
if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -211,30 +209,6 @@ class TogglDataImporter extends DefaultImporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure an externally-sourced identifier can be safely used inside a
|
|
||||||
* filesystem path. The identifiers originate from the untrusted uploaded
|
|
||||||
* ZIP, and Spatie's TemporaryDirectory::path() auto-creates any missing
|
|
||||||
* parent directory of the resolved path, so an unfiltered "../" sequence
|
|
||||||
* would escape the import sandbox and create/probe arbitrary paths on the
|
|
||||||
* host (CWE-22). Toggl identifiers are numeric, so restricting them to a
|
|
||||||
* conservative allow-list rejects traversal without affecting real data.
|
|
||||||
*
|
|
||||||
* @throws ImportException
|
|
||||||
*/
|
|
||||||
private function guardExternalIdentifier(mixed $id): string
|
|
||||||
{
|
|
||||||
if (! is_string($id) && ! is_int($id)) {
|
|
||||||
throw new ImportException('Invalid identifier in import data');
|
|
||||||
}
|
|
||||||
$id = (string) $id;
|
|
||||||
if (preg_match('/^[A-Za-z0-9_-]+$/', $id) !== 1) {
|
|
||||||
throw new ImportException('Invalid identifier in import data');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $id;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Override]
|
#[Override]
|
||||||
public function getName(): string
|
public function getName(): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,6 +48,56 @@ class UserService
|
|||||||
}
|
}
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
|
$this->createDefaultOrganizationForUser(
|
||||||
|
$user,
|
||||||
|
$currency,
|
||||||
|
$numberFormat,
|
||||||
|
$currencyFormat,
|
||||||
|
$dateFormat,
|
||||||
|
$intervalFormat,
|
||||||
|
$timeFormat,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a user without a password (e.g. provisioned via SSO). Such users
|
||||||
|
* can only authenticate through a linked identity provider.
|
||||||
|
*/
|
||||||
|
public function createPasswordlessUser(
|
||||||
|
string $name,
|
||||||
|
string $email,
|
||||||
|
string $timezone,
|
||||||
|
Weekday $weekStart,
|
||||||
|
?string $currency,
|
||||||
|
bool $verifyEmail = false
|
||||||
|
): User {
|
||||||
|
$user = new User;
|
||||||
|
$user->name = $name;
|
||||||
|
$user->email = strtolower($email);
|
||||||
|
$user->password = null;
|
||||||
|
$user->timezone = $timezone;
|
||||||
|
$user->week_start = $weekStart;
|
||||||
|
if ($verifyEmail) {
|
||||||
|
$user->email_verified_at = Carbon::now();
|
||||||
|
}
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
$this->createDefaultOrganizationForUser($user, $currency);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDefaultOrganizationForUser(
|
||||||
|
User $user,
|
||||||
|
?string $currency,
|
||||||
|
?NumberFormat $numberFormat = null,
|
||||||
|
?CurrencyFormat $currencyFormat = null,
|
||||||
|
?DateFormat $dateFormat = null,
|
||||||
|
?IntervalFormat $intervalFormat = null,
|
||||||
|
?TimeFormat $timeFormat = null,
|
||||||
|
): void {
|
||||||
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||||
|
|
||||||
if ($organizations->isEmpty()) {
|
if ($organizations->isEmpty()) {
|
||||||
@@ -64,8 +114,6 @@ class UserService
|
|||||||
);
|
);
|
||||||
$this->switchCurrentOrganization($user, $organization);
|
$this->switchCurrentOrganization($user, $organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ test('test that sorting clients by name and status works', async ({ page, ctx })
|
|||||||
|
|
||||||
test('test that sorting clients by project count works', async ({ page, ctx }) => {
|
test('test that sorting clients by project count works', async ({ page, ctx }) => {
|
||||||
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
|
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
|
||||||
await createClientViaApi(ctx, { name: 'NoProjects Client' });
|
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
|
||||||
|
|
||||||
// Create projects for the first client
|
// Create projects for the first client
|
||||||
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
|
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
|
||||||
@@ -374,119 +374,3 @@ test.describe('Employee Clients Restrictions', () => {
|
|||||||
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
// Pagination Tests
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Clients Pagination', () => {
|
|
||||||
test.describe.configure({ timeout: 30000 });
|
|
||||||
|
|
||||||
test('test that client table paginates when there are more than 15 clients', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
// Create 17 clients with zero-padded names so alphabetical sort is predictable.
|
|
||||||
// Page size is 15 → page 1 shows indices 00–14, page 2 shows 15–16.
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const prefix = `PaginationClient ${seed} `;
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 17 }, (_, i) =>
|
|
||||||
createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
await clearClientTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Default sort is name asc; first 15 clients (00–14) on page 1.
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
|
|
||||||
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
|
|
||||||
// Client 15 should be on page 2, not visible on page 1.
|
|
||||||
await expect(page.getByText(prefix + '15')).not.toBeVisible();
|
|
||||||
|
|
||||||
// Exactly 15 data rows mounted on page 1.
|
|
||||||
await expect(page.getByRole('row')).toHaveCount(15);
|
|
||||||
|
|
||||||
// Navigation to page 2.
|
|
||||||
await page.getByRole('button', { name: 'Next Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
|
||||||
// Page 2 contains the remaining 2 clients.
|
|
||||||
await expect(page.getByRole('row')).toHaveCount(2);
|
|
||||||
|
|
||||||
// Back to page 1 via Previous Page.
|
|
||||||
await page.getByRole('button', { name: 'Previous Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '15')).not.toBeVisible();
|
|
||||||
|
|
||||||
// First / Last page jumps.
|
|
||||||
await page.getByRole('button', { name: 'Last Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'First Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '15')).not.toBeVisible();
|
|
||||||
|
|
||||||
// Direct page-number button navigation + selected state.
|
|
||||||
await page.getByRole('button', { name: 'Page 2' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
|
|
||||||
'aria-current',
|
|
||||||
'page'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that client pagination is not shown when there are 15 or fewer clients', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 10 }, (_, i) =>
|
|
||||||
createClientViaApi(ctx, {
|
|
||||||
name: `FewClient ${Math.floor(Math.random() * 100000)} ${i}`,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
await clearClientTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await expect(page.getByTestId('client_table')).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that changing the sort resets client pagination to page 1', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const prefix = `SortPagClient ${seed} `;
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 17 }, (_, i) =>
|
|
||||||
createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToClientsOverview(page);
|
|
||||||
await clearClientTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Go to page 2.
|
|
||||||
await page.getByRole('button', { name: 'Next Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
|
|
||||||
// Sort by name descending.
|
|
||||||
const table = page.getByTestId('client_table');
|
|
||||||
const nameHeader = table.getByText('Name').first();
|
|
||||||
await nameHeader.click();
|
|
||||||
|
|
||||||
// Pagination reset to page 1; desc order → 16, 15 visible, 00 on page 2.
|
|
||||||
await expect(page.getByText(prefix + '16')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -117,43 +117,6 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx })
|
|||||||
await expect(page.getByText(newProjectName)).toBeVisible();
|
await expect(page.getByText(newProjectName)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that the client can be changed in the edit project modal', async ({ page, ctx }) => {
|
|
||||||
const projectName = 'Edit Client Project ' + Math.floor(1 + Math.random() * 100000);
|
|
||||||
const clientName = 'Assigned Client ' + Math.floor(1 + Math.random() * 100000);
|
|
||||||
await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
const client = await createClientViaApi(ctx, { name: clientName });
|
|
||||||
|
|
||||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
|
||||||
await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Open the project's Edit modal.
|
|
||||||
await page.getByRole('row').first().getByRole('button').click();
|
|
||||||
await page.getByRole('menuitem').getByText('Edit').first().click();
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
|
||||||
|
|
||||||
// Open the client dropdown (currently "No Client"), confirm it focuses, and pick the client.
|
|
||||||
await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click();
|
|
||||||
const clientSearch = page.getByPlaceholder('Search for a client...');
|
|
||||||
await expect(clientSearch).toBeFocused();
|
|
||||||
await clientSearch.fill(clientName);
|
|
||||||
await page.getByRole('option', { name: clientName }).click();
|
|
||||||
|
|
||||||
// The trigger updates to the chosen client.
|
|
||||||
await expect(page.getByRole('dialog').getByRole('button', { name: clientName })).toBeVisible();
|
|
||||||
|
|
||||||
// Saving persists the client assignment.
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('button', { name: 'Update Project' }).click(),
|
|
||||||
page.waitForResponse(
|
|
||||||
async (response) =>
|
|
||||||
response.url().includes('/projects/') &&
|
|
||||||
response.request().method() === 'PUT' &&
|
|
||||||
response.status() === 200 &&
|
|
||||||
(await response.json()).data.client_id === client.id
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => {
|
test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => {
|
||||||
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
|
||||||
const newBillableRate = Math.round(Math.random() * 10000);
|
const newBillableRate = Math.round(Math.random() * 10000);
|
||||||
@@ -1091,119 +1054,3 @@ test.describe('Employee Billable Rate Visibility', () => {
|
|||||||
await expect(projectRow).toContainText('200');
|
await expect(projectRow).toContainText('200');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
// Pagination Tests
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Projects Pagination', () => {
|
|
||||||
test.describe.configure({ timeout: 30000 });
|
|
||||||
|
|
||||||
test('test that project table paginates when there are more than 15 projects', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
// Create 17 projects with zero-padded names so alphabetical sort is predictable.
|
|
||||||
// Page size is 15 → page 1 shows indices 00–14, page 2 shows 15–16.
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const prefix = `PaginationProj ${seed} `;
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 17 }, (_, i) =>
|
|
||||||
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await clearProjectTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Default sort is name asc; first 15 projects (00–14) should be on page 1.
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
|
|
||||||
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
|
|
||||||
// Project 15 should be on page 2, not visible on page 1.
|
|
||||||
await expect(page.getByText(prefix + '15')).not.toBeVisible();
|
|
||||||
|
|
||||||
// Exactly 15 data rows should be mounted on page 1.
|
|
||||||
await expect(page.getByRole('row')).toHaveCount(15);
|
|
||||||
|
|
||||||
// Go to page 2.
|
|
||||||
await page.getByRole('button', { name: 'Next Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
|
||||||
// Page 2 contains the remaining 2 projects (15, 16).
|
|
||||||
await expect(page.getByRole('row')).toHaveCount(2);
|
|
||||||
|
|
||||||
// Return to page 1 via Previous Page.
|
|
||||||
await page.getByRole('button', { name: 'Previous Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '15')).not.toBeVisible();
|
|
||||||
|
|
||||||
// Jump to last page then back to first page.
|
|
||||||
await page.getByRole('button', { name: 'Last Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'First Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '15')).not.toBeVisible();
|
|
||||||
|
|
||||||
// Direct page-number button navigation.
|
|
||||||
await page.getByRole('button', { name: 'Page 2' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
// Page 2 button should be marked as selected.
|
|
||||||
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
|
|
||||||
'aria-current',
|
|
||||||
'page'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that project pagination is not shown when there are 15 or fewer projects', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 10 }, (_, i) =>
|
|
||||||
createProjectViaApi(ctx, {
|
|
||||||
name: `FewProj ${Math.floor(Math.random() * 100000)} ${i}`,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await clearProjectTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await expect(page.getByTestId('project_table')).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that changing the sort resets pagination to page 1', async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const prefix = `SortPagProj ${seed} `;
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 17 }, (_, i) =>
|
|
||||||
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await clearProjectTableState(page);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Go to page 2.
|
|
||||||
await page.getByRole('button', { name: 'Next Page' }).click();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
|
|
||||||
// Sort by name descending: header click toggles asc → desc.
|
|
||||||
const nameHeader = page
|
|
||||||
.locator('[data-testid="project_table"] .select-none', { hasText: 'Name' })
|
|
||||||
.first();
|
|
||||||
await nameHeader.click();
|
|
||||||
|
|
||||||
// After sorting, pagination resets to page 1; desc order → 16, 15, ... 02 visible.
|
|
||||||
await expect(page.getByText(prefix + '16')).toBeVisible();
|
|
||||||
await expect(page.getByText(prefix + '15')).toBeVisible();
|
|
||||||
// Index 00 should now be on page 2 (last in desc order).
|
|
||||||
await expect(page.getByText(prefix + '00')).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -717,108 +717,3 @@ test('test that keyboard navigation works in multiselect dropdown', async ({ pag
|
|||||||
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
|
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
// Pagination Tests
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Reporting Detailed Pagination', () => {
|
|
||||||
test('test that detailed reporting paginates when there are more than 15 time entries', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
// The detailed report paginates server-side with a page limit of 15.
|
|
||||||
// Create 17 time entries on a single project so we get exactly 2 pages.
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `ReportPagProj ${seed}`;
|
|
||||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
const descriptions = Array.from(
|
|
||||||
{ length: 17 },
|
|
||||||
(_, i) => `ReportPagEntry ${String(i).padStart(2, '0')} ${seed}`
|
|
||||||
);
|
|
||||||
await Promise.all(
|
|
||||||
descriptions.map((description) =>
|
|
||||||
createTimeEntryViaApi(ctx, {
|
|
||||||
description,
|
|
||||||
duration: '30min',
|
|
||||||
projectId: project.id,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToReportingDetailed(page);
|
|
||||||
await expect(page.getByText(descriptions[0]!).first()).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination nav should be rendered.
|
|
||||||
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
|
|
||||||
|
|
||||||
// Collect which descriptions are currently visible on page 1.
|
|
||||||
const visiblePage1 = new Set<string>();
|
|
||||||
for (const description of descriptions) {
|
|
||||||
if ((await page.getByText(description).count()) > 0) {
|
|
||||||
visiblePage1.add(description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The page limit is 15 → exactly 15 entries visible on page 1.
|
|
||||||
expect(visiblePage1.size).toBe(15);
|
|
||||||
|
|
||||||
// Go to page 2 and wait for the server fetch.
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('button', { name: 'Next Page' }).click(),
|
|
||||||
waitForDetailedReportingUpdate(page),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const visiblePage2 = new Set<string>();
|
|
||||||
for (const description of descriptions) {
|
|
||||||
if ((await page.getByText(description).count()) > 0) {
|
|
||||||
visiblePage2.add(description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Page 2 should hold the remaining 2 entries, disjoint from page 1.
|
|
||||||
expect(visiblePage2.size).toBe(2);
|
|
||||||
for (const description of visiblePage2) {
|
|
||||||
expect(visiblePage1.has(description)).toBe(false);
|
|
||||||
}
|
|
||||||
// Across both pages, all 17 entries should have been visible.
|
|
||||||
expect(visiblePage1.size + visiblePage2.size).toBe(17);
|
|
||||||
|
|
||||||
// Page 2 button is selected.
|
|
||||||
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
|
|
||||||
'aria-current',
|
|
||||||
'page'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Previous page returns to page 1.
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('button', { name: 'Previous Page' }).click(),
|
|
||||||
waitForDetailedReportingUpdate(page),
|
|
||||||
]);
|
|
||||||
expect((await page.getByText(descriptions[0]!).count()) > 0).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that reporting pagination is not shown when there are 15 or fewer time entries', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `FewEntriesProj ${seed}`;
|
|
||||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 5 }, (_, i) =>
|
|
||||||
createTimeEntryViaApi(ctx, {
|
|
||||||
description: `FewEntries ${i} ${seed}`,
|
|
||||||
duration: '30min',
|
|
||||||
projectId: project.id,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await goToReportingDetailed(page);
|
|
||||||
await expect(page.getByText(`FewEntries 0 ${seed}`).first()).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -96,37 +96,6 @@ test('test that project multiselect search filters the option list', async ({ pa
|
|||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that the project filter virtualizes a long list (renders only a window)', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
// Create many projects so the dropdown must virtualize rather than render all of them.
|
|
||||||
const projectNames = Array.from(
|
|
||||||
{ length: 80 },
|
|
||||||
(_, i) => `VirtProj ${String(i).padStart(2, '0')}`
|
|
||||||
);
|
|
||||||
await Promise.all(projectNames.map((name) => createProjectViaApi(ctx, { name })));
|
|
||||||
|
|
||||||
await goToReporting(page);
|
|
||||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: 'Projects' }).first().click();
|
|
||||||
|
|
||||||
// Only a small window of options is mounted, far fewer than the 80+ projects that exist.
|
|
||||||
await expect(page.getByRole('option').first()).toBeVisible();
|
|
||||||
const renderedCount = await page.getByRole('option').count();
|
|
||||||
expect(renderedCount).toBeGreaterThan(0);
|
|
||||||
expect(renderedCount).toBeLessThan(60);
|
|
||||||
|
|
||||||
// Virtualization must not drop options: searching narrows the list to the one deep match.
|
|
||||||
// Wait for the filtered count to settle to 1 before asserting — checking the option while
|
|
||||||
// the virtualizer is still re-rendering can transiently match a stale row (Firefox CI flake).
|
|
||||||
await page.getByPlaceholder('Search for a Project...').fill('VirtProj 79');
|
|
||||||
await expect(page.getByRole('option')).toHaveCount(1);
|
|
||||||
await expect(page.getByRole('option')).toContainText('VirtProj 79');
|
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that selecting multiple projects shows correct badge count', async ({ page, ctx }) => {
|
test('test that selecting multiple projects shows correct badge count', async ({ page, ctx }) => {
|
||||||
const project1Name = 'MultiProj1 ' + Math.floor(Math.random() * 10000);
|
const project1Name = 'MultiProj1 ' + Math.floor(Math.random() * 10000);
|
||||||
const project2Name = 'MultiProj2 ' + Math.floor(Math.random() * 10000);
|
const project2Name = 'MultiProj2 ' + Math.floor(Math.random() * 10000);
|
||||||
|
|||||||
@@ -152,49 +152,6 @@ test('test that editing a task name works', async ({ page, ctx }) => {
|
|||||||
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
|
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that the project can be searched and changed in the create task modal', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const sourceProject = 'Source Project ' + Math.floor(1 + Math.random() * 100000);
|
|
||||||
const targetProject = 'Target Project ' + Math.floor(1 + Math.random() * 100000);
|
|
||||||
await createProjectViaApi(ctx, { name: sourceProject });
|
|
||||||
const target = await createProjectViaApi(ctx, { name: targetProject });
|
|
||||||
|
|
||||||
await goToProjectsOverview(page);
|
|
||||||
await page.getByText(sourceProject).first().click();
|
|
||||||
await page.getByRole('button', { name: 'Create Task' }).click();
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
|
||||||
|
|
||||||
// The project dropdown is pre-filled with the source project; open it.
|
|
||||||
await page.getByRole('dialog').getByRole('button', { name: sourceProject }).click();
|
|
||||||
|
|
||||||
// Opening the dropdown focuses the search input; searching narrows it to the target project.
|
|
||||||
const projectSearch = page.getByPlaceholder('Search for a project...');
|
|
||||||
await expect(projectSearch).toBeFocused();
|
|
||||||
await projectSearch.fill('Target Project');
|
|
||||||
await page.getByRole('option', { name: targetProject }).click();
|
|
||||||
|
|
||||||
// Selecting closes the dropdown and updates the trigger to the chosen project.
|
|
||||||
await expect(
|
|
||||||
page.getByRole('dialog').getByRole('button', { name: targetProject })
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// The new selection is what gets used when the task is created.
|
|
||||||
const taskName = 'Switched Task ' + Math.floor(1 + Math.random() * 100000);
|
|
||||||
await page.getByPlaceholder('Task Name').fill(taskName);
|
|
||||||
await Promise.all([
|
|
||||||
page.getByRole('dialog').getByRole('button', { name: 'Create Task' }).click(),
|
|
||||||
page.waitForResponse(
|
|
||||||
async (response) =>
|
|
||||||
response.url().includes('/tasks') &&
|
|
||||||
response.request().method() === 'POST' &&
|
|
||||||
response.status() === 201 &&
|
|
||||||
(await response.json()).data.project_id === target.id
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that creating a project with an existing client works', async ({ page, ctx }) => {
|
test('test that creating a project with an existing client works', async ({ page, ctx }) => {
|
||||||
const clientName = 'Existing Client ' + Math.floor(1 + Math.random() * 10000);
|
const clientName = 'Existing Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
const projectName = 'Project With Client ' + Math.floor(1 + Math.random() * 10000);
|
const projectName = 'Project With Client ' + Math.floor(1 + Math.random() * 10000);
|
||||||
|
|||||||
@@ -9,14 +9,7 @@ import {
|
|||||||
} from './utils/currentTimeEntry';
|
} from './utils/currentTimeEntry';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { newTagResponse } from './utils/tags';
|
import { newTagResponse } from './utils/tags';
|
||||||
import {
|
import { createProjectViaApi, updateOrganizationCurrencyViaWeb } from './utils/api';
|
||||||
createProjectViaApi,
|
|
||||||
createTaskViaApi,
|
|
||||||
createClientViaApi,
|
|
||||||
archiveProjectViaApi,
|
|
||||||
markTaskDoneViaApi,
|
|
||||||
updateOrganizationCurrencyViaWeb,
|
|
||||||
} from './utils/api';
|
|
||||||
|
|
||||||
// Date picker button name patterns for different date formats
|
// Date picker button name patterns for different date formats
|
||||||
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
||||||
@@ -448,236 +441,3 @@ test('test that adding a project and tag before starting timer works', async ({
|
|||||||
]);
|
]);
|
||||||
await assertThatTimerIsStopped(page);
|
await assertThatTimerIsStopped(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
// Project / Task selector dropdown
|
|
||||||
// Regression coverage for the virtualized + lookup-map refactor of
|
|
||||||
// TimeTrackerProjectTaskDropdown. The dropdown only (re)filters on open and on search
|
|
||||||
// change, so we wait for the dashboard prefetch to settle before opening it.
|
|
||||||
// ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Project Task Dropdown', () => {
|
|
||||||
test.describe.configure({ timeout: 60_000 });
|
|
||||||
|
|
||||||
test('test that a project far down a long list can be found via search and selected', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
// Seed enough projects that the target sits outside the initially rendered window.
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const prefix = `VirtProj ${seed} `;
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: 30 }, (_, i) =>
|
|
||||||
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const target = prefix + '27';
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
await page.getByTestId('client_dropdown_search').fill(target);
|
|
||||||
await page.getByRole('option').filter({ hasText: target }).click();
|
|
||||||
|
|
||||||
// The trigger now reflects the selected project.
|
|
||||||
await expect(page.getByRole('button', { name: target })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that expanding a project and selecting a task works', async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `ExpandProj ${seed}`;
|
|
||||||
const taskName = `ExpandTask ${seed}`;
|
|
||||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
const projectOption = page.getByRole('option').filter({ hasText: projectName });
|
|
||||||
await expect(projectOption).toBeVisible();
|
|
||||||
|
|
||||||
// Expand the project's tasks via the "N Tasks" button, then select the task.
|
|
||||||
await projectOption.getByText(/Tasks/).click();
|
|
||||||
await page.getByText(taskName, { exact: true }).click();
|
|
||||||
|
|
||||||
// Scoped to the trigger button: the closing dropdown also contains the name while animating out.
|
|
||||||
await expect(
|
|
||||||
page.getByRole('button', { name: `${projectName} ${taskName}` })
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that keyboard navigation selects a project', async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `KbProj ${seed}`;
|
|
||||||
await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
const search = page.getByTestId('client_dropdown_search');
|
|
||||||
// On open the search is focused and "No Project" is highlighted.
|
|
||||||
await expect(search).toBeFocused();
|
|
||||||
|
|
||||||
// Arrow down from "No Project" to the project, then select it with Enter.
|
|
||||||
await search.press('ArrowDown');
|
|
||||||
await search.press('Enter');
|
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that search filters the dropdown by project and client name', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const clientName = `FilterClient ${seed}`;
|
|
||||||
const alphaProject = `AlphaProj ${seed}`;
|
|
||||||
const betaProject = `BetaProj ${seed}`;
|
|
||||||
const client = await createClientViaApi(ctx, { name: clientName });
|
|
||||||
await createProjectViaApi(ctx, { name: alphaProject, client_id: client.id });
|
|
||||||
await createProjectViaApi(ctx, { name: betaProject });
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
const search = page.getByTestId('client_dropdown_search');
|
|
||||||
const alphaOption = page.getByRole('option').filter({ hasText: alphaProject });
|
|
||||||
const betaOption = page.getByRole('option').filter({ hasText: betaProject });
|
|
||||||
|
|
||||||
// Both projects are visible before filtering.
|
|
||||||
await expect(alphaOption).toBeVisible();
|
|
||||||
await expect(betaOption).toBeVisible();
|
|
||||||
|
|
||||||
// Project-name search shows only the matching project.
|
|
||||||
await search.fill('AlphaProj');
|
|
||||||
await expect(alphaOption).toBeVisible();
|
|
||||||
await expect(betaOption).not.toBeVisible();
|
|
||||||
|
|
||||||
// Client-name search shows the project that belongs to that client.
|
|
||||||
await search.fill(clientName);
|
|
||||||
await expect(alphaOption).toBeVisible();
|
|
||||||
await expect(betaOption).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("test that searching by task name surfaces the task's project", async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectWithTask = `TaskSearchProj ${seed}`;
|
|
||||||
const taskName = `Findable Task ${seed}`;
|
|
||||||
const unrelatedProject = `Unrelated Proj ${seed}`;
|
|
||||||
const project = await createProjectViaApi(ctx, { name: projectWithTask });
|
|
||||||
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
|
|
||||||
await createProjectViaApi(ctx, { name: unrelatedProject });
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
await page.getByTestId('client_dropdown_search').fill(taskName);
|
|
||||||
|
|
||||||
// The project owning the task is shown (with the task), the unrelated project is not.
|
|
||||||
await expect(page.getByRole('option').filter({ hasText: projectWithTask })).toBeVisible();
|
|
||||||
await expect(page.getByText(taskName, { exact: true })).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByRole('option').filter({ hasText: unrelatedProject })
|
|
||||||
).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that archived projects are hidden from the dropdown', async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const activeProject = `ActiveProj ${seed}`;
|
|
||||||
const archivedProject = `ArchivedProj ${seed}`;
|
|
||||||
await createProjectViaApi(ctx, { name: activeProject });
|
|
||||||
const toArchive = await createProjectViaApi(ctx, { name: archivedProject });
|
|
||||||
await archiveProjectViaApi(ctx, toArchive);
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
|
|
||||||
// Wait for the list to load, then confirm the archived project is filtered out.
|
|
||||||
await expect(page.getByRole('option').filter({ hasText: activeProject })).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByRole('option').filter({ hasText: archivedProject })
|
|
||||||
).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that done tasks are hidden when expanding a project', async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `DoneTaskProj ${seed}`;
|
|
||||||
const activeTask = `Active Task ${seed}`;
|
|
||||||
const doneTask = `Done Task ${seed}`;
|
|
||||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
await createTaskViaApi(ctx, { name: activeTask, project_id: project.id });
|
|
||||||
const taskToFinish = await createTaskViaApi(ctx, {
|
|
||||||
name: doneTask,
|
|
||||||
project_id: project.id,
|
|
||||||
});
|
|
||||||
await markTaskDoneViaApi(ctx, taskToFinish);
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
const projectOption = page.getByRole('option').filter({ hasText: projectName });
|
|
||||||
await expect(projectOption).toBeVisible();
|
|
||||||
await projectOption.getByText(/Tasks/).click();
|
|
||||||
|
|
||||||
// Only the active task shows; the done task is filtered out.
|
|
||||||
await expect(page.getByText(activeTask, { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText(doneTask, { exact: true })).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that keyboard navigation can expand a project and select a task', async ({
|
|
||||||
page,
|
|
||||||
ctx,
|
|
||||||
}) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `KbTaskProj ${seed}`;
|
|
||||||
const taskName = `KbTask ${seed}`;
|
|
||||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
const search = page.getByTestId('client_dropdown_search');
|
|
||||||
await expect(search).toBeFocused();
|
|
||||||
|
|
||||||
// No Project is highlighted on open: down to the project, right to expand its tasks,
|
|
||||||
// down to the task, Enter to select it.
|
|
||||||
await search.press('ArrowDown');
|
|
||||||
await search.press('ArrowRight');
|
|
||||||
await search.press('ArrowDown');
|
|
||||||
await search.press('Enter');
|
|
||||||
|
|
||||||
// Scoped to the trigger button: the closing dropdown also contains the name while animating out.
|
|
||||||
await expect(
|
|
||||||
page.getByRole('button', { name: `${projectName} ${taskName}` })
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test that pressing space selects the highlighted project', async ({ page, ctx }) => {
|
|
||||||
const seed = Math.floor(Math.random() * 100000);
|
|
||||||
const projectName = `SpaceProj ${seed}`;
|
|
||||||
await createProjectViaApi(ctx, { name: projectName });
|
|
||||||
|
|
||||||
await goToDashboard(page);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'No Project' }).click();
|
|
||||||
const search = page.getByTestId('client_dropdown_search');
|
|
||||||
await expect(search).toBeFocused();
|
|
||||||
|
|
||||||
// Arrow down from "No Project" to the project, then the space shortcut selects it.
|
|
||||||
await search.press('ArrowDown');
|
|
||||||
await search.press('Space');
|
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -373,20 +373,6 @@ export async function createTaskViaApi(
|
|||||||
return body.data as { id: string; name: string; project_id: string };
|
return body.data as { id: string; name: string; project_id: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markTaskDoneViaApi(ctx: TestContext, task: { id: string; name: string }) {
|
|
||||||
const response = await ctx.request.put(
|
|
||||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks/${task.id}`,
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
name: task.name,
|
|
||||||
is_done: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
return (await response.json()).data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTagViaApi(ctx: TestContext, data: { name: string }) {
|
export async function createTagViaApi(ctx: TestContext, data: { name: string }) {
|
||||||
const response = await ctx.request.post(
|
const response = await ctx.request.post(
|
||||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tags`,
|
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tags`,
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ declare(strict_types=1);
|
|||||||
return [
|
return [
|
||||||
'clockify_time_entries' => [
|
'clockify_time_entries' => [
|
||||||
'name' => 'Clockify Time Entries',
|
'name' => 'Clockify Time Entries',
|
||||||
'description' => '<strong>Important:</strong> If you also want to import your projects use the "Clockify Projects" importer before this one, since that export contains more details such as billable status, billable rates and estimated time.<br><br>'.
|
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
|
||||||
'1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
|
|
||||||
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
|
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
|
||||||
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
|
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
|
||||||
'4. Now select the date range that you want to export in the right top. '.
|
'4. Now select the date range that you want to export in the right top. '.
|
||||||
@@ -62,8 +61,7 @@ return [
|
|||||||
],
|
],
|
||||||
'harvest_time_entries' => [
|
'harvest_time_entries' => [
|
||||||
'name' => 'Harvest Time Entries',
|
'name' => 'Harvest Time Entries',
|
||||||
'description' => '<strong>Important:</strong> If you also want to import your projects use the "Harvest Projects" importer before this one, since that export contains more details such as billable status and estimated time.<br><br>'.
|
'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
|
||||||
'1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
|
|
||||||
'<br>3. Now click on "Export all time" '.
|
'<br>3. Now click on "Export all time" '.
|
||||||
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
|
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
|
||||||
],
|
],
|
||||||
|
|||||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -20,7 +20,6 @@
|
|||||||
"@tanstack/vue-query": "^5.100.10",
|
"@tanstack/vue-query": "^5.100.10",
|
||||||
"@tanstack/vue-query-devtools": "^5.91.0",
|
"@tanstack/vue-query-devtools": "^5.91.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@tanstack/vue-virtual": "^3.13.24",
|
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
@@ -5465,6 +5464,17 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-vue-next": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
|
||||||
|
"deprecated": "Package deprecated. Please use @lucide/vue instead.",
|
||||||
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": ">=3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -8399,7 +8409,7 @@
|
|||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite-plugin-dts": "^4.5.4"
|
"vite-plugin-dts": "^4.0.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
@@ -8414,17 +8424,15 @@
|
|||||||
"version": "0.0.21",
|
"version": "0.0.21",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chroma-js": "^3.1.2",
|
"@types/chroma-js": "^3.1.0",
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"vite-plugin-dts": "^4.5.4",
|
"vite-plugin-dts": "^4.0.3",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@floating-ui/vue": "^1.1.4",
|
"@floating-ui/vue": "^1.1.4",
|
||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@internationalized/date": "^3.0.0",
|
"@internationalized/date": "^3.0.0",
|
||||||
"@lucide/vue": ">=1.0.0",
|
|
||||||
"@tanstack/vue-virtual": "^3.13.24",
|
|
||||||
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
||||||
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
||||||
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
||||||
@@ -8433,6 +8441,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"focus-trap": "^7.0.0 || ^8.0.0",
|
"focus-trap": "^7.0.0 || ^8.0.0",
|
||||||
|
"lucide-vue-next": ">=0.453.0",
|
||||||
"parse-duration": "^2.0.1",
|
"parse-duration": "^2.0.1",
|
||||||
"radix-vue": "^1.9.0",
|
"radix-vue": "^1.9.0",
|
||||||
"reka-ui": "^2.2.0",
|
"reka-ui": "^2.2.0",
|
||||||
|
|||||||
@@ -64,7 +64,6 @@
|
|||||||
"@tanstack/vue-query": "^5.100.10",
|
"@tanstack/vue-query": "^5.100.10",
|
||||||
"@tanstack/vue-query-devtools": "^5.91.0",
|
"@tanstack/vue-query-devtools": "^5.91.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@tanstack/vue-virtual": "^3.13.24",
|
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { UserCircleIcon } from '@heroicons/vue/24/solid';
|
import { UserCircleIcon } from '@heroicons/vue/24/solid';
|
||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import { type Component, computed, ref, watch } from 'vue';
|
import { type Component, computed, ref } from 'vue';
|
||||||
import { type Client } from '@/packages/api/src';
|
import { type Client } from '@/packages/api/src';
|
||||||
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue';
|
||||||
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue';
|
||||||
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue';
|
||||||
import Pagination from '@/Components/Common/Pagination.vue';
|
|
||||||
import { canCreateClients } from '@/utils/permissions';
|
import { canCreateClients } from '@/utils/permissions';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import {
|
import {
|
||||||
@@ -44,14 +43,11 @@ const projectCountMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Name is always the secondary sort so rows with equal values render
|
|
||||||
// alphabetically instead of in API (created_at) order.
|
|
||||||
const sorting = computed<SortingState>(() => [
|
const sorting = computed<SortingState>(() => [
|
||||||
{
|
{
|
||||||
id: props.sortColumn,
|
id: props.sortColumn,
|
||||||
desc: props.sortDirection === 'desc',
|
desc: props.sortDirection === 'desc',
|
||||||
},
|
},
|
||||||
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const columns = computed(() => [
|
const columns = computed(() => [
|
||||||
@@ -104,19 +100,6 @@ const table = useVueTable({
|
|||||||
const sortedClients = computed(() => {
|
const sortedClients = computed(() => {
|
||||||
return table.getRowModel().rows.map((row) => row.original);
|
return table.getRowModel().rows.map((row) => row.original);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
|
|
||||||
const PAGE_SIZE = 15;
|
|
||||||
const currentPage = ref(1);
|
|
||||||
|
|
||||||
watch([() => props.sortColumn, () => props.sortDirection, () => props.clients], () => {
|
|
||||||
currentPage.value = 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const paginatedClients = computed(() => {
|
|
||||||
const start = (currentPage.value - 1) * PAGE_SIZE;
|
|
||||||
return sortedClients.value.slice(start, start + PAGE_SIZE);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -143,14 +126,10 @@ const paginatedClients = computed(() => {
|
|||||||
>Create your First Client
|
>Create your First Client
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="client in paginatedClients" :key="client.id">
|
<template v-for="client in sortedClients" :key="client.id">
|
||||||
<ClientTableRow :client="client"></ClientTableRow>
|
<ClientTableRow :client="client"></ClientTableRow>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
|
||||||
v-model:page="currentPage"
|
|
||||||
:total="sortedClients.length"
|
|
||||||
:items-per-page="PAGE_SIZE"></Pagination>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -89,17 +89,11 @@ function selectMember(member: Member) {
|
|||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<!-- kept open so the list stays visible during the popover close animation -->
|
|
||||||
<ComboboxRoot
|
<ComboboxRoot
|
||||||
v-model:search-term="searchValue"
|
v-model:search-term="searchValue"
|
||||||
:open="true"
|
v-model:open="open"
|
||||||
class="relative"
|
class="relative"
|
||||||
:filter-function="(val: string[]) => val"
|
:filter-function="(val: string[]) => val">
|
||||||
@update:open="
|
|
||||||
(value: boolean) => {
|
|
||||||
if (!value) open = false;
|
|
||||||
}
|
|
||||||
">
|
|
||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
PaginationEllipsis,
|
|
||||||
PaginationFirst,
|
|
||||||
PaginationLast,
|
|
||||||
PaginationList,
|
|
||||||
PaginationListItem,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrev,
|
|
||||||
PaginationRoot,
|
|
||||||
} from 'radix-vue';
|
|
||||||
import {
|
|
||||||
ChevronDoubleLeftIcon,
|
|
||||||
ChevronDoubleRightIcon,
|
|
||||||
ChevronLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
EllipsisHorizontalIcon,
|
|
||||||
} from '@heroicons/vue/20/solid';
|
|
||||||
import { buttonVariants } from '@/packages/ui/src';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { computed, watch } from 'vue';
|
|
||||||
|
|
||||||
const page = defineModel<number>('page', { default: 1 });
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
total: number;
|
|
||||||
itemsPerPage?: number;
|
|
||||||
siblingCount?: number;
|
|
||||||
showEdges?: boolean;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
itemsPerPage: 15,
|
|
||||||
siblingCount: 1,
|
|
||||||
showEdges: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.itemsPerPage)));
|
|
||||||
|
|
||||||
watch(page, (value) => {
|
|
||||||
if (value > pageCount.value) {
|
|
||||||
page.value = pageCount.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(pageCount, (value) => {
|
|
||||||
if (page.value > value) {
|
|
||||||
page.value = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// The shared buttonVariants ghost/outline hover is `bg-white/5`, which is invisible in light
|
|
||||||
// mode. Override it with a theme-aware hover that shows in both light and dark mode.
|
|
||||||
const hoverClass = 'hover:bg-black/5 dark:hover:bg-white/5';
|
|
||||||
const navButtonClass = cn(buttonVariants({ variant: 'ghost', size: 'icon' }), hoverClass);
|
|
||||||
|
|
||||||
function pageButtonClass(isActive: boolean): string {
|
|
||||||
return cn(
|
|
||||||
buttonVariants({ variant: isActive ? 'outline' : 'ghost', size: 'icon' }),
|
|
||||||
hoverClass
|
|
||||||
);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<PaginationRoot
|
|
||||||
v-if="pageCount > 1"
|
|
||||||
v-model:page="page"
|
|
||||||
:total="props.total"
|
|
||||||
:items-per-page="props.itemsPerPage"
|
|
||||||
:sibling-count="props.siblingCount"
|
|
||||||
:show-edges="props.showEdges"
|
|
||||||
class="mx-auto flex w-full justify-center py-8">
|
|
||||||
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
|
|
||||||
<PaginationFirst :class="navButtonClass">
|
|
||||||
<ChevronDoubleLeftIcon class="size-4" />
|
|
||||||
</PaginationFirst>
|
|
||||||
<PaginationPrev :class="navButtonClass">
|
|
||||||
<ChevronLeftIcon class="size-4" />
|
|
||||||
</PaginationPrev>
|
|
||||||
<template v-for="(item, index) in items" :key="index">
|
|
||||||
<PaginationListItem
|
|
||||||
v-if="item.type === 'page'"
|
|
||||||
:value="item.value"
|
|
||||||
:class="pageButtonClass(item.value === page)">
|
|
||||||
{{ item.value }}
|
|
||||||
</PaginationListItem>
|
|
||||||
<PaginationEllipsis
|
|
||||||
v-else
|
|
||||||
:index="index"
|
|
||||||
class="flex size-9 items-center justify-center text-text-tertiary">
|
|
||||||
<EllipsisHorizontalIcon class="size-4" />
|
|
||||||
</PaginationEllipsis>
|
|
||||||
</template>
|
|
||||||
<PaginationNext :class="navButtonClass">
|
|
||||||
<ChevronRightIcon class="size-4" />
|
|
||||||
</PaginationNext>
|
|
||||||
<PaginationLast :class="navButtonClass">
|
|
||||||
<ChevronDoubleRightIcon class="size-4" />
|
|
||||||
</PaginationLast>
|
|
||||||
</PaginationList>
|
|
||||||
</PaginationRoot>
|
|
||||||
</template>
|
|
||||||
@@ -9,10 +9,10 @@ import {
|
|||||||
ComboboxItem,
|
ComboboxItem,
|
||||||
ComboboxRoot,
|
ComboboxRoot,
|
||||||
ComboboxViewport,
|
ComboboxViewport,
|
||||||
ComboboxVirtualizer,
|
} from 'radix-vue';
|
||||||
} from 'reka-ui';
|
|
||||||
import { Check, Plus } from '@lucide/vue';
|
import { Check, Plus } from '@lucide/vue';
|
||||||
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
|
import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
|
||||||
|
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
||||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
@@ -37,16 +37,7 @@ const emit = defineEmits(['update:modelValue', 'changed']);
|
|||||||
|
|
||||||
const activeClients = computed(() => clients.value.filter((c) => !c.is_archived));
|
const activeClients = computed(() => clients.value.filter((c) => !c.is_archived));
|
||||||
|
|
||||||
// Pinned on open so rows don't re-sort while interacting; the project list itself stays reactive.
|
const sortedProjects = ref<Project[]>([]);
|
||||||
const pinnedProjectId = ref<string | null>(null);
|
|
||||||
|
|
||||||
const sortedProjects = computed(() => {
|
|
||||||
return [...projects.value].sort((a, b) => {
|
|
||||||
const aPinned = pinnedProjectId.value === a.id ? 0 : 1;
|
|
||||||
const bPinned = pinnedProjectId.value === b.id ? 0 : 1;
|
|
||||||
return aPinned - bPinned;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const shownProjects = computed(() => {
|
const shownProjects = computed(() => {
|
||||||
return sortedProjects.value.filter((project) => {
|
return sortedProjects.value.filter((project) => {
|
||||||
@@ -74,7 +65,9 @@ watch(open, (isOpen) => {
|
|||||||
searchInput.value?.$el?.focus();
|
searchInput.value?.$el?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
pinnedProjectId.value = model.value;
|
sortedProjects.value = [...projects.value].sort((iteratingProject) => {
|
||||||
|
return model.value === iteratingProject.id ? -1 : 1;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,51 +103,40 @@ function updateValue(project: Project) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<!-- kept open so the list stays visible during the popover close animation -->
|
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||||
<div>
|
|
||||||
<ComboboxRoot
|
<ComboboxRoot
|
||||||
:open="true"
|
v-model:search-term="searchValue"
|
||||||
|
v-model:open="open"
|
||||||
:model-value="currentProject"
|
:model-value="currentProject"
|
||||||
class="relative"
|
class="relative"
|
||||||
:ignore-filter="true"
|
@update:model-value="updateValue">
|
||||||
@update:model-value="updateValue"
|
|
||||||
@update:open="
|
|
||||||
(value: boolean) => {
|
|
||||||
if (!value) open = false;
|
|
||||||
}
|
|
||||||
">
|
|
||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
v-model="searchValue"
|
|
||||||
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
|
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
|
||||||
placeholder="Search for a project..." />
|
placeholder="Search for a project..." />
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
<ComboboxViewport
|
<ComboboxViewport
|
||||||
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
|
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
|
||||||
<ComboboxVirtualizer
|
<ComboboxItem
|
||||||
v-slot="{ option: project }"
|
v-for="project in shownProjects"
|
||||||
:options="shownProjects"
|
:key="project.id"
|
||||||
:estimate-size="32"
|
:value="project"
|
||||||
:text-content="(p: Project) => p.name">
|
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
<ComboboxItem
|
:data-project-id="project.id">
|
||||||
:value="project"
|
<span class="flex items-center gap-2">
|
||||||
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
|
||||||
:data-project-id="project.id">
|
|
||||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
|
||||||
<span
|
|
||||||
:style="{ backgroundColor: project.color }"
|
|
||||||
class="w-3 h-3 rounded-full shrink-0"></span>
|
|
||||||
<span class="truncate">{{ project.name }}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
v-if="isProjectSelected(project)"
|
:style="{ backgroundColor: project.color }"
|
||||||
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
class="w-3 h-3 rounded-full shrink-0"></span>
|
||||||
<Check class="h-4 w-4" />
|
<span>{{ project.name }}</span>
|
||||||
</span>
|
</span>
|
||||||
</ComboboxItem>
|
<span
|
||||||
</ComboboxVirtualizer>
|
v-if="isProjectSelected(project)"
|
||||||
|
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</ComboboxItem>
|
||||||
</ComboboxViewport>
|
</ComboboxViewport>
|
||||||
<div
|
<div
|
||||||
v-if="canCreateProjects()"
|
v-if="canCreateProjects()"
|
||||||
@@ -168,7 +150,7 @@ function updateValue(project: Project) {
|
|||||||
</div>
|
</div>
|
||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</ComboboxRoot>
|
</ComboboxRoot>
|
||||||
</div>
|
</UseFocusTrap>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<ProjectCreateModal
|
<ProjectCreateModal
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||||
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
|
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
|
||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||||
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
|
||||||
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
|
||||||
import Pagination from '@/Components/Common/Pagination.vue';
|
|
||||||
|
|
||||||
export type SortColumn =
|
export type SortColumn =
|
||||||
| 'name'
|
| 'name'
|
||||||
@@ -57,15 +56,12 @@ const clientNameMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert sort props to TanStack Table format.
|
// Convert sort props to TanStack Table format
|
||||||
// Name is always the secondary sort so rows with equal values render
|
|
||||||
// alphabetically instead of in API (created_at) order.
|
|
||||||
const sorting = computed<SortingState>(() => [
|
const sorting = computed<SortingState>(() => [
|
||||||
{
|
{
|
||||||
id: props.sortColumn,
|
id: props.sortColumn,
|
||||||
desc: props.sortDirection === 'desc',
|
desc: props.sortDirection === 'desc',
|
||||||
},
|
},
|
||||||
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Define column accessors for sorting.
|
// Define column accessors for sorting.
|
||||||
@@ -147,19 +143,6 @@ const sortedProjects = computed(() => {
|
|||||||
return table.getRowModel().rows.map((row) => row.original);
|
return table.getRowModel().rows.map((row) => row.original);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Client-side pagination: the full list is in memory, only one page is mounted at a time.
|
|
||||||
const PAGE_SIZE = 15;
|
|
||||||
const currentPage = ref(1);
|
|
||||||
|
|
||||||
watch([() => props.sortColumn, () => props.sortDirection, () => props.projects], () => {
|
|
||||||
currentPage.value = 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const paginatedProjects = computed(() => {
|
|
||||||
const start = (currentPage.value - 1) * PAGE_SIZE;
|
|
||||||
return sortedProjects.value.slice(start, start + PAGE_SIZE);
|
|
||||||
});
|
|
||||||
|
|
||||||
const showCreateProjectModal = ref(false);
|
const showCreateProjectModal = ref(false);
|
||||||
|
|
||||||
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
|
||||||
@@ -216,7 +199,7 @@ const gridTemplate = computed(() => {
|
|||||||
>Create your First Project
|
>Create your First Project
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="project in paginatedProjects" :key="project.id">
|
<template v-for="project in sortedProjects" :key="project.id">
|
||||||
<ProjectTableRow
|
<ProjectTableRow
|
||||||
:show-billable-rate="props.showBillableRate"
|
:show-billable-rate="props.showBillableRate"
|
||||||
:project="project"></ProjectTableRow>
|
:project="project"></ProjectTableRow>
|
||||||
@@ -224,8 +207,4 @@ const gridTemplate = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
|
||||||
v-model:page="currentPage"
|
|
||||||
:total="sortedProjects.length"
|
|
||||||
:items-per-page="PAGE_SIZE"></Pagination>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
|
|||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||||
|
|
||||||
defineProps({
|
withDefaults(
|
||||||
canResetPassword: Boolean,
|
defineProps<{
|
||||||
status: String,
|
canResetPassword?: boolean;
|
||||||
});
|
status?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
canResetPassword: false,
|
||||||
|
status: '',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
email: '',
|
email: '',
|
||||||
@@ -28,8 +34,8 @@ const submit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
flash: {
|
flash?: {
|
||||||
message: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
@@ -61,6 +67,9 @@ const page = usePage<{
|
|||||||
{{ page.props.flash?.message }}
|
{{ page.props.flash?.message }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Extension seam: alternative-auth errors (e.g. SSO callback failures) -->
|
||||||
|
<slot name="error" />
|
||||||
|
|
||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="email">Email</FieldLabel>
|
<FieldLabel for="email">Email</FieldLabel>
|
||||||
@@ -103,5 +112,8 @@ const page = usePage<{
|
|||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Extension seam: alternative auth methods (e.g. SSO providers) -->
|
||||||
|
<slot name="alternatives" />
|
||||||
</AuthenticationCard>
|
</AuthenticationCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import AppLayout from '@/Layouts/AppLayout.vue';
|
|||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import {
|
import {
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronDoubleLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
ChevronDoubleRightIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
EllipsisVerticalIcon,
|
EllipsisVerticalIcon,
|
||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
LockClosedIcon,
|
LockClosedIcon,
|
||||||
} from '@heroicons/vue/20/solid';
|
} from '@heroicons/vue/20/solid';
|
||||||
import Pagination from '@/Components/Common/Pagination.vue';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -40,6 +43,16 @@ import { useClientsQuery } from '@/utils/useClientsQuery';
|
|||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { useMembersQuery } from '@/utils/useMembersQuery';
|
import { useMembersQuery } from '@/utils/useMembersQuery';
|
||||||
|
import {
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationFirst,
|
||||||
|
PaginationLast,
|
||||||
|
PaginationList,
|
||||||
|
PaginationListItem,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrev,
|
||||||
|
PaginationRoot,
|
||||||
|
} from 'radix-vue';
|
||||||
import { useQueryClient } from '@tanstack/vue-query';
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
||||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||||
@@ -397,6 +410,62 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination v-model:page="currentPage" :total="totalPages" :items-per-page="pageLimit" />
|
<PaginationRoot
|
||||||
|
v-model:page="currentPage"
|
||||||
|
:total="totalPages"
|
||||||
|
:items-per-page="pageLimit"
|
||||||
|
class="flex justify-center items-center py-8"
|
||||||
|
:sibling-count="1"
|
||||||
|
show-edges>
|
||||||
|
<PaginationList v-slot="{ items }" class="flex items-center space-x-1 relative">
|
||||||
|
<div class="pr-2 flex items-center space-x-1 border-r border-border-primary mr-1">
|
||||||
|
<PaginationFirst class="navigation-item">
|
||||||
|
<ChevronDoubleLeftIcon class="w-4"> </ChevronDoubleLeftIcon>
|
||||||
|
</PaginationFirst>
|
||||||
|
<PaginationPrev class="mr-4 navigation-item">
|
||||||
|
<ChevronLeftIcon class="w-4 text-text-tertiary hover:text-text-primary">
|
||||||
|
</ChevronLeftIcon>
|
||||||
|
</PaginationPrev>
|
||||||
|
</div>
|
||||||
|
<template v-for="(page, index) in items">
|
||||||
|
<PaginationListItem
|
||||||
|
v-if="page.type === 'page'"
|
||||||
|
:key="index"
|
||||||
|
class="pagination-item"
|
||||||
|
:value="page.value">
|
||||||
|
{{ page.value }}
|
||||||
|
</PaginationListItem>
|
||||||
|
<PaginationEllipsis
|
||||||
|
v-else
|
||||||
|
:key="page.type"
|
||||||
|
:index="index"
|
||||||
|
class="PaginationEllipsis">
|
||||||
|
<div class="px-2">…</div>
|
||||||
|
</PaginationEllipsis>
|
||||||
|
</template>
|
||||||
|
<div class="!ml-2 pl-2 flex items-center space-x-1 border-l border-border-primary">
|
||||||
|
<PaginationNext class="navigation-item">
|
||||||
|
<ChevronRightIcon
|
||||||
|
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronRightIcon>
|
||||||
|
</PaginationNext>
|
||||||
|
<PaginationLast class="navigation-item">
|
||||||
|
<ChevronDoubleRightIcon
|
||||||
|
class="w-4 text-text-tertiary hover:text-text-primary"></ChevronDoubleRightIcon>
|
||||||
|
</PaginationLast>
|
||||||
|
</div>
|
||||||
|
</PaginationList>
|
||||||
|
</PaginationRoot>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
<style lang="postcss">
|
||||||
|
.navigation-item {
|
||||||
|
@apply bg-quaternary h-8 w-8 flex items-center justify-center rounded border border-border-primary text-text-tertiary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-item {
|
||||||
|
@apply bg-secondary h-8 w-8 flex items-center justify-center rounded border border-border-tertiary text-text-secondary hover:text-text-primary transition cursor-pointer hover:border-border-secondary hover:bg-secondary focus-visible:text-text-primary focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-ring;
|
||||||
|
}
|
||||||
|
.pagination-item[data-selected] {
|
||||||
|
@apply text-text-primary bg-accent-300/10 border border-accent-300/20 rounded-md font-medium hover:bg-accent-300/20 active:bg-accent-300/20 outline-0 focus-visible:ring-2 focus:ring-ring transition ease-in-out duration-150;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -10,35 +10,73 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
|
|||||||
import { type DefineComponent } from 'vue';
|
import { type DefineComponent } from 'vue';
|
||||||
import { setupPrefetching } from '@/utils/prefetch';
|
import { setupPrefetching } from '@/utils/prefetch';
|
||||||
|
|
||||||
|
interface ExtensionManifest {
|
||||||
|
name?: string;
|
||||||
|
alias?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
const extensionManifests = import.meta.glob('../../extensions/**/module.json', {
|
||||||
|
eager: true,
|
||||||
|
import: 'default',
|
||||||
|
}) as Record<string, ExtensionManifest>;
|
||||||
|
// BillingPortal is a Vue 2 component and must not be bundled into the Vue 3 app.
|
||||||
|
const extensionPages = import.meta.glob<DefineComponent>([
|
||||||
|
'../../extensions/**/resources/js/Pages/**/*.vue',
|
||||||
|
'!**/BillingPortal.vue',
|
||||||
|
]);
|
||||||
|
const extensionDirectories = Object.entries(extensionManifests).reduce<Record<string, string>>(
|
||||||
|
(directories, [path, manifest]) => {
|
||||||
|
const match = path.match(/^\.\.\/\.\.\/extensions\/([^/]+)\/module\.json$/);
|
||||||
|
const extensionDirectory = match?.[1];
|
||||||
|
|
||||||
|
if (extensionDirectory === undefined) {
|
||||||
|
return directories;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of [manifest.name, manifest.alias, extensionDirectory]) {
|
||||||
|
if (typeof key !== 'string' || key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
directories[key] = extensionDirectory;
|
||||||
|
directories[key.toLowerCase()] = extensionDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
return directories;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
function resolveExtensionDirectory(moduleName: string): string {
|
||||||
|
return (
|
||||||
|
extensionDirectories[moduleName] ??
|
||||||
|
extensionDirectories[moduleName.toLowerCase()] ??
|
||||||
|
moduleName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
createInertiaApp({
|
createInertiaApp({
|
||||||
title: (title) => `${title} - ${appName}`,
|
title: (title) => `${title} - ${appName}`,
|
||||||
resolve: (name) => {
|
resolve: (name) => {
|
||||||
if (name.includes('Invoicing::')) {
|
// "Module::Page" (both halves present) resolves to that extension's page
|
||||||
const [module, page] = name.split('::');
|
// directory; everything else is a host page under resources/js/Pages.
|
||||||
|
const [module, ...pageSegments] = name.split('::');
|
||||||
|
const page = pageSegments.join('::');
|
||||||
|
|
||||||
const pagePath = module
|
if (module && page) {
|
||||||
? `../../extensions/${module}/resources/js/Pages/${page}.vue`
|
const extensionDirectory = resolveExtensionDirectory(module);
|
||||||
: `./Pages/${page}.vue`;
|
const pagePath = `../../extensions/${extensionDirectory}/resources/js/Pages/${page}.vue`;
|
||||||
|
|
||||||
// BillingPortal is a Vue 2 Component and therefore should not be imported
|
return resolvePageComponent(pagePath, extensionPages);
|
||||||
const pages = module
|
|
||||||
? import.meta.glob<DefineComponent>([
|
|
||||||
'../../extensions/**/resources/js/Pages/*.vue',
|
|
||||||
'!**/BillingPortal.vue',
|
|
||||||
])
|
|
||||||
: import.meta.glob<DefineComponent>('./Pages/**/*.vue');
|
|
||||||
|
|
||||||
return resolvePageComponent(pagePath, pages);
|
|
||||||
} else {
|
|
||||||
return resolvePageComponent(
|
|
||||||
`./Pages/${name}.vue`,
|
|
||||||
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return resolvePageComponent(
|
||||||
|
`./Pages/${name}.vue`,
|
||||||
|
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
|
||||||
|
);
|
||||||
},
|
},
|
||||||
setup({ el, App, props, plugin }) {
|
setup({ el, App, props, plugin }) {
|
||||||
const app = createApp({ render: () => h(App, props) });
|
const app = createApp({ render: () => h(App, props) });
|
||||||
|
|||||||
@@ -57,7 +57,6 @@
|
|||||||
"@floating-ui/vue": "^1.1.4",
|
"@floating-ui/vue": "^1.1.4",
|
||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
"@vitejs/plugin-vue": "^5.1.2 || ^6.0.0",
|
||||||
"@tanstack/vue-virtual": "^3.13.24",
|
|
||||||
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
"@vueuse/core": "^12.5.0 || ^14.0.0",
|
||||||
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
"@vueuse/integrations": "^12.5.0 || ^14.0.0",
|
||||||
"focus-trap": "^7.0.0 || ^8.0.0",
|
"focus-trap": "^7.0.0 || ^8.0.0",
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
ComboboxItem,
|
ComboboxItem,
|
||||||
ComboboxRoot,
|
ComboboxRoot,
|
||||||
ComboboxViewport,
|
ComboboxViewport,
|
||||||
ComboboxVirtualizer,
|
} from 'radix-vue';
|
||||||
} from 'reka-ui';
|
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
import { Check, Plus } from '@lucide/vue';
|
import { Check, Plus } from '@lucide/vue';
|
||||||
|
|
||||||
@@ -26,6 +26,10 @@ const searchInput = ref<HTMLElement | null>(null);
|
|||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const searchValue = ref('');
|
const searchValue = ref('');
|
||||||
|
|
||||||
|
function isClientSelected(id: string) {
|
||||||
|
return model.value === id;
|
||||||
|
}
|
||||||
|
|
||||||
watch(open, (isOpen) => {
|
watch(open, (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -54,23 +58,15 @@ async function addClientIfNoneExists() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_CLIENT: { id: string | null; name: string } = { id: null, name: 'No Client' };
|
|
||||||
|
|
||||||
const currentClient = computed(() => {
|
const currentClient = computed(() => {
|
||||||
return props.clients.find((client) => client.id === model.value) ?? NO_CLIENT;
|
return (
|
||||||
|
props.clients.find((client) => client.id === model.value) ?? {
|
||||||
|
id: null,
|
||||||
|
name: 'No Client',
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
type ClientRow = Client | typeof NO_CLIENT;
|
|
||||||
|
|
||||||
// Fold the "No Client" entry in as the first row so the whole list virtualizes through one
|
|
||||||
// ComboboxVirtualizer. NO_CLIENT is a shared constant so currentClient and the row reference
|
|
||||||
// the same object and single-select highlighting still matches.
|
|
||||||
const clientRows = computed<ClientRow[]>(() => [NO_CLIENT, ...filteredClients.value]);
|
|
||||||
|
|
||||||
function clientRowName(row: ClientRow) {
|
|
||||||
return row.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||||
|
|
||||||
function updateValue(client: { id: string | null; name: string }) {
|
function updateValue(client: { id: string | null; name: string }) {
|
||||||
@@ -85,56 +81,56 @@ function updateValue(client: { id: string | null; name: string }) {
|
|||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div>
|
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||||
<ComboboxRoot
|
<ComboboxRoot
|
||||||
:open="true"
|
v-model:search-term="searchValue"
|
||||||
|
v-model:open="open"
|
||||||
:model-value="currentClient"
|
:model-value="currentClient"
|
||||||
class="relative"
|
class="relative"
|
||||||
:ignore-filter="true"
|
@update:model-value="updateValue">
|
||||||
@update:model-value="updateValue"
|
|
||||||
@update:open="
|
|
||||||
(value: boolean) => {
|
|
||||||
if (!value) open = false;
|
|
||||||
}
|
|
||||||
">
|
|
||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
v-model="searchValue"
|
|
||||||
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
|
class="bg-transparent border-0 placeholder-muted-foreground text-sm text-popover-foreground py-2 px-3 focus:ring-0 border-b border-popover-border focus:border-popover-border w-full"
|
||||||
placeholder="Search for a client..." />
|
placeholder="Search for a client..." />
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
<ComboboxViewport
|
<ComboboxViewport
|
||||||
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
|
class="w-[--reka-popper-anchor-width] max-h-60 overflow-y-scroll p-1">
|
||||||
<ComboboxVirtualizer
|
<ComboboxItem
|
||||||
v-slot="{ option: row }"
|
:value="{ id: null, name: 'No Client' }"
|
||||||
:options="clientRows"
|
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground">
|
||||||
:estimate-size="32"
|
<span>No Client</span>
|
||||||
:text-content="clientRowName">
|
<span
|
||||||
<ComboboxItem
|
v-if="model === null"
|
||||||
:value="row"
|
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
<Check class="h-4 w-4" />
|
||||||
:data-client-id="row.id">
|
</span>
|
||||||
<span class="min-w-0 flex-1 truncate">{{ row.name }}</span>
|
</ComboboxItem>
|
||||||
<span
|
<ComboboxItem
|
||||||
v-if="model === row.id"
|
v-for="client in filteredClients"
|
||||||
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
:key="client.id"
|
||||||
<Check class="h-4 w-4" />
|
:value="client"
|
||||||
</span>
|
class="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
</ComboboxItem>
|
:data-client-id="client.id">
|
||||||
</ComboboxVirtualizer>
|
<span>{{ client.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="isClientSelected(client.id)"
|
||||||
|
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</ComboboxItem>
|
||||||
|
<div
|
||||||
|
v-if="searchValue.length > 0 && filteredClients.length === 0"
|
||||||
|
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||||
|
@click="addClientIfNoneExists">
|
||||||
|
<Plus class="h-4 w-4 shrink-0" />
|
||||||
|
<span>Add "{{ searchValue }}" as a new Client</span>
|
||||||
|
</div>
|
||||||
</ComboboxViewport>
|
</ComboboxViewport>
|
||||||
<div
|
|
||||||
v-if="searchValue.length > 0 && filteredClients.length === 0"
|
|
||||||
class="flex items-center gap-2 rounded-sm mx-1 px-2 py-1.5 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
|
||||||
@click="addClientIfNoneExists">
|
|
||||||
<Plus class="h-4 w-4 shrink-0" />
|
|
||||||
<span>Add "{{ searchValue }}" as a new Client</span>
|
|
||||||
</div>
|
|
||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</ComboboxRoot>
|
</ComboboxRoot>
|
||||||
</div>
|
</UseFocusTrap>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts" generic="T">
|
<script setup lang="ts" generic="T">
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, type Ref, ref, watch } from 'vue';
|
||||||
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
|
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
|
||||||
import {
|
import {
|
||||||
ComboboxAnchor,
|
ComboboxAnchor,
|
||||||
@@ -9,16 +9,10 @@ import {
|
|||||||
ComboboxItem,
|
ComboboxItem,
|
||||||
ComboboxRoot,
|
ComboboxRoot,
|
||||||
ComboboxViewport,
|
ComboboxViewport,
|
||||||
ComboboxVirtualizer,
|
} from 'radix-vue';
|
||||||
} from 'reka-ui';
|
|
||||||
|
|
||||||
const NONE_ID = 'none';
|
const NONE_ID = 'none';
|
||||||
|
|
||||||
// height of one row (px-2 py-1.5 text-sm → 12px padding + 20px line box).
|
|
||||||
// Rows are uniform single-line, so a fixed size is exact enough for the virtualizer and avoids
|
|
||||||
// any per-row DOM measurement.
|
|
||||||
const ROW_HEIGHT = 32;
|
|
||||||
|
|
||||||
const model = defineModel<string[]>({
|
const model = defineModel<string[]>({
|
||||||
default: [],
|
default: [],
|
||||||
});
|
});
|
||||||
@@ -33,25 +27,20 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const searchValue = ref('');
|
const searchValue = ref('');
|
||||||
// Pinned on open so rows don't re-sort while toggling; the item list itself stays reactive.
|
const sortedItems = ref<T[]>([]) as Ref<T[]>;
|
||||||
const pinnedSelection = ref<Set<string>>(new Set());
|
|
||||||
|
|
||||||
watch(open, (isOpen) => {
|
watch(open, (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
searchValue.value = '';
|
searchValue.value = '';
|
||||||
pinnedSelection.value = new Set(model.value);
|
sortedItems.value = [...props.items].sort((a, b) => {
|
||||||
|
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
|
||||||
|
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
|
||||||
|
if (aSelected !== bSelected) return aSelected - bSelected;
|
||||||
|
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedItems = computed(() => {
|
|
||||||
return [...props.items].sort((a, b) => {
|
|
||||||
const aSelected = pinnedSelection.value.has(props.getKeyFromItem(a)) ? 0 : 1;
|
|
||||||
const bSelected = pinnedSelection.value.has(props.getKeyFromItem(b)) ? 0 : 1;
|
|
||||||
if (aSelected !== bSelected) return aSelected - bSelected;
|
|
||||||
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
const filteredItems = computed(() => {
|
||||||
const search = searchValue.value.toLowerCase().trim();
|
const search = searchValue.value.toLowerCase().trim();
|
||||||
if (!search) return sortedItems.value;
|
if (!search) return sortedItems.value;
|
||||||
@@ -67,23 +56,6 @@ const showNoItem = computed(() => {
|
|||||||
return props.noItemLabel.toLowerCase().includes(search);
|
return props.noItemLabel.toLowerCase().includes(search);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A single flat list for the virtualizer. The optional "no item" entry is folded in as the
|
|
||||||
// first row so the whole list (including it) is virtualized through one ComboboxVirtualizer.
|
|
||||||
type Row = { kind: 'none' } | { kind: 'item'; item: T };
|
|
||||||
|
|
||||||
const rows = computed<Row[]>(() => {
|
|
||||||
const itemRows = filteredItems.value.map((item): Row => ({ kind: 'item', item }));
|
|
||||||
return showNoItem.value ? [{ kind: 'none' }, ...itemRows] : itemRows;
|
|
||||||
});
|
|
||||||
|
|
||||||
function keyForRow(row: Row): string {
|
|
||||||
return row.kind === 'none' ? NONE_ID : props.getKeyFromItem(row.item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nameForRow(row: Row): string {
|
|
||||||
return row.kind === 'none' ? (props.noItemLabel ?? '') : props.getNameForItem(row.item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleItem(id: string) {
|
function toggleItem(id: string) {
|
||||||
if (model.value.includes(id)) {
|
if (model.value.includes(id)) {
|
||||||
model.value = model.value.filter((itemId) => itemId !== id);
|
model.value = model.value.filter((itemId) => itemId !== id);
|
||||||
@@ -102,44 +74,46 @@ const emit = defineEmits(['update:modelValue', 'changed', 'submit']);
|
|||||||
<slot name="trigger"></slot>
|
<slot name="trigger"></slot>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<!-- kept open so the list stays visible during the popover close animation -->
|
|
||||||
<ComboboxRoot
|
<ComboboxRoot
|
||||||
:open="true"
|
v-model:search-term="searchValue"
|
||||||
|
v-model:open="open"
|
||||||
class="p-2"
|
class="p-2"
|
||||||
:ignore-filter="true"
|
:filter-function="(val: string[]) => val">
|
||||||
@update:open="
|
|
||||||
(value: boolean) => {
|
|
||||||
if (!value) open = false;
|
|
||||||
}
|
|
||||||
">
|
|
||||||
<ComboboxAnchor>
|
<ComboboxAnchor>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
v-model="searchValue"
|
|
||||||
class="w-full h-8 rounded-md border border-input-border bg-input-background px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
|
class="w-full h-8 rounded-md border border-input-border bg-input-background px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
|
||||||
:placeholder="searchPlaceholder" />
|
:placeholder="searchPlaceholder" />
|
||||||
</ComboboxAnchor>
|
</ComboboxAnchor>
|
||||||
<ComboboxContent
|
<ComboboxContent
|
||||||
:dismiss-able="false"
|
:dismiss-able="false"
|
||||||
position="inline"
|
position="inline"
|
||||||
class="mt-2 min-w-60 max-w-80">
|
class="mt-2 min-w-60 max-w-80 max-h-60 overflow-y-auto">
|
||||||
<ComboboxViewport class="max-h-60 overflow-y-auto">
|
<ComboboxViewport>
|
||||||
<ComboboxVirtualizer
|
<ComboboxItem
|
||||||
v-slot="{ option }"
|
v-if="showNoItem"
|
||||||
:options="rows"
|
:value="NONE_ID"
|
||||||
:estimate-size="ROW_HEIGHT"
|
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||||
:text-content="nameForRow">
|
@select.prevent="toggleItem(NONE_ID)">
|
||||||
<ComboboxItem
|
<Checkbox
|
||||||
:value="keyForRow(option)"
|
:checked="model.includes(NONE_ID)"
|
||||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
aria-hidden="true"
|
||||||
@select.prevent="toggleItem(keyForRow(option))">
|
:tabindex="-1"
|
||||||
<Checkbox
|
class="pointer-events-none" />
|
||||||
:checked="model.includes(keyForRow(option))"
|
<span class="truncate">{{ noItemLabel }}</span>
|
||||||
aria-hidden="true"
|
</ComboboxItem>
|
||||||
:tabindex="-1"
|
<ComboboxItem
|
||||||
class="pointer-events-none" />
|
v-for="item in filteredItems"
|
||||||
<span class="truncate">{{ nameForRow(option) }}</span>
|
:key="getKeyFromItem(item)"
|
||||||
</ComboboxItem>
|
:value="getKeyFromItem(item)"
|
||||||
</ComboboxVirtualizer>
|
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active cursor-default"
|
||||||
|
@select.prevent="toggleItem(getKeyFromItem(item))">
|
||||||
|
<Checkbox
|
||||||
|
:checked="model.includes(getKeyFromItem(item))"
|
||||||
|
aria-hidden="true"
|
||||||
|
:tabindex="-1"
|
||||||
|
class="pointer-events-none" />
|
||||||
|
<span class="truncate">{{ getNameForItem(item) }}</span>
|
||||||
|
</ComboboxItem>
|
||||||
</ComboboxViewport>
|
</ComboboxViewport>
|
||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</ComboboxRoot>
|
</ComboboxRoot>
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex justify-between items-center w-full text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
class="flex justify-between items-center w-full text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||||
<div class="flex space-x-3 items-center px-3 py-1.5 min-w-0">
|
<div class="flex space-x-3 items-center px-3 py-1.5">
|
||||||
<div :style="{ backgroundColor: color }" class="w-3 h-3 rounded-full shrink-0"></div>
|
<div :style="{ backgroundColor: color }" class="w-3 h-3 rounded-full"></div>
|
||||||
<span class="truncate">{{ name }}</span>
|
<span>{{ name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<slot name="actions"></slot>
|
<slot name="actions"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,24 +37,19 @@ const model = defineModel<string[]>({
|
|||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const searchValue = ref('');
|
const searchValue = ref('');
|
||||||
// Pinned on open so rows don't re-sort while toggling; the tag list itself stays reactive.
|
const sortedTags = ref<Tag[]>([]);
|
||||||
const pinnedSelection = ref<Set<string>>(new Set());
|
|
||||||
|
|
||||||
watch(open, (isOpen) => {
|
watch(open, (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
searchValue.value = '';
|
searchValue.value = '';
|
||||||
pinnedSelection.value = new Set(model.value);
|
sortedTags.value = [...props.tags].sort((a, b) => {
|
||||||
|
const aSelected = model.value.includes(a.id) ? 0 : 1;
|
||||||
|
const bSelected = model.value.includes(b.id) ? 0 : 1;
|
||||||
|
return aSelected - bSelected;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedTags = computed(() => {
|
|
||||||
return [...props.tags].sort((a, b) => {
|
|
||||||
const aSelected = pinnedSelection.value.has(a.id) ? 0 : 1;
|
|
||||||
const bSelected = pinnedSelection.value.has(b.id) ? 0 : 1;
|
|
||||||
return aSelected - bSelected;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredTags = computed(() => {
|
const filteredTags = computed(() => {
|
||||||
const search = searchValue.value.toLowerCase().trim();
|
const search = searchValue.value.toLowerCase().trim();
|
||||||
if (!search) return sortedTags.value;
|
if (!search) return sortedTags.value;
|
||||||
|
|||||||
@@ -87,32 +87,4 @@ describe('TimeTrackerProjectTaskDropdown', () => {
|
|||||||
|
|
||||||
expect(wrapper.emitted('changed')?.at(-1)).toEqual(['', null]);
|
expect(wrapper.emitted('changed')?.at(-1)).toEqual(['', null]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a project's tasks visible when the search term matches the project name", async () => {
|
|
||||||
const project = {
|
|
||||||
id: 'p-dummy',
|
|
||||||
name: 'dummy',
|
|
||||||
color: '#fff',
|
|
||||||
client_id: null,
|
|
||||||
is_archived: false,
|
|
||||||
} as unknown as Project;
|
|
||||||
const tasks = [
|
|
||||||
{ id: 't-1', name: 'design', project_id: 'p-dummy', is_done: false },
|
|
||||||
{ id: 't-2', name: 'build', project_id: 'p-dummy', is_done: false },
|
|
||||||
] as unknown as Task[];
|
|
||||||
|
|
||||||
const wrapper = mountDropdown({ projects: [project], tasks });
|
|
||||||
await nextTick();
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
const searchInput = wrapper.find('[data-testid="client_dropdown_search"]');
|
|
||||||
await searchInput.setValue('dummy');
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
// project itself shows up
|
|
||||||
expect(wrapper.find('[data-project-id="p-dummy"]').exists()).toBe(true);
|
|
||||||
// and its tasks are still available even though they don't match "dummy":
|
|
||||||
// the task expander keeps showing all of the project's tasks
|
|
||||||
expect(wrapper.text()).toContain('2 Tasks');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { ChevronRightIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
import { ChevronRightIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
|
||||||
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { useVirtualizer } from '@tanstack/vue-virtual';
|
|
||||||
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
|
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
|
||||||
import type {
|
import type {
|
||||||
CreateClientBody,
|
CreateClientBody,
|
||||||
@@ -30,6 +29,7 @@ const project = defineModel<string | null>('project', {
|
|||||||
const searchInput = ref<HTMLInputElement | null>(null);
|
const searchInput = ref<HTMLInputElement | null>(null);
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const dropdownViewport = ref<HTMLElement | null>(null);
|
const dropdownViewport = ref<HTMLElement | null>(null);
|
||||||
|
import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component';
|
||||||
|
|
||||||
const searchValue = ref('');
|
const searchValue = ref('');
|
||||||
|
|
||||||
@@ -85,119 +85,72 @@ const filteredProjects = computed<ProjectWithTasks[]>(() => {
|
|||||||
return filteredResults.value.map((client) => client.projects).flat();
|
return filteredResults.value.map((client) => client.projects).flat();
|
||||||
});
|
});
|
||||||
|
|
||||||
type FlatRow =
|
|
||||||
| { kind: 'client'; key: string; name: string }
|
|
||||||
| { kind: 'project'; key: string; project: ProjectWithTasks }
|
|
||||||
| { kind: 'task'; key: string; task: Task };
|
|
||||||
|
|
||||||
// Flatten the grouped client → project → task tree into a single ordered list so it can be
|
|
||||||
// virtualized: only the rows currently inside the viewport are mounted, which keeps the
|
|
||||||
// dropdown responsive even with thousands of projects/tasks.
|
|
||||||
const flatRows = computed<FlatRow[]>(() => {
|
|
||||||
const rows: FlatRow[] = [];
|
|
||||||
for (const client of filteredResults.value) {
|
|
||||||
// The "No Project" group renders its project inline without a client header.
|
|
||||||
if (client.id !== 'no_project_no_client') {
|
|
||||||
rows.push({ kind: 'client', key: 'client-' + client.id, name: client.name });
|
|
||||||
}
|
|
||||||
for (const projectWithTasks of client.projects) {
|
|
||||||
rows.push({
|
|
||||||
kind: 'project',
|
|
||||||
key: 'project-' + projectWithTasks.id,
|
|
||||||
project: projectWithTasks,
|
|
||||||
});
|
|
||||||
if (projectWithTasks.expanded) {
|
|
||||||
for (const taskItem of projectWithTasks.tasks) {
|
|
||||||
rows.push({ kind: 'task', key: 'task-' + taskItem.id, task: taskItem });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
});
|
|
||||||
|
|
||||||
const ROW_HEIGHT = { client: 28, project: 36, task: 32 } as const;
|
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer(
|
|
||||||
computed(() => ({
|
|
||||||
count: flatRows.value.length,
|
|
||||||
getScrollElement: () => dropdownViewport.value,
|
|
||||||
estimateSize: (index: number) => {
|
|
||||||
const row = flatRows.value[index];
|
|
||||||
if (row?.kind === 'client') return ROW_HEIGHT.client;
|
|
||||||
if (row?.kind === 'task') return ROW_HEIGHT.task;
|
|
||||||
return ROW_HEIGHT.project;
|
|
||||||
},
|
|
||||||
getItemKey: (index: number) => flatRows.value[index]?.key ?? index,
|
|
||||||
overscan: 12,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
|
|
||||||
|
|
||||||
const visibleRows = computed(() =>
|
|
||||||
rowVirtualizer.value.getVirtualItems().map((virtualRow) => ({
|
|
||||||
virtualRow,
|
|
||||||
row: flatRows.value[virtualRow.index]!,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Lookup maps so filtering is O(projects + tasks + clients) instead of
|
|
||||||
// O(projects × (tasks + clients)). They are rebuilt only when the underlying task/client
|
|
||||||
// props change, not on every keystroke.
|
|
||||||
const tasksByProject = computed(() => {
|
|
||||||
const map = new Map<string, Task[]>();
|
|
||||||
for (const taskItem of props.tasks) {
|
|
||||||
const list = map.get(taskItem.project_id);
|
|
||||||
if (list) {
|
|
||||||
list.push(taskItem);
|
|
||||||
} else {
|
|
||||||
map.set(taskItem.project_id, [taskItem]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
const clientsById = computed(() => {
|
|
||||||
const map = new Map<string, Client>();
|
|
||||||
for (const clientItem of props.clients) {
|
|
||||||
map.set(clientItem.id, clientItem);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
function addProjectToFilterObject(
|
function addProjectToFilterObject(
|
||||||
tempFilteredClients: ClientsWithProjectsWithTasks,
|
tempFilteredClients: ClientsWithProjectsWithTasks,
|
||||||
groupIndexByKey: Map<string, number>,
|
|
||||||
project: Project,
|
project: Project,
|
||||||
filteredTasks: Task[],
|
filteredTasks: Task[],
|
||||||
expanded = false
|
expanded = false
|
||||||
) {
|
) {
|
||||||
const client = project.client_id ? clientsById.value.get(project.client_id) : undefined;
|
// check if client already exists in filter array
|
||||||
const groupKey = client ? client.id : 'no_client';
|
const projectClientIndex = tempFilteredClients.findIndex(
|
||||||
const newProject: ProjectWithTasks = { ...project, expanded, tasks: filteredTasks };
|
(client) => client.id === project.client_id
|
||||||
|
);
|
||||||
|
|
||||||
// O(1) group lookup instead of scanning the accumulating array for every project.
|
const client = props.clients.find((client) => client.id === project.client_id);
|
||||||
const existingIndex = groupIndexByKey.get(groupKey);
|
|
||||||
if (existingIndex !== undefined) {
|
|
||||||
tempFilteredClients[existingIndex]!.projects.push(newProject);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
groupIndexByKey.set(groupKey, tempFilteredClients.length);
|
if (projectClientIndex !== -1) {
|
||||||
if (client) {
|
// client already exists in filter array
|
||||||
tempFilteredClients.push({ ...client, projects: [newProject] });
|
tempFilteredClients[projectClientIndex]!.projects.push({
|
||||||
} else {
|
...project,
|
||||||
tempFilteredClients.push({
|
expanded: expanded,
|
||||||
id: 'no_client',
|
tasks: filteredTasks,
|
||||||
name: 'No Client',
|
|
||||||
color: 'var(--theme-color-icon-default)',
|
|
||||||
created_at: '',
|
|
||||||
updated_at: '',
|
|
||||||
value: '',
|
|
||||||
is_archived: false,
|
|
||||||
projects: [newProject],
|
|
||||||
});
|
});
|
||||||
|
} else if (client) {
|
||||||
|
// project has client but is not already in filter array
|
||||||
|
// client is not yet in filter array
|
||||||
|
tempFilteredClients.push({
|
||||||
|
...client,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
...project,
|
||||||
|
expanded: expanded,
|
||||||
|
tasks: filteredTasks,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// project has no client
|
||||||
|
const customNoClientId = 'no_client';
|
||||||
|
const noClientIndex = tempFilteredClients.findIndex(
|
||||||
|
(client) => client.id === customNoClientId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (noClientIndex !== -1) {
|
||||||
|
// no client group already exists in filter array
|
||||||
|
tempFilteredClients[noClientIndex]!.projects.push({
|
||||||
|
...project,
|
||||||
|
expanded: expanded,
|
||||||
|
tasks: filteredTasks,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// no client group is not yet in filter array
|
||||||
|
tempFilteredClients.push({
|
||||||
|
id: customNoClientId,
|
||||||
|
name: 'No Client',
|
||||||
|
color: 'var(--theme-color-icon-default)',
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
value: '',
|
||||||
|
is_archived: false,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
...project,
|
||||||
|
expanded: expanded,
|
||||||
|
tasks: filteredTasks,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,50 +186,39 @@ function updateFilteredResults() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchTerm = searchValue.value?.toLowerCase()?.trim() || '';
|
|
||||||
const groupIndexByKey = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const filterProject of props.projects) {
|
for (const filterProject of props.projects) {
|
||||||
const projectNameIncludesSearchTerm = filterProject.name.toLowerCase().includes(searchTerm);
|
const projectNameIncludesSearchTerm = filterProject.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||||
|
|
||||||
const clientName = filterProject.client_id
|
const clientNameIncludesSearchTerm = props.clients
|
||||||
? clientsById.value.get(filterProject.client_id)?.name
|
.find((client) => client.id === filterProject.client_id)
|
||||||
: undefined;
|
?.name.toLowerCase()
|
||||||
const clientNameIncludesSearchTerm = clientName?.toLowerCase().includes(searchTerm);
|
.includes(searchValue.value?.toLowerCase()?.trim() || '');
|
||||||
|
|
||||||
const projectTasks = tasksByProject.value.get(filterProject.id) ?? [];
|
// check if one of the project tasks
|
||||||
|
const projectTasks = props.tasks.filter((task) => {
|
||||||
// tasks that should be selectable regardless of the search term
|
return task.project_id === filterProject.id;
|
||||||
// (open tasks, plus the currently selected one even if it's done)
|
|
||||||
const availableTasks = projectTasks.filter((filterTask) => {
|
|
||||||
return !filterTask.is_done || filterTask.id === task.value;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredTasks = availableTasks.filter((filterTask) => {
|
const filteredTasks = projectTasks.filter((filterTask) => {
|
||||||
return filterTask.name.toLowerCase().includes(searchTerm);
|
return (
|
||||||
|
filterTask.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchValue.value?.toLowerCase()?.trim() || '') &&
|
||||||
|
(!filterTask.is_done || filterTask.id === task.value)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(projectNameIncludesSearchTerm || clientNameIncludesSearchTerm) &&
|
(projectNameIncludesSearchTerm || clientNameIncludesSearchTerm) &&
|
||||||
(!filterProject.is_archived || project.value === filterProject.id)
|
(!filterProject.is_archived || project.value === filterProject.id)
|
||||||
) {
|
) {
|
||||||
// search term matches project (or client) name: show all the tasks
|
// search term matches project name
|
||||||
addProjectToFilterObject(
|
addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, false);
|
||||||
tempFilteredClients,
|
|
||||||
groupIndexByKey,
|
|
||||||
filterProject,
|
|
||||||
availableTasks,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
} else if (filteredTasks.length > 0 && !filterProject.is_archived) {
|
} else if (filteredTasks.length > 0 && !filterProject.is_archived) {
|
||||||
// search term matches task name
|
// search term matches task name
|
||||||
addProjectToFilterObject(
|
addProjectToFilterObject(tempFilteredClients, filterProject, filteredTasks, true);
|
||||||
tempFilteredClients,
|
|
||||||
groupIndexByKey,
|
|
||||||
filterProject,
|
|
||||||
filteredTasks,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,18 +410,24 @@ function moveHighlightDown() {
|
|||||||
const highlightedItemId = ref<string | null>(null);
|
const highlightedItemId = ref<string | null>(null);
|
||||||
|
|
||||||
watch(highlightedItemId, () => {
|
watch(highlightedItemId, () => {
|
||||||
if (highlightedItemId.value === null) {
|
const highlightedItem = dropdownViewport.value?.querySelector(
|
||||||
return;
|
`[data-project-id="${highlightedItemId.value}"]`
|
||||||
}
|
|
||||||
// The highlighted row may be virtualized out of the DOM, so scroll by index
|
|
||||||
// through the virtualizer instead of querying for the element.
|
|
||||||
const index = flatRows.value.findIndex(
|
|
||||||
(row) =>
|
|
||||||
(row.kind === 'project' && row.project.id === highlightedItemId.value) ||
|
|
||||||
(row.kind === 'task' && row.task.id === highlightedItemId.value)
|
|
||||||
);
|
);
|
||||||
if (index !== -1) {
|
if (highlightedItem) {
|
||||||
rowVirtualizer.value.scrollToIndex(index, { align: 'auto' });
|
highlightedItem.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'nearest',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const highlightedTask = dropdownViewport.value?.querySelector(
|
||||||
|
`[data-task-id="${highlightedItemId.value}"]`
|
||||||
|
);
|
||||||
|
if (highlightedTask) {
|
||||||
|
highlightedTask.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'nearest',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -594,7 +542,7 @@ const showCreateProject = ref(false);
|
|||||||
</slot>
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div>
|
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||||
<input
|
<input
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
:value="searchValue"
|
:value="searchValue"
|
||||||
@@ -610,63 +558,62 @@ const showCreateProject = ref(false);
|
|||||||
@keydown.left.prevent="collapseProject" />
|
@keydown.left.prevent="collapseProject" />
|
||||||
<div
|
<div
|
||||||
ref="dropdownViewport"
|
ref="dropdownViewport"
|
||||||
class="w-[400px] max-w-[calc(100vw-2rem)] max-h-[350px] overflow-y-scroll relative"
|
class="min-w-[350px] max-h-[350px] overflow-y-scroll relative"
|
||||||
@mousemove="mouseEnterHighlightActivated = true">
|
@mousemove="mouseEnterHighlightActivated = true">
|
||||||
<div :style="{ height: `${totalSize}px`, width: '100%', position: 'relative' }">
|
<template v-for="client in filteredResults" :key="client.id">
|
||||||
<div
|
<div
|
||||||
v-for="{ virtualRow, row } in visibleRows"
|
v-if="client.id !== 'no_project_no_client'"
|
||||||
:key="row.key"
|
class="w-full pb-1 pt-2 px-2 text-text-tertiary text-xs font-semibold flex space-x-1 items-center">
|
||||||
class="absolute left-0 top-0 w-full"
|
<span>
|
||||||
:style="{ transform: `translateY(${virtualRow.start}px)` }">
|
{{ client.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<template
|
||||||
|
v-for="projectWithTasks in client.projects"
|
||||||
|
:key="projectWithTasks.id">
|
||||||
<div
|
<div
|
||||||
v-if="row.kind === 'client'"
|
|
||||||
class="w-full pb-1 pt-2 px-2 text-text-tertiary text-xs font-semibold flex space-x-1 items-center">
|
|
||||||
<span class="truncate">{{ row.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="row.kind === 'project'"
|
|
||||||
role="option"
|
role="option"
|
||||||
class="px-1 py-0.5 cursor-default"
|
class="px-1 py-0.5 cursor-default"
|
||||||
:value="row.project.id"
|
:value="projectWithTasks.id"
|
||||||
:data-project-id="row.project.id"
|
:data-project-id="projectWithTasks.id"
|
||||||
@click="selectProject(row.project.id)">
|
@click="selectProject(projectWithTasks.id)">
|
||||||
<div
|
<div
|
||||||
class="rounded-lg"
|
class="rounded-lg"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-card-background-active':
|
'bg-card-background-active':
|
||||||
row.project.id === highlightedItemId,
|
projectWithTasks.id === highlightedItemId,
|
||||||
}">
|
}">
|
||||||
<ProjectDropdownItem
|
<ProjectDropdownItem
|
||||||
class="hover:!bg-transparent"
|
class="hover:!bg-transparent"
|
||||||
:selected="isProjectSelected(row.project)"
|
:selected="isProjectSelected(projectWithTasks)"
|
||||||
:name="row.project.name"
|
:name="projectWithTasks.name"
|
||||||
:color="row.project.color"
|
:color="projectWithTasks.color"
|
||||||
@mouseenter="setHighlightItemId(row.project.id)">
|
@mouseenter="setHighlightItemId(projectWithTasks.id)">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<button
|
<button
|
||||||
v-if="row.project.tasks.length > 0"
|
v-if="projectWithTasks.tasks.length > 0"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="px-2 py-0.5 mr-2 relative transition items-center rounded flex space-x-0.5 text-xs shrink-0"
|
class="px-2 py-0.5 mr-2 relative transition items-center rounded flex space-x-0.5 text-xs"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-white/5 text-text-secondary':
|
'bg-white/5 text-text-secondary':
|
||||||
row.project.expanded,
|
projectWithTasks.expanded,
|
||||||
'hover:bg-white/5 hover:text-text-secondary text-text-tertiary':
|
'hover:bg-white/5 hover:text-text-secondary text-text-tertiary':
|
||||||
!row.project.expanded,
|
!projectWithTasks.expanded,
|
||||||
}"
|
}"
|
||||||
@click.prevent.stop="
|
@click.prevent.stop="
|
||||||
() => {
|
() => {
|
||||||
row.project.expanded =
|
projectWithTasks.expanded =
|
||||||
!row.project.expanded;
|
!projectWithTasks.expanded;
|
||||||
searchInput?.focus();
|
searchInput?.focus();
|
||||||
}
|
}
|
||||||
">
|
">
|
||||||
<span class="whitespace-nowrap"
|
<span
|
||||||
>{{ row.project.tasks.length }} Tasks</span
|
>{{ projectWithTasks.tasks.length }} Tasks</span
|
||||||
>
|
>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
:class="{
|
:class="{
|
||||||
'transform rotate-180':
|
'transform rotate-180':
|
||||||
row.project.expanded,
|
projectWithTasks.expanded,
|
||||||
}"
|
}"
|
||||||
class="w-4"></ChevronDownIcon>
|
class="w-4"></ChevronDownIcon>
|
||||||
</button>
|
</button>
|
||||||
@@ -674,23 +621,23 @@ const showCreateProject = ref(false);
|
|||||||
</ProjectDropdownItem>
|
</ProjectDropdownItem>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="projectWithTasks.expanded" class="bg-quaternary">
|
||||||
v-else-if="row.kind === 'task'"
|
<div
|
||||||
:data-task-id="row.task.id"
|
v-for="task in projectWithTasks.tasks"
|
||||||
class="flex items-center space-x-2 w-full px-5 py-1.5 text-start text-xs font-semibold leading-5 text-text-primary focus:outline-none transition duration-150 ease-in-out"
|
:key="task.id"
|
||||||
:class="
|
:data-task-id="task.id"
|
||||||
row.task.id === highlightedItemId
|
:class="{
|
||||||
? 'bg-card-background-active'
|
'bg-card-background-active': task.id === highlightedItemId,
|
||||||
: 'bg-quaternary'
|
}"
|
||||||
"
|
class="flex items-center space-x-2 w-full px-5 py-1.5 text-start text-xs font-semibold leading-5 text-text-primary focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
|
||||||
@click="selectTask(row.task.id)"
|
@click="selectTask(task.id)"
|
||||||
@mouseenter="setHighlightItemId(row.task.id)">
|
@mouseenter="setHighlightItemId(task.id)">
|
||||||
<MinusIcon
|
<MinusIcon class="w-3 h-3 text-text-quaternary"></MinusIcon>
|
||||||
class="w-3 h-3 text-text-quaternary shrink-0"></MinusIcon>
|
<span>{{ task.name }}</span>
|
||||||
<span class="min-w-0 truncate">{{ row.task.name }}</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="canCreateProject" class="hover:bg-card-background-active rounded-b-lg">
|
<div v-if="canCreateProject" class="hover:bg-card-background-active rounded-b-lg">
|
||||||
<button
|
<button
|
||||||
@@ -704,7 +651,7 @@ const showCreateProject = ref(false);
|
|||||||
<span>Create new Project</span>
|
<span>Create new Project</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</UseFocusTrap>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<ProjectCreateModal
|
<ProjectCreateModal
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ body {
|
|||||||
--muted-foreground: var(--color-text-tertiary);
|
--muted-foreground: var(--color-text-tertiary);
|
||||||
--accent: var(--color-bg-tertiary);
|
--accent: var(--color-bg-tertiary);
|
||||||
--accent-foreground: var(--color-text-primary);
|
--accent-foreground: var(--color-text-primary);
|
||||||
--destructive: 0 72% 60%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: var(--color-text-primary);
|
--destructive-foreground: var(--color-text-primary);
|
||||||
--border: var(--color-border-primary);
|
--border: var(--color-border-primary);
|
||||||
--input: var(--color-border-tertiary);
|
--input: var(--color-border-tertiary);
|
||||||
|
|||||||
@@ -18,13 +18,3 @@ window.getTimezoneSetting = vi.fn(() => 'UTC');
|
|||||||
window.getWeekStartSetting = vi.fn(() => 'monday');
|
window.getWeekStartSetting = vi.fn(() => 'monday');
|
||||||
window.getNumberFormat = vi.fn(() => 'point');
|
window.getNumberFormat = vi.fn(() => 'point');
|
||||||
window.getIntervalFormat = vi.fn(() => 'hours-minutes');
|
window.getIntervalFormat = vi.fn(() => 'hours-minutes');
|
||||||
|
|
||||||
// happy-dom has no layout engine, so every element reports offsetWidth/offsetHeight of 0.
|
|
||||||
// TanStack Virtual (used by the project/task dropdown) measures via those properties, so
|
|
||||||
// without a size it renders zero rows. Give elements a usable box so virtualized components
|
|
||||||
// render their rows in component tests.
|
|
||||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 400 });
|
|
||||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
|
|
||||||
configurable: true,
|
|
||||||
get: () => 350,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery, keepPreviousData } from '@tanstack/vue-query';
|
import { useQuery } from '@tanstack/vue-query';
|
||||||
import { api, type TimeEntryResponse } from '@/packages/api/src';
|
import { api, type TimeEntryResponse } from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { computed, type Ref, type ComputedRef, unref } from 'vue';
|
import { computed, type Ref, type ComputedRef, unref } from 'vue';
|
||||||
@@ -21,9 +21,6 @@ export function useTimeEntriesReportQuery(
|
|||||||
},
|
},
|
||||||
queries: { ...unref(filterParams) },
|
queries: { ...unref(filterParams) },
|
||||||
}),
|
}),
|
||||||
// Keep the previous page's data (incl. meta.total) while the next page loads, so
|
|
||||||
// pagination doesn't transiently see total=1 and clamp the page back to 1.
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
staleTime: 1000 * 30, // 30 seconds
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
"Project","Client","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
|
|
||||||
"Active Project","Big Company","Active","Public","Yes","Task 1, Task 2","0.00","100.00","","","","","","","","","0.00","0.00","100.01","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
|
|
||||||
"Archived Project","","Archived","Public","Yes","","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
|
|
||||||
|
@@ -1,2 +0,0 @@
|
|||||||
"Project","Client","Status","Visibility","Billability","Activities","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
|
|
||||||
"Project With Activities","","Active","Public","Yes","Activity A, Activity B","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","","",""
|
|
||||||
|
@@ -1,3 +0,0 @@
|
|||||||
"Project","Client","Description","Task","User","Group","Email","Tags","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
|
|
||||||
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
|
|
||||||
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
|
|
||||||
|
@@ -1,3 +0,0 @@
|
|||||||
"Project","Client","Description","Activity","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
|
|
||||||
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","No","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
|
|
||||||
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","Yes","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
|
|
||||||
|
@@ -45,7 +45,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(4, 'data');
|
$response->assertJsonCount(4, 'data');
|
||||||
$clients = Client::query()->orderBy('created_at', 'desc')->orderBy('id')->get();
|
$clients = Client::query()->orderBy('created_at', 'desc')->get();
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
$response->assertJson(fn (AssertableJson $json) => $json
|
||||||
->has('data')
|
->has('data')
|
||||||
->has('links')
|
->has('links')
|
||||||
@@ -84,12 +84,9 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
|||||||
->has('links')
|
->has('links')
|
||||||
->has('meta')
|
->has('meta')
|
||||||
->count('data', 2)
|
->count('data', 2)
|
||||||
|
->where('data.0.id', $clients->get(0)->getKey())
|
||||||
|
->where('data.1.id', $clients->get(1)->getKey())
|
||||||
);
|
);
|
||||||
// Both clients share the same created_at, so their relative order is not defined.
|
|
||||||
$this->assertEqualsCanonicalizing([
|
|
||||||
$clients->get(0)->getKey(),
|
|
||||||
$clients->get(1)->getKey(),
|
|
||||||
], $response->json('data.*.id'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void
|
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ use App\Models\ProjectMember;
|
|||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Testing\Fluent\AssertableJson;
|
use Illuminate\Testing\Fluent\AssertableJson;
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
use Mockery\MockInterface;
|
use Mockery\MockInterface;
|
||||||
@@ -83,49 +81,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$this->assertSame([$projectNewest->getKey(), $projectMiddle->getKey(), $projectOldest->getKey()], $ids);
|
$this->assertSame([$projectNewest->getKey(), $projectMiddle->getKey(), $projectOldest->getKey()], $ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_index_endpoint_pagination_returns_every_project_exactly_once_when_they_share_created_at(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'projects:view',
|
|
||||||
'projects:view:all',
|
|
||||||
]);
|
|
||||||
config(['app.pagination_per_page_default' => 15]);
|
|
||||||
|
|
||||||
// Bulk import: 300 projects that all share the exact same created_at.
|
|
||||||
$sharedCreatedAt = now()->subDay()->startOfSecond();
|
|
||||||
$rows = [];
|
|
||||||
for ($i = 0; $i < 300; $i++) {
|
|
||||||
$rows[] = [
|
|
||||||
'id' => (string) Str::uuid(),
|
|
||||||
'name' => 'Project '.$i,
|
|
||||||
'color' => '#000000',
|
|
||||||
'is_billable' => false,
|
|
||||||
'is_public' => false,
|
|
||||||
'organization_id' => $data->organization->getKey(),
|
|
||||||
'created_at' => $sharedCreatedAt,
|
|
||||||
'updated_at' => $sharedCreatedAt,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
DB::table('projects')->insert($rows);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act - walk every page like resources/js/utils/fetchAllPages.ts does.
|
|
||||||
$orgId = $data->organization->getKey();
|
|
||||||
$first = $this->getJson(route('api.v1.projects.index', [$orgId]).'?page=1');
|
|
||||||
$this->assertResponseCode($first, 200);
|
|
||||||
$lastPage = $first->json('meta.last_page');
|
|
||||||
$collected = collect($first->json('data.*.id'));
|
|
||||||
for ($page = 2; $page <= $lastPage; $page++) {
|
|
||||||
$response = $this->getJson(route('api.v1.projects.index', [$orgId]).'?page='.$page);
|
|
||||||
$this->assertResponseCode($response, 200);
|
|
||||||
$collected = $collected->concat($response->json('data.*.id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert - every project appears exactly once, none duplicated or missing.
|
|
||||||
$this->assertEqualsCanonicalizing(array_column($rows, 'id'), $collected->all(), 'Some projects were duplicated or missing across pages');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_projects(): void
|
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_projects(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(4, 'data');
|
$response->assertJsonCount(4, 'data');
|
||||||
$reports = Report::query()->orderBy('created_at', 'desc')->orderBy('id')->get();
|
$reports = Report::query()->orderBy('created_at', 'desc')->get();
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
$response->assertJson(fn (AssertableJson $json) => $json
|
||||||
->has('data')
|
->has('data')
|
||||||
->has('links')
|
->has('links')
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class TagEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(4, 'data');
|
$response->assertJsonCount(4, 'data');
|
||||||
$tags = Tag::query()->orderBy('created_at', 'desc')->orderBy('id')->get();
|
$tags = Tag::query()->orderBy('created_at', 'desc')->get();
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
$response->assertJson(fn (AssertableJson $json) => $json
|
||||||
->has('data')
|
->has('data')
|
||||||
->has('links')
|
->has('links')
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ use App\Models\User;
|
|||||||
use App\Service\TimeEntryFilter;
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -392,59 +391,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_index_endpoint_pagination_returns_every_time_entry_exactly_once_with_rounding(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'time-entries:view:own',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Bulk import: 300 time entries that all share the exact same start.
|
|
||||||
$sharedStart = Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07');
|
|
||||||
$rows = [];
|
|
||||||
for ($i = 0; $i < 300; $i++) {
|
|
||||||
$rows[] = [
|
|
||||||
'id' => (string) Str::uuid(),
|
|
||||||
'description' => 'Entry '.$i,
|
|
||||||
'start' => $sharedStart,
|
|
||||||
'end' => $sharedStart,
|
|
||||||
'billable' => false,
|
|
||||||
'is_imported' => true,
|
|
||||||
'user_id' => $data->member->user_id,
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
'organization_id' => $data->organization->getKey(),
|
|
||||||
'created_at' => $sharedStart,
|
|
||||||
'updated_at' => $sharedStart,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
DB::table('time_entries')->insert($rows);
|
|
||||||
$this->actAsOrganizationWithSubscription();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act - walk every page like the client does (limit/offset), with rounding enabled.
|
|
||||||
$orgId = $data->organization->getKey();
|
|
||||||
$limit = 15;
|
|
||||||
$collected = collect();
|
|
||||||
$offset = 0;
|
|
||||||
do {
|
|
||||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
|
||||||
$orgId,
|
|
||||||
'member_id' => $data->member->getKey(),
|
|
||||||
'rounding_type' => TimeEntryRoundingType::Nearest,
|
|
||||||
'rounding_minutes' => 6,
|
|
||||||
'limit' => $limit,
|
|
||||||
'offset' => $offset,
|
|
||||||
]));
|
|
||||||
$this->assertResponseCode($response, 200);
|
|
||||||
$ids = $response->json('data.*.id');
|
|
||||||
$collected = $collected->concat($ids);
|
|
||||||
$offset += $limit;
|
|
||||||
} while (count($ids) === $limit);
|
|
||||||
|
|
||||||
// Assert - every time entry appears exactly once, none duplicated or missing.
|
|
||||||
$this->assertEqualsCanonicalizing(array_column($rows, 'id'), $collected->all(), 'Some time entries were duplicated or missing across pages');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_index_endpoint_can_round_up(): void
|
public function test_index_endpoint_can_round_up(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Service\Import\Importers;
|
namespace Tests\Unit\Service\Import\Importers;
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Task;
|
|
||||||
use App\Service\Import\Importers\ClockifyProjectsImporter;
|
use App\Service\Import\Importers\ClockifyProjectsImporter;
|
||||||
use App\Service\Import\Importers\DefaultImporter;
|
use App\Service\Import\Importers\DefaultImporter;
|
||||||
use App\Service\Import\Importers\ImportException;
|
use App\Service\Import\Importers\ImportException;
|
||||||
@@ -52,68 +50,4 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$this->checkTestScenarioProjectsOnlyAfterImport();
|
$this->checkTestScenarioProjectsOnlyAfterImport();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_sets_archived_at_based_on_status_column(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new ClockifyProjectsImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
|
|
||||||
$this->assertNull($activeProject->archived_at);
|
|
||||||
$this->assertFalse($activeProject->is_archived);
|
|
||||||
|
|
||||||
$archivedProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Archived Project')->firstOrFail();
|
|
||||||
$this->assertNotNull($archivedProject->archived_at);
|
|
||||||
$this->assertTrue($archivedProject->is_archived);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_import_supports_renamed_tasks_column(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new ClockifyProjectsImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
// Newer Clockify exports rename the "Task" column to "Tasks".
|
|
||||||
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
|
|
||||||
$this->assertEqualsCanonicalizing(
|
|
||||||
['Task 1', 'Task 2'],
|
|
||||||
Task::query()->where('project_id', $activeProject->id)->pluck('name')->all(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_import_supports_activities_column_alias_for_tasks(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new ClockifyProjectsImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
// Some Clockify exports name the tasks column "Activities".
|
|
||||||
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_3.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project With Activities')->firstOrFail();
|
|
||||||
$this->assertEqualsCanonicalizing(
|
|
||||||
['Activity A', 'Activity B'],
|
|
||||||
Task::query()->where('project_id', $project->id)->pluck('name')->all(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,30 +41,6 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
|||||||
$this->assertSame(1, $report->clientsCreated);
|
$this->assertSame(1, $report->clientsCreated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_of_test_file_without_billable_works_and_defaults_to_non_billable(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new ClockifyTimeEntriesImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_4.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
$report = $importer->getReport();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(false, true);
|
|
||||||
$this->checkTimeEntries($testScenario, false, true);
|
|
||||||
$this->assertSame(2, $report->timeEntriesCreated);
|
|
||||||
$this->assertSame(2, $report->tagsCreated);
|
|
||||||
$this->assertSame(1, $report->tasksCreated);
|
|
||||||
$this->assertSame(1, $report->usersCreated);
|
|
||||||
$this->assertSame(2, $report->projectsCreated);
|
|
||||||
$this->assertSame(1, $report->clientsCreated);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_import_of_test_with_special_characters_description_succeeds(): void
|
public function test_import_of_test_with_special_characters_description_succeeds(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -117,25 +93,6 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
|||||||
$this->assertSame(0, $report->clientsCreated);
|
$this->assertSame(0, $report->clientsCreated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_supports_activity_column_alias_for_task(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$importer = new ClockifyTimeEntriesImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
// Some Clockify exports name the task column "Activity".
|
|
||||||
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_5.csv');
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
$report = $importer->getReport();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame(2, $report->timeEntriesCreated);
|
|
||||||
$this->assertSame(1, $report->tasksCreated);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_import_fails_if_month_in_date_is_bigger_than_12(): void
|
public function test_import_fails_if_month_in_date_is_bigger_than_12(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class ImporterTestAbstract extends TestCase
|
|||||||
/**
|
/**
|
||||||
* @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag}
|
* @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag}
|
||||||
*/
|
*/
|
||||||
protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false, bool $billableDefault = false): object
|
protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false): object
|
||||||
{
|
{
|
||||||
$users = User::all();
|
$users = User::all();
|
||||||
$this->assertCount(2, $users);
|
$this->assertCount(2, $users);
|
||||||
@@ -80,12 +80,12 @@ class ImporterTestAbstract extends TestCase
|
|||||||
$this->assertSame('#ef5350', $project1->color);
|
$this->assertSame('#ef5350', $project1->color);
|
||||||
$this->assertSame(null, $project1->billable_rate);
|
$this->assertSame(null, $project1->billable_rate);
|
||||||
// Project for Big Company
|
// Project for Big Company
|
||||||
$this->assertSame(! $billableDefault, $project2->is_billable);
|
$this->assertSame(true, $project2->is_billable);
|
||||||
$this->assertSame(false, $project2->is_public);
|
$this->assertSame(false, $project2->is_public);
|
||||||
$this->assertSame('#ec407a', $project2->color);
|
$this->assertSame('#ec407a', $project2->color);
|
||||||
$this->assertSame(10001, $project2->billable_rate);
|
$this->assertSame(10001, $project2->billable_rate);
|
||||||
// Project (Archived)
|
// Project (Archived)
|
||||||
$this->assertSame(! $billableDefault, $project3->is_billable);
|
$this->assertSame(true, $project3->is_billable);
|
||||||
$this->assertSame(true, $project3->is_public);
|
$this->assertSame(true, $project3->is_public);
|
||||||
$this->assertSame('#6a407f', $project3->color);
|
$this->assertSame('#6a407f', $project3->color);
|
||||||
$this->assertSame(null, $project3->billable_rate);
|
$this->assertSame(null, $project3->billable_rate);
|
||||||
@@ -176,7 +176,7 @@ class ImporterTestAbstract extends TestCase
|
|||||||
/**
|
/**
|
||||||
* @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario
|
* @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario
|
||||||
*/
|
*/
|
||||||
protected function checkTimeEntries(object $testScenario, bool $secondRun = false, bool $billableDefault = false): void
|
protected function checkTimeEntries(object $testScenario, bool $secondRun = false): void
|
||||||
{
|
{
|
||||||
$timeEntries = TimeEntry::all();
|
$timeEntries = TimeEntry::all();
|
||||||
if ($secondRun) {
|
if ($secondRun) {
|
||||||
@@ -197,7 +197,7 @@ class ImporterTestAbstract extends TestCase
|
|||||||
$this->assertSame('Working hard', $timeEntry2->description);
|
$this->assertSame('Working hard', $timeEntry2->description);
|
||||||
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
|
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
|
||||||
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
|
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
|
||||||
$this->assertSame(! $billableDefault, $timeEntry2->billable);
|
$this->assertTrue($timeEntry2->billable);
|
||||||
$this->assertTrue($timeEntry2->is_imported);
|
$this->assertTrue($timeEntry2->is_imported);
|
||||||
$this->assertSame([], $timeEntry2->tags);
|
$this->assertSame([], $timeEntry2->tags);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ use App\Service\Import\Importers\ImportException;
|
|||||||
use App\Service\Import\Importers\TogglDataImporter;
|
use App\Service\Import\Importers\TogglDataImporter;
|
||||||
use Exception;
|
use Exception;
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
|
||||||
use ZipArchive;
|
|
||||||
|
|
||||||
#[CoversClass(TogglDataImporter::class)]
|
#[CoversClass(TogglDataImporter::class)]
|
||||||
#[CoversClass(ImportException::class)]
|
#[CoversClass(ImportException::class)]
|
||||||
@@ -90,82 +88,6 @@ class TogglDataImporterTest extends ImporterTestAbstract
|
|||||||
$this->assertSame(0, $report->clientsCreated);
|
$this->assertSame(0, $report->clientsCreated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_with_path_traversal_in_project_id_is_rejected_without_touching_the_filesystem(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$importer = new TogglDataImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
|
|
||||||
$markerDir = sys_get_temp_dir().'/solidtime_path_traversal_'.uniqid();
|
|
||||||
$this->assertDirectoryDoesNotExist($markerDir);
|
|
||||||
// Enough "../" to reach the filesystem root from any temp location, then
|
|
||||||
// back down into the attacker-chosen marker directory. The importer
|
|
||||||
// appends ".json", so the parent directory Spatie's TemporaryDirectory
|
|
||||||
// would auto-create for the resolved path is exactly $markerDir.
|
|
||||||
$traversalId = str_repeat('../', 40).ltrim($markerDir, '/').'/probe';
|
|
||||||
$data = file_get_contents($this->buildTogglZipWithProjectId($traversalId));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
$importer->importData($data, 'Europe/Vienna');
|
|
||||||
$this->fail('Expected ImportException was not thrown');
|
|
||||||
} catch (ImportException $e) {
|
|
||||||
// Rejected by the identifier guard, not by a downstream
|
|
||||||
// "missing in ZIP" error (which would mean the sink was reached
|
|
||||||
// and the directory had already been created).
|
|
||||||
$this->assertSame('Invalid identifier in import data', $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert: no directory was created outside the import sandbox.
|
|
||||||
$this->assertDirectoryDoesNotExist($markerDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_import_with_valid_numeric_project_id_is_accepted(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$importer = new TogglDataImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
// A legitimate Toggl numeric id must still pass the guard. The
|
|
||||||
// projects_users file is intentionally absent, so the importer fails
|
|
||||||
// with the ordinary "missing in ZIP" error rather than the guard error.
|
|
||||||
$data = file_get_contents($this->buildTogglZipWithProjectId(402));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try {
|
|
||||||
$importer->importData($data, 'Europe/Vienna');
|
|
||||||
$this->fail('Expected ImportException was not thrown');
|
|
||||||
} catch (ImportException $e) {
|
|
||||||
// Assert: the numeric id passed the guard and reached the ZIP
|
|
||||||
// content check (proving valid data is not rejected).
|
|
||||||
$this->assertSame('File "projects_users/402.json" missing in ZIP', $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildTogglZipWithProjectId(mixed $projectId): string
|
|
||||||
{
|
|
||||||
$tempDir = TemporaryDirectory::make();
|
|
||||||
$zipPath = $tempDir->path('traversal.zip');
|
|
||||||
$zip = new ZipArchive;
|
|
||||||
$zip->open($zipPath, ZipArchive::CREATE);
|
|
||||||
$zip->addFromString('clients.json', '[]');
|
|
||||||
$zip->addFromString('tags.json', '[]');
|
|
||||||
$zip->addFromString('workspace_users.json', '[]');
|
|
||||||
$zip->addFromString('projects.json', (string) json_encode([[
|
|
||||||
'id' => $projectId,
|
|
||||||
'client_id' => null,
|
|
||||||
'color' => '#ff0000',
|
|
||||||
'billable' => false,
|
|
||||||
'is_private' => false,
|
|
||||||
'rate' => null,
|
|
||||||
'name' => 'Traversal',
|
|
||||||
]]));
|
|
||||||
$zip->close();
|
|
||||||
|
|
||||||
return $zipPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_import_of_user_with_unknown_timezone_will_be_mapped_to_utc(): void
|
public function test_import_of_user_with_unknown_timezone_will_be_mapped_to_utc(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Service;
|
namespace Tests\Unit\Service;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Enums\Weekday;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Models\OrganizationInvitation;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectMember;
|
use App\Models\ProjectMember;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
@@ -135,4 +137,60 @@ class UserServiceTest extends TestCase
|
|||||||
$this->assertSame(Role::Owner->value, $newMember->role);
|
$this->assertSame(Role::Owner->value, $newMember->role);
|
||||||
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());
|
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_create_passwordless_user_joins_accepted_invitation_organization_instead_of_creating_personal_one(): void
|
||||||
|
{
|
||||||
|
// Arrange — an accepted invitation exists for the email (e.g. the user
|
||||||
|
// followed the invite link, then signs up via SSO). Casing differs to
|
||||||
|
// prove the email is normalised before the invitation is matched.
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
OrganizationInvitation::factory()
|
||||||
|
->forOrganization($organization)
|
||||||
|
->role(Role::Employee)
|
||||||
|
->accepted()
|
||||||
|
->create([
|
||||||
|
'email' => 'invitee@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$user = $this->userService->createPasswordlessUser(
|
||||||
|
'Invitee',
|
||||||
|
'Invitee@Example.com',
|
||||||
|
'UTC',
|
||||||
|
Weekday::Monday,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert — invitation is materialised, no personal organization is created
|
||||||
|
$this->assertNull($user->password);
|
||||||
|
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||||
|
'email' => 'invitee@example.com',
|
||||||
|
]);
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertSame(1, $user->organizations()->count());
|
||||||
|
$this->assertSame($organization->getKey(), $user->organizations()->first()->getKey());
|
||||||
|
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
|
||||||
|
$this->assertSame(Role::Employee->value, $member->role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_passwordless_user_creates_personal_organization_when_no_invitation_exists(): void
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
$user = $this->userService->createPasswordlessUser(
|
||||||
|
'Solo User',
|
||||||
|
'solo@example.com',
|
||||||
|
'UTC',
|
||||||
|
Weekday::Monday,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert — a personal organization is created, owned by the user and set current
|
||||||
|
$user->refresh();
|
||||||
|
$this->assertNull($user->password);
|
||||||
|
$this->assertSame(1, $user->organizations()->count());
|
||||||
|
$organization = $user->organizations()->first();
|
||||||
|
$this->assertTrue($organization->personal_team);
|
||||||
|
$this->assertSame($user->getKey(), $organization->user_id);
|
||||||
|
$this->assertSame($organization->getKey(), $user->currentOrganization->getKey());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user