Compare commits

..

29 Commits

Author SHA1 Message Date
Constantin Graf
95bd15864a Fixed formatting 2026-08-31 12:07:08 +02:00
Constantin Graf
8f446306ef Test still-running email preference 2026-08-31 11:52:55 +02:00
Constantin Graf
e74af760dd Move email preference to notifications section 2026-08-31 11:43:32 +02:00
Constantin Graf
425175c43e Add still-running email preference 2026-08-31 11:14:39 +02:00
Andrew Herron
28aba7b27a Stopped downloads opening a new tab now that it's guaranteed to be an attachment. 2026-08-28 17:30:27 +02:00
Andrew Herron
453d5ed066 Set content-disposition 'attachment' for both local and S3 downloads. Added a bunch of tests. Fixes #1148. 2026-08-28 17:30:27 +02:00
Gregor Vostrak
ed3ba0135b fix undefined table-state helper in projects search e2e test 2026-08-28 13:10:36 +02:00
Gregor Vostrak
fb7d945c50 polish projects search ui, fix empty states 2026-08-27 18:50:34 +02:00
AKolenda
9958e1c09c add search input to projects overview page
Filters the project table by name client-side, reusing the existing
filteredProjects computed and the dropdown search input styling.
Search is deliberately not persisted in the table state so a reload
never leaves the table silently filtered.
2026-08-27 18:50:34 +02:00
github-actions[bot]
f00d48f1c3 Update VOUCHED list
https://github.com/solidtime-io/solidtime/issues/1217#issuecomment-5442102245
2026-08-27 16:28:04 +00:00
Constantin Graf
7edbfd5d76 Updated extension billing to 0.0.6 2026-08-27 15:22:31 +02:00
Gregor Vostrak
1edb940557 fix empty-value sorting edge cases and simplify sort components 2026-08-26 18:17:34 +02:00
Gregor Vostrak
c9330e6cb8 fix semantic task table role 2026-08-26 18:17:34 +02:00
Gregor Vostrak
0e9f652d84 refactor table sorting to composables and shared SortableTableHeaderCell 2026-08-26 18:17:34 +02:00
Niklaus Bucher
d29f5706ce Add sorting to tasks table 2026-08-26 18:17:34 +02:00
Constantin Graf
b2849ec04a Fixed code formatting 2026-08-25 13:35:32 +02:00
Constantin Graf
bd69cf478c Bump billing extension to v0.0.5 2026-08-25 13:35:32 +02:00
Constantin Graf
5197135d00 Updated service extension 2026-08-25 13:35:32 +02:00
Constantin Graf
b09b8649b8 Fixed typo in import 2026-08-25 12:32:30 +02:00
Constantin Graf
99fafdb8d6 Add database indexes 2026-08-25 12:32:30 +02:00
Gregor Vostrak
b02e49c7e0 improve modal scrolling behaviour on mobile 2026-08-24 21:46:46 +02:00
Gregor Vostrak
8609635d74 fix modal overflow on smaller viewports with dynamic viewport height 2026-08-22 17:43:33 +02:00
github-actions[bot]
5a07f798b3 Update VOUCHED list
https://github.com/solidtime-io/solidtime/discussions/1188#discussioncomment-DC_kwDOLFnGEc4BFEiq
2026-08-21 13:22:28 +00:00
Gregor Vostrak
3af69830ee move api rate limit to app config, add fallback for empty env key 2026-08-20 17:40:33 +02:00
Darwin Correa
a050153bcd feat: make API rate limits configurable via env vars (#1204)
* feat: make API rate limits configurable via env vars
2026-08-20 17:22:24 +02:00
Gregor Vostrak
9d9731c7ce add formatting support for months grouping 2026-08-20 17:11:35 +02:00
Andrew Herron
dfe3206614 Add "Week" grouping option to reporting 2026-08-20 17:11:35 +02:00
Andrew Herron
97fd882878 Format date group labels in all aggregate exports 2026-08-20 17:11:35 +02:00
Andrew Herron
111e12df12 Add "Date" grouping option to reporting 2026-08-20 17:11:35 +02:00
60 changed files with 1679 additions and 598 deletions

2
.github/VOUCHED.td vendored
View File

@@ -14,10 +14,12 @@
# Seeded 2026-07-25 from the authors of every merged pull request.
agross
akolenda
bufferhead-code
candideu
kasparrosin
korridor
nikbucher tasks table sorting
onatcer
shrootbuck
smilebeda

View File

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

View File

@@ -28,7 +28,9 @@ class ExportController extends Controller
$filepath = $exportService->export($organization);
$downloadUrl = Storage::disk(config('filesystems.private'))
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
->temporaryUrl($filepath, Carbon::now()->addMinutes(10), [
'ResponseContentDisposition' => 'attachment; filename="'.basename($filepath).'"',
]);
return new JsonResponse([
'success' => true,

View File

@@ -336,7 +336,9 @@ class TimeEntryController extends Controller
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
->temporaryUrl($path, now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
]);
}
@@ -547,7 +549,9 @@ class TimeEntryController extends Controller
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
->temporaryUrl($path, now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
]);
}

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $timezone
* @property bool $is_placeholder
* @property Weekday $week_start
* @property bool $send_time_entry_still_running_email
* @property string|null $profile_photo_path
* @property-read Organization|null $currentOrganization
* @property-read string $profile_photo_url
@@ -108,6 +109,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
'is_admin' => 'boolean',
'is_placeholder' => 'boolean',
'week_start' => Weekday::class,
'send_time_entry_still_running_email' => 'boolean',
];
/**
@@ -117,6 +119,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
*/
protected $attributes = [
'week_start' => Weekday::Monday,
'send_time_entry_still_running_email' => true,
];
/**

View File

@@ -20,6 +20,7 @@ use App\Service\BillingContract;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\IpLookup\NoIpLookupService;
use App\Service\PermissionStore;
use DateTimeInterface;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
@@ -29,8 +30,13 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AppServiceProvider extends ServiceProvider
{
@@ -98,6 +104,27 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
$this->app->bind(BillingContract::class);
// Storage
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
// so mirror it through the signed query parameters of the storage route.
$privateDisk = config('filesystems.private');
if (config('filesystems.disks.'.$privateDisk.'.driver') === 'local') {
$disk = Storage::disk($privateDisk);
$disk->serveUsing(function (Request $request, string $path, array $headers) use ($disk): StreamedResponse {
return $disk->response($path, null, $headers, $request->query('disposition', 'inline'));
});
$disk->buildTemporaryUrlsUsing(function (string $path, DateTimeInterface $expiration, array $options) use ($privateDisk): string {
$parameters = array_filter([
'path' => $path,
'disposition' => isset($options['ResponseContentDisposition'])
? Str::before($options['ResponseContentDisposition'], ';')
: null,
]);
return url(URL::temporarySignedRoute('storage.'.$privateDisk, $expiration, $parameters, absolute: false));
});
}
// Routing
Route::model('member', Member::class);
Route::model('invitation', OrganizationInvitation::class);

View File

@@ -33,8 +33,8 @@ class RouteServiceProvider extends ServiceProvider
}
return $request->user()
? Limit::perMinute(200)->by($request->user()->id)
: Limit::perMinute(60)->by($request->ip());
? Limit::perMinute(config('app.api_rate_limit_authenticated_per_minute'))->by($request->user()->id)
: Limit::perMinute(config('app.api_rate_limit_guest_per_minute'))->by($request->ip());
});
$this->routes(function (): void {

View File

@@ -155,9 +155,10 @@ class LocalizationService
}
/**
* Time group types have no server-side descriptor; Day and Week keys are ISO dates and
* Time group types have no server-side descriptor; their keys are ISO dates and are
* formatted here instead. A Week key is the first day of that week, so it renders as the
* range it covers.
* range it covers. A Year key is already a bare year, so it is returned unchanged - it must
* not be parsed, Carbon reads a four digit string as a time of day.
*/
public function formatTimeGroupKey(?string $key, TimeEntryAggregationType $groupType): ?string
{
@@ -175,6 +176,13 @@ class LocalizationService
return $this->formatDate($weekStart).' - '.$this->formatDate($weekStart->copy()->addDays(6));
}
if ($groupType === TimeEntryAggregationType::Month) {
// Note: the leading "!" resets all fields the format does not name. Without it the
// day of the month is taken from today, and a day that the parsed month does not
// have overflows the date into the next month.
return Carbon::createFromFormat('!Y-m', $key)->format('F Y');
}
return $key;
}

View File

@@ -158,6 +158,21 @@ return [
'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15),
/*
|--------------------------------------------------------------------------
| API Rate Limiting
|--------------------------------------------------------------------------
|
| The number of API requests allowed per minute, counted per user for
| authenticated requests and per IP address for guest requests. These
| limits are only enforced when the application runs in production.
|
*/
'api_rate_limit_authenticated_per_minute' => (int) (env('API_RATE_LIMIT_AUTH_PER_MINUTE') ?: 200),
'api_rate_limit_guest_per_minute' => (int) (env('API_RATE_LIMIT_GUEST_PER_MINUTE') ?: 60),
/*
|--------------------------------------------------------------------------
| Encryption Key

View File

@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use function Laravel\Prompts\info as consoleInfo;
return new class extends Migration
{
/**
* PostgreSQL cannot build or drop an index concurrently inside a transaction.
* Keeping this migration non-transactional prevents long write locks in production.
*
* @var bool
*/
public $withinTransaction = false;
/**
* Run the migrations.
*/
public function up(): void
{
// Tenant-scoped client pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'clients_organization_created_id_index', 'CREATE INDEX CONCURRENTLY clients_organization_created_id_index ON clients (organization_id, created_at DESC, id)');
// Tenant-scoped project pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'projects_organization_created_id_index', 'CREATE INDEX CONCURRENTLY projects_organization_created_id_index ON projects (organization_id, created_at DESC, id)');
// Speeds client relationship loads and the FK check when a client is deleted or its ID changes.
$this->runIndexOperation('create', 'projects_client_id_index', 'CREATE INDEX CONCURRENTLY projects_client_id_index ON projects (client_id)');
// Tenant-scoped task pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'tasks_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tasks_organization_created_id_index ON tasks (organization_id, created_at DESC, id)');
// Speeds project task lists and the FK check when a project is deleted or its ID changes.
$this->runIndexOperation('create', 'tasks_project_id_index', 'CREATE INDEX CONCURRENTLY tasks_project_id_index ON tasks (project_id)');
// Tenant-scoped tag pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'tags_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tags_organization_created_id_index ON tags (organization_id, created_at DESC, id)');
// Tenant-scoped report pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'reports_organization_created_id_index', 'CREATE INDEX CONCURRENTLY reports_organization_created_id_index ON reports (organization_id, created_at DESC, id)');
// Tenant-scoped member pagination; the existing (organization_id, user_id) unique index remains for membership lookup.
$this->runIndexOperation('create', 'members_organization_created_id_index', 'CREATE INDEX CONCURRENTLY members_organization_created_id_index ON members (organization_id, created_at DESC, id)');
// Supports reverse user-to-membership lookups and the FK check when a user is deleted or its ID changes.
$this->runIndexOperation('create', 'members_user_id_index', 'CREATE INDEX CONCURRENTLY members_user_id_index ON members (user_id)');
// The existing (project_id, user_id) unique index covers project_id, but member_id and legacy user_id need reverse indexes.
$this->runIndexOperation('create', 'project_members_member_id_index', 'CREATE INDEX CONCURRENTLY project_members_member_id_index ON project_members (member_id)');
$this->runIndexOperation('create', 'project_members_user_id_index', 'CREATE INDEX CONCURRENTLY project_members_user_id_index ON project_members (user_id)');
// Supports organization deletion/current-team cleanup and the FK check on users.current_team_id.
$this->runIndexOperation('create', 'users_current_team_id_index', 'CREATE INDEX CONCURRENTLY users_current_team_id_index ON users (current_team_id)');
// Filament loads the newest audits first; this avoids scanning and sorting the large append-only audit table.
$this->runIndexOperation('create', 'audits_created_at_index', 'CREATE INDEX CONCURRENTLY audits_created_at_index ON audits (created_at DESC)');
// Main tenant time-entry range/pagination path, including its start DESC, id ordering; also covers the organization FK.
$this->runIndexOperation('create', 'time_entries_organization_start_id_index', 'CREATE INDEX CONCURRENTLY time_entries_organization_start_id_index ON time_entries (organization_id, start DESC, id)');
// Filament lists time entries globally by creation time, so the tenant-prefixed index cannot provide this ordering.
$this->runIndexOperation('create', 'time_entries_created_at_index', 'CREATE INDEX CONCURRENTLY time_entries_created_at_index ON time_entries (created_at DESC)');
// Dashboard history is consistently filtered by user and organization, then bounded by start; user first also covers its FK.
$this->runIndexOperation('create', 'time_entries_user_organization_start_index', 'CREATE INDEX CONCURRENTLY time_entries_user_organization_start_index ON time_entries (user_id, organization_id, start)');
// Member timelines, overlap checks, and billable-rate updates start with member_id; also covers its FK.
$this->runIndexOperation('create', 'time_entries_member_start_index', 'CREATE INDEX CONCURRENTLY time_entries_member_start_index ON time_entries (member_id, start)');
// These relationship/filter indexes also prevent full scans for FK checks when parent rows change or are deleted.
$this->runIndexOperation('create', 'time_entries_project_id_index', 'CREATE INDEX CONCURRENTLY time_entries_project_id_index ON time_entries (project_id)');
$this->runIndexOperation('create', 'time_entries_task_id_index', 'CREATE INDEX CONCURRENTLY time_entries_task_id_index ON time_entries (task_id)');
$this->runIndexOperation('create', 'time_entries_client_id_index', 'CREATE INDEX CONCURRENTLY time_entries_client_id_index ON time_entries (client_id)');
// Active-timer checks touch only open entries, so a partial index stays small while serving the hot member_id lookup.
$this->runIndexOperation('create', 'time_entries_active_member_index', 'CREATE INDEX CONCURRENTLY time_entries_active_member_index ON time_entries (member_id) WHERE "end" IS NULL');
// whereJsonContains(tags, tag_id) compiles to JSONB containment, which is supported by a GIN index.
$this->runIndexOperation('create', 'time_entries_tags_gin_index', 'CREATE INDEX CONCURRENTLY time_entries_tags_gin_index ON time_entries USING GIN (tags)');
// Passport already indexes user_id; these indexes cover the other FK used during OAuth client deletion/update.
$this->runIndexOperation('create', 'oauth_access_tokens_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_access_tokens_client_id_index ON oauth_access_tokens (client_id)');
$this->runIndexOperation('create', 'oauth_auth_codes_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_auth_codes_client_id_index ON oauth_auth_codes (client_id)');
// owner_id is already the leading column of oauth_clients_owner_id_owner_type_index.
$this->runIndexOperation('drop', 'oauth_clients_user_id_index', 'DROP INDEX CONCURRENTLY oauth_clients_user_id_index');
// Public report lookup starts with the unique share_secret index; no query filters only by this boolean.
$this->runIndexOperation('drop', 'reports_is_public_index', 'DROP INDEX CONCURRENTLY reports_is_public_index');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$indexes = [
'clients_organization_created_id_index',
'projects_organization_created_id_index',
'projects_client_id_index',
'tasks_organization_created_id_index',
'tasks_project_id_index',
'tags_organization_created_id_index',
'reports_organization_created_id_index',
'members_organization_created_id_index',
'members_user_id_index',
'project_members_member_id_index',
'project_members_user_id_index',
'users_current_team_id_index',
'audits_created_at_index',
'time_entries_organization_start_id_index',
'time_entries_created_at_index',
'time_entries_user_organization_start_index',
'time_entries_member_start_index',
'time_entries_project_id_index',
'time_entries_task_id_index',
'time_entries_client_id_index',
'time_entries_active_member_index',
'time_entries_tags_gin_index',
'oauth_access_tokens_client_id_index',
'oauth_auth_codes_client_id_index',
];
$concurrently = DB::transactionLevel() === 0 ? ' CONCURRENTLY' : '';
foreach ($indexes as $index) {
$this->runIndexOperation('drop', $index, 'DROP INDEX'.$concurrently.' IF EXISTS '.$index);
}
$this->runIndexOperation('create', 'oauth_clients_user_id_index', 'CREATE INDEX'.$concurrently.' oauth_clients_user_id_index ON oauth_clients (owner_id)');
$this->runIndexOperation('create', 'reports_is_public_index', 'CREATE INDEX'.$concurrently.' reports_is_public_index ON reports (is_public)');
}
private function runIndexOperation(string $operation, string $index, string $statement): void
{
$indexState = $this->indexState($index);
if ($operation === 'create' && $indexState === ['valid' => true, 'ready' => true]) {
$this->writeProgress(sprintf('Skipping index [%s] because it already exists and is valid', $index));
return;
}
if ($operation === 'drop' && $indexState === null) {
$this->writeProgress(sprintf('Skipping index [%s] because it does not exist', $index));
return;
}
if ($operation === 'create' && $indexState !== null) {
$this->writeProgress(sprintf(
'Index [%s] exists but is incomplete (valid=%s, ready=%s); dropping it before rebuilding',
$index,
$indexState['valid'] ? 'true' : 'false',
$indexState['ready'] ? 'true' : 'false',
));
$this->executeIndexStatement(
'drop incomplete',
$index,
(DB::transactionLevel() === 0 ? 'DROP INDEX CONCURRENTLY ' : 'DROP INDEX ').$this->quoteIdentifier($index),
);
}
$this->executeIndexStatement($operation, $index, $statement);
}
private function executeIndexStatement(string $operation, string $index, string $statement): void
{
$startedAt = microtime(true);
$this->writeProgress(sprintf('Starting to %s index [%s]', $operation, $index));
try {
DB::statement($statement);
} catch (Throwable $exception) {
$this->writeProgress(sprintf(
'Failed to %s index [%s] after %.2f seconds: %s',
$operation,
$index,
microtime(true) - $startedAt,
$exception->getMessage(),
));
throw $exception;
}
$this->writeProgress(sprintf(
'Finished %s index [%s] in %.2f seconds',
$operation === 'create' ? 'creating' : 'dropping',
$index,
microtime(true) - $startedAt,
));
}
/**
* @return array{valid: bool, ready: bool}|null
*/
private function indexState(string $index): ?array
{
$state = DB::selectOne(
<<<'SQL'
SELECT pg_index.indisvalid::int AS valid, pg_index.indisready::int AS ready
FROM pg_index
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
WHERE pg_namespace.nspname = current_schema()
AND pg_class.relname = ?
SQL,
[$index],
);
if ($state === null) {
return null;
}
return [
'valid' => (bool) $state->valid,
'ready' => (bool) $state->ready,
];
}
private function quoteIdentifier(string $identifier): string
{
return DB::connection()->getQueryGrammar()->wrap($identifier);
}
private function writeProgress(string $message): void
{
if (app()->runningUnitTests()) {
return;
}
$message = sprintf('[%s] %s', date(DATE_ATOM), $message);
Log::info($message);
consoleInfo($message);
}
};

View File

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

View File

@@ -8,7 +8,7 @@ import {
createProjectViaApi,
createPublicProjectViaApi,
} from './utils/api';
import { getTableRowNames } from './utils/table';
import { clearTableState, getTableRowNames } from './utils/table';
async function goToClientsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
@@ -210,18 +210,12 @@ test('test that client context menu delete deletes the client', async ({ page, c
// Sorting Tests
// =============================================
async function clearClientTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('client-table-state');
});
}
test('test that sorting clients by name and status works', async ({ page, ctx }) => {
await createClientViaApi(ctx, { name: 'AAA SortClient' });
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
await goToClientsOverview(page);
await clearClientTableState(page);
await clearTableState(page, 'client-table-state');
await page.reload();
const table = page.getByTestId('client_table');
@@ -253,7 +247,7 @@ test('test that sorting clients by project count works', async ({ page, ctx }) =
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
await goToClientsOverview(page);
await clearClientTableState(page);
await clearTableState(page, 'client-table-state');
await page.reload();
const table = page.getByTestId('client_table');
@@ -274,7 +268,7 @@ test('test that sorting clients by project count works', async ({ page, ctx }) =
test('test that client sort state persists after page reload', async ({ page }) => {
await goToClientsOverview(page);
await clearClientTableState(page);
await clearTableState(page, 'client-table-state');
await page.reload();
const table = page.getByTestId('client_table');
@@ -397,7 +391,7 @@ test.describe('Clients Pagination', () => {
);
await goToClientsOverview(page);
await clearClientTableState(page);
await clearTableState(page, 'client-table-state');
await page.reload();
// Default sort is name asc; first 15 clients (00–14) on page 1.
@@ -450,7 +444,7 @@ test.describe('Clients Pagination', () => {
);
await goToClientsOverview(page);
await clearClientTableState(page);
await clearTableState(page, 'client-table-state');
await page.reload();
await expect(page.getByTestId('client_table')).toBeVisible();
@@ -470,7 +464,7 @@ test.describe('Clients Pagination', () => {
);
await goToClientsOverview(page);
await clearClientTableState(page);
await clearTableState(page, 'client-table-state');
await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });

View File

@@ -11,7 +11,7 @@ import {
updateMemberBillableRateViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import { getTableRowNames } from './utils/table';
import { clearTableState, getTableRowNames } from './utils/table';
// Tests that invite + accept members need more time
test.describe.configure({ timeout: 45000 });
@@ -779,20 +779,18 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
// Sorting Tests
// =============================================
// Helper to clear localStorage before tests that check sorting
async function clearMemberTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('member-table-state');
});
}
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
// Create two placeholder members with names that sort predictably around "John Doe"
// Create two placeholder members with names that sort predictably around "John Doe".
// Seeded alphabetically a second apart: created_at only has second precision and
// same-second rows fall back to a random UUID order. The spacing is what makes the
// API order (created_at desc: ZZZ, AAA, John) deterministic, so the tie-break
// assertions below are testing the tie-break rather than a coin flip.
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
await page.waitForTimeout(1100);
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
await goToMembersPage(page);
await clearMemberTableState(page);
await clearTableState(page, 'member-table-state');
await page.reload();
const table = page.getByTestId('member_table');
@@ -814,20 +812,24 @@ test('test that sorting members by name, role, and status works', async ({ page,
const ownerIdx = names.indexOf('John Doe');
const placeholderIdx = names.indexOf('AAA SortFirst');
expect(ownerIdx).toBeLessThan(placeholderIdx);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
await roleHeader.click(); // desc: Placeholder first
names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
// -- Status sorting --
const statusHeader = table.getByText('Status').first();
await statusHeader.click(); // asc: Active(0) < Inactive(1)
names = await getTableRowNames(table);
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
await statusHeader.click(); // desc: Inactive first
names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
// -- Email: just verify sort indicator appears --
const emailHeader = table.getByText('Email').first();
@@ -837,7 +839,7 @@ test('test that sorting members by name, role, and status works', async ({ page,
test('test that member sort state persists after page reload', async ({ page }) => {
await goToMembersPage(page);
await clearMemberTableState(page);
await clearTableState(page, 'member-table-state');
await page.reload();
const table = page.getByTestId('member_table');
@@ -875,7 +877,7 @@ test('test that sorting members by billable rate works', async ({ page, ctx }) =
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
await goToMembersPage(page);
await clearMemberTableState(page);
await clearTableState(page, 'member-table-state');
await page.reload();
const table = page.getByTestId('member_table');

View File

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

View File

@@ -13,18 +13,12 @@ import {
archiveProjectViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import { clearTableState, getSeededRowOrder } from './utils/table';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
}
// Helper to clear localStorage before tests that check persistence
async function clearProjectTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('project-table-state');
});
}
// Create new project via modal
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
@@ -84,7 +78,7 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx })
await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page);
await clearProjectTableState(page);
await clearTableState(page, 'project-table-state');
await page.reload();
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
@@ -480,7 +474,7 @@ test('test that sorting projects by all columns works', async ({ page, ctx }) =>
});
await goToProjectsOverview(page);
await clearProjectTableState(page);
await clearTableState(page, 'project-table-state');
await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible();
await expect(page.getByText('AAA Project')).toBeVisible();
@@ -609,7 +603,7 @@ test('test that filtering projects by status works', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page);
await clearProjectTableState(page);
await clearTableState(page, 'project-table-state');
await page.reload();
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
@@ -640,7 +634,7 @@ test('test that filtering projects by status works', async ({ page, ctx }) => {
test('test that filter state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await clearTableState(page, 'project-table-state');
await page.reload();
// Apply Active status filter
@@ -656,9 +650,96 @@ test('test that filter state persists after page reload', async ({ page }) => {
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
});
test('test that projects without a client or estimate are ordered by name at the bottom', async ({
page,
ctx,
}) => {
// Seeded a second apart: created_at only has second precision and same-second rows
// fall back to a random UUID order. The spacing makes the API order of the clientless
// rows (created_at desc: ZZZ, AAA) deterministic and different from the alphabetical
// order the name tie-break should produce.
await createProjectViaApi(ctx, { name: 'AAA Tiebreak Project' });
await page.waitForTimeout(1100);
await createProjectViaApi(ctx, { name: 'ZZZ Tiebreak Project' });
const clientAardvark = await createClientViaApi(ctx, { name: 'Aardvark Co' });
const clientZulu = await createClientViaApi(ctx, { name: 'Zulu Co' });
const projectM = await createProjectViaApi(ctx, {
name: 'MMM Tiebreak Project',
client_id: clientAardvark.id,
estimated_time: 36000, // 10h, 1h tracked below = 10%
});
await createTimeEntryViaApi(ctx, { duration: '1h', projectId: projectM.id });
const projectN = await createProjectViaApi(ctx, {
name: 'NNN Tiebreak Project',
client_id: clientZulu.id,
estimated_time: 14400, // 4h, 2h tracked below = 50%
});
await createTimeEntryViaApi(ctx, { duration: '2h', projectId: projectN.id });
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await page.reload();
const table = page.getByTestId('project_table');
await expect(table).toBeVisible();
const seeded = [
'AAA Tiebreak Project',
'MMM Tiebreak Project',
'NNN Tiebreak Project',
'ZZZ Tiebreak Project',
];
const getOrder = () => getSeededRowOrder(table, seeded);
// -- Client: empty rows last in both directions, alphabetical among themselves --
const clientHeader = table.locator('.select-none', { hasText: 'Client' }).first();
await clientHeader.click();
await expect
.poll(getOrder)
.toEqual([
'MMM Tiebreak Project',
'NNN Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
await clientHeader.click();
await expect
.poll(getOrder)
.toEqual([
'NNN Tiebreak Project',
'MMM Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
// -- Progress: same, and the first click sorts highest first --
const progressHeader = table.locator('.select-none', { hasText: 'Progress' }).first();
await progressHeader.click();
await expect
.poll(getOrder)
.toEqual([
'NNN Tiebreak Project',
'MMM Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
await progressHeader.click();
await expect
.poll(getOrder)
.toEqual([
'MMM Tiebreak Project',
'NNN Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
});
test('test that sort state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await clearTableState(page, 'project-table-state');
await page.reload();
// Click on Name header twice to sort descending
@@ -1114,7 +1195,7 @@ test.describe('Projects Pagination', () => {
);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await clearTableState(page, 'project-table-state');
await page.reload();
// Default sort is name asc; first 15 projects (00–14) should be on page 1.
@@ -1168,7 +1249,7 @@ test.describe('Projects Pagination', () => {
);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await clearTableState(page, 'project-table-state');
await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible();
@@ -1185,7 +1266,7 @@ test.describe('Projects Pagination', () => {
);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await clearTableState(page, 'project-table-state');
await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
@@ -1207,3 +1288,36 @@ test.describe('Projects Pagination', () => {
await expect(page.getByText(prefix + '00')).not.toBeVisible();
});
});
test('test that searching projects by name works', async ({ page, ctx }) => {
const suffix = Math.floor(1 + Math.random() * 10000);
const matchingProjectName = 'Searchable Project ' + suffix;
const otherProjectName = 'Unrelated Work ' + suffix;
await createProjectViaApi(ctx, { name: matchingProjectName });
await createProjectViaApi(ctx, { name: otherProjectName });
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await page.reload();
await expect(page.getByText(matchingProjectName)).toBeVisible({ timeout: 10000 });
await expect(page.getByText(otherProjectName)).toBeVisible();
const searchInput = page.getByRole('searchbox', { name: 'Search projects' });
// Searching is case insensitive and matches part of the name
await searchInput.fill('SEARCHABLE');
await expect(page.getByText(matchingProjectName)).toBeVisible();
await expect(page.getByText(otherProjectName)).not.toBeVisible();
// A term that matches nothing empties the table
await searchInput.fill('no project has this name');
await expect(page.getByText(matchingProjectName)).not.toBeVisible();
await expect(page.getByText(otherProjectName)).not.toBeVisible();
await expect(page.getByText('No matching projects')).toBeVisible();
await expect(page.getByRole('button', { name: 'Create your First Project' })).not.toBeVisible();
// Clearing the search restores both projects
await searchInput.fill('');
await expect(page.getByText(matchingProjectName)).toBeVisible();
await expect(page.getByText(otherProjectName)).toBeVisible();
});

View File

@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { createTagViaApi } from './utils/api';
import { getTableRowNames } from './utils/table';
import { clearTableState, getTableRowNames } from './utils/table';
async function goToTagsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/tags');
@@ -147,18 +147,12 @@ test('test that tag context menu delete deletes the tag', async ({ page, ctx })
// Sorting Tests
// =============================================
async function clearTagTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('tag-table-state');
});
}
test('test that sorting tags by name works', async ({ page, ctx }) => {
await createTagViaApi(ctx, { name: 'AAA SortTag' });
await createTagViaApi(ctx, { name: 'ZZZ SortTag' });
await goToTagsOverview(page);
await clearTagTableState(page);
await clearTableState(page, 'tag-table-state');
await page.reload();
const table = page.getByTestId('tag_table');
@@ -176,7 +170,7 @@ test('test that sorting tags by name works', async ({ page, ctx }) => {
test('test that tag sort state persists after page reload', async ({ page }) => {
await goToTagsOverview(page);
await clearTagTableState(page);
await clearTableState(page, 'tag-table-state');
await page.reload();
const table = page.getByTestId('tag_table');

View File

@@ -7,13 +7,56 @@ import {
createPublicProjectViaApi,
createTaskViaApi,
createClientViaApi,
createTimeEntryViaApi,
updateOrganizationSettingViaApi,
type TestContext,
} from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
}
async function createSortableTasks(ctx: TestContext) {
const project = await createProjectViaApi(ctx, { name: 'Task Sorting Project' });
const taskA = await createTaskViaApi(ctx, {
name: 'AAA Sorting Task',
project_id: project.id,
estimated_time: 36000,
});
const taskB = await createTaskViaApi(ctx, {
name: 'BBB Sorting Task',
project_id: project.id,
estimated_time: 14400,
});
const taskC = await createTaskViaApi(ctx, {
name: 'CCC Sorting Task',
project_id: project.id,
});
expect(taskA.estimated_time).toBe(36000);
expect(taskB.estimated_time).toBe(14400);
expect(taskC.estimated_time).toBeNull();
await createTimeEntryViaApi(ctx, {
duration: '1h',
projectId: project.id,
taskId: taskA.id,
});
await createTimeEntryViaApi(ctx, {
duration: '2h',
projectId: project.id,
taskId: taskB.id,
});
await createTimeEntryViaApi(ctx, {
duration: '3h',
projectId: project.id,
taskId: taskC.id,
});
return { project, taskA, taskB, taskC };
}
test('test that creating and deleting a new task in a new project works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
@@ -301,6 +344,59 @@ test('test that creating a new project from the task create modal project dropdo
await expect(page.getByTestId('task_table')).toContainText(newTaskName);
});
// =============================================
// Sorting Tests
// =============================================
test('test that sorting tasks by name, total time and progress works', async ({ page, ctx }) => {
const { project, taskA, taskB, taskC } = await createSortableTasks(ctx);
await goToProjectsOverview(page);
await clearTableState(page, 'task-table-state');
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
const table = page.getByTestId('task_table');
await expect(table).toBeVisible();
// This project contains only the seeded tasks, so assert the complete order.
const expectOrder = async (expected: string[]) => {
await expect.poll(() => getTableRowNames(table)).toEqual(expected);
};
const clickHeader = async (headerText: string) => {
await table.getByText(headerText).first().click();
};
await expectOrder([taskA.name, taskB.name, taskC.name]);
await clickHeader('Task Name');
await expectOrder([taskC.name, taskB.name, taskA.name]);
await clickHeader('Task Name');
await expectOrder([taskA.name, taskB.name, taskC.name]);
await clickHeader('Total Time');
await expectOrder([taskC.name, taskB.name, taskA.name]);
await clickHeader('Total Time');
await expectOrder([taskA.name, taskB.name, taskC.name]);
await clickHeader('Progress');
await expectOrder([taskB.name, taskA.name, taskC.name]);
await clickHeader('Progress');
await expectOrder([taskA.name, taskB.name, taskC.name]);
});
test('test that task sort state persists after page reload', async ({ page, ctx }) => {
const { project, taskA, taskB, taskC } = await createSortableTasks(ctx);
await goToProjectsOverview(page);
await clearTableState(page, 'task-table-state');
await page.goto(PLAYWRIGHT_BASE_URL + '/projects/' + project.id);
const table = page.getByTestId('task_table');
await expect(table).toBeVisible();
await table.getByText('Progress').first().click();
await expect.poll(() => getTableRowNames(table)).toEqual([taskB.name, taskA.name, taskC.name]);
await page.reload();
// Verify the persisted row order, not just the sort indicator.
await expect.poll(() => getTableRowNames(table)).toEqual([taskB.name, taskA.name, taskC.name]);
});
// =============================================
// Employee Permission Tests
// =============================================

View File

@@ -357,7 +357,7 @@ export async function createProjectWithClientViaApi(
export async function createTaskViaApi(
ctx: TestContext,
data: { name: string; project_id: string }
data: { name: string; project_id: string; estimated_time?: number }
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks`,
@@ -365,12 +365,20 @@ export async function createTaskViaApi(
data: {
name: data.name,
project_id: data.project_id,
...(data.estimated_time !== undefined
? { estimated_time: data.estimated_time }
: {}),
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; name: string; project_id: string };
return body.data as {
id: string;
name: string;
project_id: string;
estimated_time: number | null;
};
}
export async function markTaskDoneViaApi(ctx: TestContext, task: { id: string; name: string }) {

View File

@@ -1,4 +1,4 @@
import type { Locator } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
/**
* Extract the first cell's text content from each row in a table.
@@ -14,3 +14,22 @@ export async function getTableRowNames(table: Locator): Promise<string[]> {
}
return names;
}
/**
* The visual order of the given seeded names within the table, ignoring any other rows.
*/
export async function getSeededRowOrder(table: Locator, seeded: string[]): Promise<string[]> {
const rowNames = await getTableRowNames(table);
return rowNames
.map((rowName) => seeded.find((name) => rowName.includes(name)))
.filter((name): name is string => Boolean(name));
}
/**
* Drop a table's persisted sort/filter state so a test starts from the defaults.
*/
export async function clearTableState(page: Page, key: string) {
await page.evaluate((storageKey) => {
localStorage.removeItem(storageKey);
}, key);
}

View File

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

View File

@@ -11,14 +11,13 @@ import Pagination from '@/Components/Common/Pagination.vue';
import { canCreateClients } from '@/utils/permissions';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
useSortableTable,
type SortableColumnDef,
type SortDirection,
} from '@/utils/useSortableTable';
export type SortColumn = 'name' | 'projects_count' | 'status';
export type SortDirection = 'asc' | 'desc';
export type { SortDirection } from '@/utils/useSortableTable';
const props = defineProps<{
clients: Client[];
@@ -44,17 +43,7 @@ const projectCountMap = computed(() => {
return map;
});
// Name is always the secondary sort so rows with equal values render
// alphabetically instead of in API (created_at) order.
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
]);
const columns = computed(() => [
const columns = computed<SortableColumnDef<Client, SortColumn>[]>(() => [
{
id: 'name',
accessorFn: (row: Client) => row.name.toLowerCase(),
@@ -70,41 +59,22 @@ const columns = computed(() => [
},
]);
const descFirstColumns = new Set<SortColumn>(
columns.value
.filter((c) => 'sortDescFirst' in c && c.sortDescFirst)
.map((c) => c.id as SortColumn)
);
const {
sortedRows: sortedClients,
descFirstColumns,
nextDirection,
} = useSortableTable({
data: () => props.clients,
columns: () => columns.value,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
function handleSort(column: SortColumn) {
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
emit('sort', column, nextDirection(column));
}
const table = useVueTable({
get data() {
return props.clients;
},
get columns() {
return columns.value;
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedClients = computed(() => {
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);

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
const props = defineProps<{
@@ -9,57 +9,29 @@ const props = defineProps<{
descFirstColumns: ReadonlySet<SortColumn>;
}>();
const emit = defineEmits<{
defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script>
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
<SortableTableHeaderCell
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"
column="name"
v-bind="props"
@sort="$emit('sort', $event)">
Name
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('projects_count')">
</SortableTableHeaderCell>
<SortableTableHeaderCell
column="projects_count"
v-bind="props"
@sort="$emit('sort', $event)">
Projects
<ChevronDownIcon v-if="isChevronDown('projects_count')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('projects_count')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
</SortableTableHeaderCell>
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)">
Status
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
</SortableTableHeaderCell>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -3,16 +3,14 @@ import MemberTableHeading from '@/Components/Common/Member/MemberTableHeading.vu
import MemberTableRow from '@/Components/Common/Member/MemberTableRow.vue';
import { useMembersQuery } from '@/utils/useMembersQuery';
import type { Member } from '@/packages/api/src';
import { computed } from 'vue';
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
useSortableTable,
type SortableColumnDef,
type SortDirection,
} from '@/utils/useSortableTable';
export type SortColumn = 'name' | 'email' | 'role' | 'billable_rate' | 'status';
export type SortDirection = 'asc' | 'desc';
export type { SortDirection } from '@/utils/useSortableTable';
const props = defineProps<{
sortColumn: SortColumn;
@@ -33,14 +31,7 @@ const roleOrder: Record<string, number> = {
placeholder: 4,
};
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
const columns = [
const columns: SortableColumnDef<Member, SortColumn>[] = [
{
id: 'name',
accessorFn: (row: Member) => row.name.toLowerCase(),
@@ -56,7 +47,6 @@ const columns = [
{
id: 'billable_rate',
sortDescFirst: true,
sortUndefined: 'last' as const,
accessorFn: (row: Member) => {
if (row.billable_rate === null) return undefined;
return row.billable_rate;
@@ -68,36 +58,21 @@ const columns = [
},
];
const descFirstColumns = new Set<SortColumn>(
columns.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
);
const {
sortedRows: sortedMembers,
descFirstColumns,
nextDirection,
} = useSortableTable({
data: () => members.value,
columns: () => columns,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
function handleSort(column: SortColumn) {
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
emit('sort', column, nextDirection(column));
}
const table = useVueTable({
get data() {
return members.value;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedMembers = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
</script>
<template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
const props = defineProps<{
@@ -9,73 +9,35 @@ const props = defineProps<{
descFirstColumns: ReadonlySet<SortColumn>;
}>();
const emit = defineEmits<{
defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script>
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
<SortableTableHeaderCell
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"
column="name"
v-bind="props"
@sort="$emit('sort', $event)">
Name
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('email')">
</SortableTableHeaderCell>
<SortableTableHeaderCell column="email" v-bind="props" @sort="$emit('sort', $event)">
Email
<ChevronDownIcon v-if="isChevronDown('email')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('email')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('role')">
</SortableTableHeaderCell>
<SortableTableHeaderCell column="role" v-bind="props" @sort="$emit('sort', $event)">
Role
<ChevronDownIcon v-if="isChevronDown('role')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('role')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('billable_rate')">
</SortableTableHeaderCell>
<SortableTableHeaderCell
column="billable_rate"
v-bind="props"
@sort="$emit('sort', $event)">
Billable Rate
<ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
</SortableTableHeaderCell>
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)">
Status
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
</SortableTableHeaderCell>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span>
</div>

View File

@@ -7,6 +7,7 @@ import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import Pagination from '@/Components/Common/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn =
| 'name'
@@ -16,7 +17,7 @@ export type SortColumn =
| 'billable_rate'
| 'status'
| 'visibility';
export type SortDirection = 'asc' | 'desc';
export type { SortDirection } from '@/utils/useSortableTable';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects';
@@ -27,20 +28,24 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
useSortableTable,
type SortableColumnDef,
type SortDirection,
} from '@/utils/useSortableTable';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const props = defineProps<{
projects: Project[];
showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const props = withDefaults(
defineProps<{
projects: Project[];
showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
isFiltered?: boolean;
isLoading?: boolean;
}>(),
{ isFiltered: false, isLoading: false }
);
const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection];
@@ -57,28 +62,16 @@ const clientNameMap = computed(() => {
return map;
});
// Convert sort props to TanStack Table format.
// Name is always the secondary sort so rows with equal values render
// alphabetically instead of in API (created_at) order.
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []),
]);
// Define column accessors for sorting.
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
// while text columns default to ascending (A-Z) on first click (chevron down).
const columns = computed(() => [
const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
{
id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(),
},
{
id: 'client_name',
sortUndefined: 'last' as const,
accessorFn: (row: Project) => {
if (!row.client_id) return undefined;
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
@@ -87,12 +80,11 @@ const columns = computed(() => [
{
id: 'spent_time',
sortDescFirst: true,
accessorFn: (row: Project) => row.spent_time ?? 0,
accessorFn: (row: Project) => row.spent_time,
},
{
id: 'progress',
sortDescFirst: true,
sortUndefined: 'last' as const,
accessorFn: (row: Project) => {
if (!row.estimated_time) return undefined;
return (row.spent_time / row.estimated_time) * 100;
@@ -101,7 +93,7 @@ const columns = computed(() => [
{
id: 'billable_rate',
sortDescFirst: true,
accessorFn: (row: Project) => row.billable_rate ?? 0,
accessorFn: (row: Project) => row.billable_rate,
},
{
id: 'status',
@@ -113,40 +105,22 @@ const columns = computed(() => [
},
]);
// Columns with sortDescFirst get desc as default direction on first click.
const descFirstColumns = new Set<SortColumn>(
columns.value.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
);
const {
sortedRows: sortedProjects,
descFirstColumns,
nextDirection,
} = useSortableTable({
data: () => props.projects,
columns: () => columns.value,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
function handleSort(column: SortColumn) {
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
emit('sort', column, nextDirection(column));
}
const table = useVueTable({
get data() {
return props.projects;
},
get columns() {
return columns.value;
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedProjects = computed(() => {
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);
@@ -160,6 +134,28 @@ const paginatedProjects = computed(() => {
return sortedProjects.value.slice(start, start + PAGE_SIZE);
});
const emptyState = computed(() => {
if (props.isFiltered) {
return {
title: 'No matching projects',
description: 'Try a different search term or adjust your filters.',
showCreateButton: false,
};
}
if (!canCreateProjects()) {
return {
title: 'You are not a member of any projects',
description: 'Ask your manager to add you to a project as a team member.',
showCreateButton: false,
};
}
return {
title: 'No projects found',
description: 'Create your first project now!',
showCreateButton: true,
};
});
const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
@@ -193,24 +189,21 @@ const gridTemplate = computed(() => {
:sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></ProjectTableHeading>
<div v-if="sortedProjects.length === 0" class="col-span-full py-24 text-center">
<div
v-if="props.isLoading"
class="col-span-full flex justify-center items-center py-24">
<LoadingSpinner></LoadingSpinner>
</div>
<div
v-else-if="sortedProjects.length === 0"
class="col-span-full py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">
{{
canCreateProjects()
? 'No projects found'
: 'You are not a member of any projects'
}}
</h3>
<h3 class="text-text-primary font-semibold">{{ emptyState.title }}</h3>
<p class="pb-5 max-w-md mx-auto text-sm pt-1">
{{
canCreateProjects()
? 'Create your first project now!'
: 'Ask your manager to add you to a project as a team member.'
}}
{{ emptyState.description }}
</p>
<SecondaryButton
v-if="canCreateProjects()"
v-if="emptyState.showCreateButton"
:icon="PlusIcon"
@click="showCreateProjectModal = true"
>Create your First Project

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
const props = defineProps<{
@@ -14,90 +15,51 @@ const emit = defineEmits<{
sort: [column: SortColumn];
}>();
// Bound once per cell instead of repeating the three sort props on every column.
const sortState = computed(() => ({
sortColumn: props.sortColumn,
sortDirection: props.sortDirection,
descFirstColumns: props.descFirstColumns,
}));
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script>
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
<SortableTableHeaderCell
class="pr-3 pl-2 sm:pl-4 lg:pl-6"
column="name"
v-bind="sortState"
@sort="handleSort">
Name
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('client_name')">
</SortableTableHeaderCell>
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
Client
<ChevronDownIcon v-if="isChevronDown('client_name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('client_name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('spent_time')">
</SortableTableHeaderCell>
<SortableTableHeaderCell column="spent_time" v-bind="sortState" @sort="handleSort">
Total Time
<ChevronDownIcon v-if="isChevronDown('spent_time')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('spent_time')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('progress')">
</SortableTableHeaderCell>
<SortableTableHeaderCell column="progress" v-bind="sortState" @sort="handleSort">
Progress
<ChevronDownIcon v-if="isChevronDown('progress')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('progress')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
</SortableTableHeaderCell>
<SortableTableHeaderCell
v-if="showBillableRate"
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('billable_rate')">
column="billable_rate"
v-bind="sortState"
@sort="handleSort">
Billable Rate
<ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('billable_rate')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
</SortableTableHeaderCell>
<SortableTableHeaderCell column="status" v-bind="sortState" @sort="handleSort">
Status
<ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('visibility')">
</SortableTableHeaderCell>
<SortableTableHeaderCell column="visibility" v-bind="sortState" @sort="handleSort">
Visibility
<ChevronDownIcon v-if="isChevronDown('visibility')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('visibility')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
</SortableTableHeaderCell>
<div class="relative py-1.5 pl-3 pr-2 sm:pr-4 lg:pr-6">
<span class="sr-only">Edit</span>
</div>
</TableHeading>
</template>
<style scoped></style>

View File

@@ -88,13 +88,13 @@ const showEditProjectModal = ref(false);
<ContextMenuTrigger as-child>
<TableRow :href="route('projects.show', { project: project.id })">
<div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
<div
:style="{
backgroundColor: project.color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-3 h-3 rounded-full"></div>
class="w-3 h-3 ml-1 rounded-full"></div>
<span class="overflow-ellipsis overflow-hidden">
{{ project.name }}
</span>
@@ -155,7 +155,7 @@ const showEditProjectModal = ref(false);
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-2 sm:pr-4 lg:pr-6">
<ProjectMoreOptionsDropdown
:project="project"
@edit="showEditProjectModal = true"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ref } from 'vue';
import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid';
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
import {
@@ -81,23 +81,18 @@ function toggleNoClient() {
clientIds,
});
}
const hasActiveFilters = computed(() => {
return (
props.filters.status !== 'all' ||
props.filters.visibility !== 'all' ||
props.filters.clientIds.length > 0
);
});
</script>
<template>
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="xs" aria-label="Filter projects">
<ListFilterIcon
:class="[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']" />
<span v-if="!hasActiveFilters" class="text-nowrap">Filter</span>
<!-- -ml-1.5 cancels the icon's inset inside the button so it sits flush with the toolbar's left padding -->
<Button
variant="ghost"
size="icon"
class="-ml-1.5 h-7 w-7 flex-shrink-0"
aria-label="Filter projects">
<ListFilterIcon class="h-4 w-4 text-icon-default" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-56">

View File

@@ -9,7 +9,7 @@ const showExportModal = defineModel('show', { default: false });
function downloadCurrentExport() {
if (props.exportUrl) {
window.open(props.exportUrl, '_blank')?.focus();
window.open(props.exportUrl, '_self');
}
}
</script>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts" generic="TColumn extends string">
import { computed, useAttrs } from 'vue';
import type { ClassValue } from 'clsx';
import { cn } from '@/lib/utils';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortDirection } from '@/utils/useSortableTable';
defineOptions({ inheritAttrs: false });
const props = defineProps<{
column: TColumn;
sortColumn: TColumn;
sortDirection: SortDirection;
descFirstColumns: ReadonlySet<TColumn>;
}>();
const emit = defineEmits<{
sort: [column: TColumn];
}>();
const attrs = useAttrs();
const isSorted = computed(() => props.sortColumn === props.column);
const isChevronDown = computed(() => {
if (!isSorted.value) return false;
return props.descFirstColumns.has(props.column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
});
const cellClass = computed(() =>
cn(
'px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1',
attrs.class as ClassValue
)
);
</script>
<template>
<button type="button" :class="cellClass" @click="emit('sort', column)">
<slot></slot>
<span class="sr-only">
{{
isSorted
? `sorted ${sortDirection === 'asc' ? 'ascending' : 'descending'}`
: 'not sorted'
}}
</span>
<ChevronDownIcon v-if="isChevronDown" aria-hidden="true" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isSorted" aria-hidden="true" class="w-4 h-4" />
<span v-else aria-hidden="true" class="w-4 h-4"></span>
</button>
</template>

View File

@@ -2,7 +2,7 @@
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { computed, ref } from 'vue';
import { ref } from 'vue';
import { useTagsQuery } from '@/utils/useTagsQuery';
import TagTableRow from '@/Components/Common/Tag/TagTableRow.vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
@@ -10,14 +10,13 @@ import TagTableHeading from '@/Components/Common/Tag/TagTableHeading.vue';
import { canCreateTags } from '@/utils/permissions';
import type { Tag } from '@/packages/api/src';
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
useSortableTable,
type SortableColumnDef,
type SortDirection,
} from '@/utils/useSortableTable';
export type SortColumn = 'name';
export type SortDirection = 'asc' | 'desc';
export type { SortDirection } from '@/utils/useSortableTable';
const props = defineProps<{
createTag: (name: string) => Promise<Tag | undefined>;
@@ -32,50 +31,27 @@ const emit = defineEmits<{
const { tags } = useTagsQuery();
const showCreateTagModal = ref(false);
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
const columns = [
const columns: SortableColumnDef<Tag, SortColumn>[] = [
{
id: 'name',
accessorFn: (row: Tag) => row.name.toLowerCase(),
},
];
const descFirstColumns = new Set<SortColumn>(
columns.filter((c) => 'sortDescFirst' in c && c.sortDescFirst).map((c) => c.id as SortColumn)
);
const {
sortedRows: sortedTags,
descFirstColumns,
nextDirection,
} = useSortableTable({
data: () => tags.value,
columns: () => columns,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
});
function handleSort(column: SortColumn) {
if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
emit('sort', column, nextDirection(column));
}
const table = useVueTable({
get data() {
return tags.value;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedTags = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
</script>
<template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';
const props = defineProps<{
@@ -9,41 +9,20 @@ const props = defineProps<{
descFirstColumns: ReadonlySet<SortColumn>;
}>();
const emit = defineEmits<{
defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
function isChevronDown(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return props.descFirstColumns.has(column)
? props.sortDirection === 'desc'
: props.sortDirection === 'asc';
}
function isChevronUp(column: SortColumn): boolean {
if (!isSorted(column)) return false;
return !isChevronDown(column);
}
</script>
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
<SortableTableHeaderCell
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"
column="name"
v-bind="props"
@sort="$emit('sort', $event)">
Name
<ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
</SortableTableHeaderCell>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -8,12 +8,62 @@ import TaskTableHeading from '@/Components/Common/Task/TaskTableHeading.vue';
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
import { canCreateTasks } from '@/utils/permissions';
import type { Task } from '@/packages/api/src';
import {
useSortableTable,
type SortableColumnDef,
type SortDirection,
} from '@/utils/useSortableTable';
export type SortColumn = 'name' | 'spent_time' | 'progress';
export type { SortDirection } from '@/utils/useSortableTable';
const props = defineProps<{
projectId: string;
tasks: Task[];
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection];
}>();
const columns: SortableColumnDef<Task, SortColumn>[] = [
{
id: 'name',
accessorFn: (row: Task) => row.name.toLowerCase(),
},
{
id: 'spent_time',
sortDescFirst: true,
accessorFn: (row: Task) => row.spent_time,
},
{
id: 'progress',
sortDescFirst: true,
accessorFn: (row: Task) => {
if (!row.estimated_time) return undefined;
return (row.spent_time / row.estimated_time) * 100;
},
},
];
const {
sortedRows: sortedTasks,
descFirstColumns,
nextDirection,
} = useSortableTable({
data: () => props.tasks,
columns: () => columns,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
function handleSort(column: SortColumn) {
emit('sort', column, nextDirection(column));
}
const createTask = ref(false);
</script>
@@ -30,8 +80,12 @@ const createTask = ref(false);
1fr minmax(80px, auto) minmax(120px, auto) minmax(50px, auto)
80px;
">
<TaskTableHeading></TaskTableHeading>
<div v-if="tasks.length === 0" class="col-span-5 py-24 text-center">
<TaskTableHeading
:sort-column="sortColumn"
:sort-direction="sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></TaskTableHeading>
<div v-if="sortedTasks.length === 0" class="col-span-5 py-24 text-center">
<PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
<h3 class="text-text-primary font-semibold">No tasks found</h3>
<p v-if="canCreateTasks()" class="pb-5">Create your first task now!</p>
@@ -42,7 +96,7 @@ const createTask = ref(false);
>Create your First Task
</SecondaryButton>
</div>
<template v-for="task in tasks" :key="task.id">
<template v-for="task in sortedTasks" :key="task.id">
<TaskTableRow :task="task"></TaskTableRow>
</template>
</div>

View File

@@ -1,19 +1,37 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
import type { SortColumn, SortDirection } from '@/Components/Common/Task/TaskTable.vue';
const props = defineProps<{
sortColumn: SortColumn;
sortDirection: SortDirection;
descFirstColumns: ReadonlySet<SortColumn>;
}>();
defineEmits<{
sort: [column: SortColumn];
}>();
</script>
<template>
<TableHeading>
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<SortableTableHeaderCell
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"
column="name"
v-bind="props"
@sort="$emit('sort', $event)">
Task Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Total Time</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
</SortableTableHeaderCell>
<SortableTableHeaderCell column="spent_time" v-bind="props" @sort="$emit('sort', $event)">
Total Time
</SortableTableHeaderCell>
<SortableTableHeaderCell column="progress" v-bind="props" @sort="$emit('sort', $event)">
Progress
</SortableTableHeaderCell>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>
</TableHeading>
</template>
<style scoped></style>

View File

@@ -11,8 +11,8 @@ import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'
import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateClients } from '@/utils/permissions';
import { TabBar, TabBarItem } from '@/packages/ui/src';
import { useStorage } from '@vueuse/core';
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
import { useTableSortState } from '@/utils/useTableSortState';
import type { SortColumn } from '@/Components/Common/Client/ClientTable.vue';
const { clients } = useClientsQuery();
@@ -20,25 +20,10 @@ const activeTab = ref<'active' | 'archived'>('active');
const createClient = ref(false);
interface ClientTableState {
sortColumn: SortColumn;
sortDirection: SortDirection;
}
const tableState = useStorage<ClientTableState>(
'client-table-state',
{
sortColumn: 'name',
sortDirection: 'asc',
},
undefined,
{ mergeDefaults: true }
);
function handleSort(column: SortColumn, direction: SortDirection) {
tableState.value.sortColumn = column;
tableState.value.sortDirection = direction;
}
const { tableState, handleSort } = useTableSortState<SortColumn>('client-table-state', {
sortColumn: 'name',
sortDirection: 'asc',
});
const shownClients = computed(() => {
return clients.value.filter((client) => {

View File

@@ -12,8 +12,8 @@ import type { Role } from '@/types/jetstream';
import PageTitle from '@/Components/Common/PageTitle.vue';
import InvitationTable from '@/Components/Common/Invitation/InvitationTable.vue';
import { canCreateInvitations } from '@/utils/permissions';
import { useStorage } from '@vueuse/core';
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
import { useTableSortState } from '@/utils/useTableSortState';
import type { SortColumn } from '@/Components/Common/Member/MemberTable.vue';
const inviteMember = ref(false);
@@ -23,25 +23,10 @@ defineProps<{
const activeTab = ref<'all' | 'invitations'>('all');
interface MemberTableState {
sortColumn: SortColumn;
sortDirection: SortDirection;
}
const tableState = useStorage<MemberTableState>(
'member-table-state',
{
sortColumn: 'name',
sortDirection: 'asc',
},
undefined,
{ mergeDefaults: true }
);
function handleSort(column: SortColumn, direction: SortDirection) {
tableState.value.sortColumn = column;
tableState.value.sortDirection = direction;
}
const { tableState, handleSort } = useTableSortState<SortColumn>('member-table-state', {
sortColumn: 'name',
sortDirection: 'asc',
});
</script>
<template>

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import {
import { Link } from '@inertiajs/vue3';
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
import TaskTable from '@/Components/Common/Task/TaskTable.vue';
import type { SortColumn } from '@/Components/Common/Task/TaskTable.vue';
import CardTitle from '@/packages/ui/src/CardTitle.vue';
import Card from '@/Components/Common/Card.vue';
import ProjectMemberTable from '@/Components/Common/ProjectMember/ProjectMemberTable.vue';
@@ -29,6 +30,7 @@ import { formatCents } from '../packages/ui/src/utils/money';
import { getOrganizationCurrencyString } from '../utils/money';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useTableSortState } from '@/utils/useTableSortState';
const { projects } = useProjectsQuery();
@@ -65,6 +67,11 @@ const activeTab = ref<'active' | 'done'>('active');
const { tasks } = useTasksQuery();
const { tableState, handleSort } = useTableSortState<SortColumn>('task-table-state', {
sortColumn: 'name',
sortDirection: 'asc',
});
const shownTasks = computed(() => {
return tasks.value.filter((task) => {
if (activeTab.value === 'active') {
@@ -159,7 +166,12 @@ const shownTasks = computed(() => {
</template>
</CardTitle>
<Card>
<TaskTable :tasks="shownTasks" :project-id="projectId"></TaskTable>
<TaskTable
:tasks="shownTasks"
:project-id="projectId"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"
@sort="handleSort"></TaskTable>
</Card>
</div>
<div v-if="canViewProjectMembers()">

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
import { Search } from '@lucide/vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
import { computed } from 'vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { computed, ref } from 'vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useProjectsStore } from '@/utils/useProjects';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
@@ -18,6 +20,7 @@ import { getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useStorage } from '@vueuse/core';
import { useTableSortState } from '@/utils/useTableSortState';
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
import ProjectVisibilityFilterBadge from '@/Components/Common/Project/ProjectVisibilityFilterBadge.vue';
@@ -26,7 +29,7 @@ import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
// Fetch data using TanStack Query
const { projects } = useProjectsQuery();
const { projects, isLoading: projectsLoading } = useProjectsQuery();
const { clients } = useClientsQuery();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
@@ -41,7 +44,7 @@ interface ProjectTableState {
};
}
const tableState = useStorage<ProjectTableState>(
const { tableState, handleSort } = useTableSortState<SortColumn, ProjectTableState>(
'project-table-state',
{
sortColumn: 'name',
@@ -52,24 +55,28 @@ const tableState = useStorage<ProjectTableState>(
visibility: 'all',
},
},
undefined,
{
mergeDefaults: (storage, defaults) => ({
...defaults,
...storage,
filters: { ...defaults.filters, ...storage.filters },
}),
}
// The filters are merged key by key so a stored value missing a newer filter still
// picks up its default instead of the whole object falling back.
(storage, defaults) => ({
...defaults,
...storage,
filters: { ...defaults.filters, ...storage.filters },
})
);
function handleSort(column: SortColumn, direction: SortDirection) {
tableState.value.sortColumn = column;
tableState.value.sortDirection = direction;
}
// Not persisted, so a reload never starts silently filtered
const search = ref('');
// Filter projects based on current filters
const filteredProjects = computed(() => {
const searchTerm = search.value.trim().toLowerCase();
return projects.value.filter((project) => {
// Name search
if (searchTerm && !project.name.toLowerCase().includes(searchTerm)) {
return false;
}
// Status filter
if (tableState.value.filters.status === 'active' && project.is_archived) {
return false;
@@ -103,6 +110,15 @@ const filteredProjects = computed(() => {
});
});
const hasActiveFilters = computed(() => {
return (
search.value.trim() !== '' ||
tableState.value.filters.status !== 'all' ||
tableState.value.filters.visibility !== 'all' ||
tableState.value.filters.clientIds.length > 0
);
});
// Helper functions for active filters
function removeStatusFilter() {
tableState.value.filters.status = 'all';
@@ -135,8 +151,7 @@ const showBillableRate = computed(() => {
<template>
<AppLayout title="Projects" data-testid="projects_view">
<MainContainer
class="py-3 sm:pt-5 border-b border-default-background-separator flex justify-between items-center">
<MainContainer class="py-3 sm:pt-5 flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
</div>
@@ -189,11 +204,25 @@ const showBillableRate = computed(() => {
:clients="clients"
@remove="removeClientFilter"
@update:value="tableState.filters.clientIds = $event as string[]" />
<div class="relative">
<Search
class="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-icon-default" />
<TextInput
v-model="search"
size="sm"
type="search"
aria-label="Search projects"
placeholder="Search projects..."
class="w-60 border-transparent bg-transparent pl-7 shadow-none placeholder:text-text-tertiary hover:bg-black/5 focus-visible:bg-input-background dark:hover:bg-white/5 [&::-webkit-search-cancel-button]:hidden" />
</div>
</div>
</MainContainer>
<ProjectTable
:show-billable-rate="showBillableRate"
:is-filtered="hasActiveFilters"
:is-loading="projectsLoading"
:projects="filteredProjects"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"

View File

@@ -9,30 +9,15 @@ import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateTags } from '@/utils/permissions';
import { useTagsStore } from '@/utils/useTags';
import { useStorage } from '@vueuse/core';
import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';
import { useTableSortState } from '@/utils/useTableSortState';
import type { SortColumn } from '@/Components/Common/Tag/TagTable.vue';
const showCreateTagModal = ref(false);
interface TagTableState {
sortColumn: SortColumn;
sortDirection: SortDirection;
}
const tableState = useStorage<TagTableState>(
'tag-table-state',
{
sortColumn: 'name',
sortDirection: 'asc',
},
undefined,
{ mergeDefaults: true }
);
function handleSort(column: SortColumn, direction: SortDirection) {
tableState.value.sortColumn = column;
tableState.value.sortDirection = direction;
}
const { tableState, handleSort } = useTableSortState<SortColumn>('tag-table-state', {
sortColumn: 'name',
sortDirection: 'asc',
});
async function createTag(tag: string) {
return await useTagsStore().createTag(tag);

View File

@@ -198,7 +198,7 @@ const showResultModal = ref(false);
for="file-upload"
class="relative cursor-pointer rounded-md font-semibold text-text-primary focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 focus-within:ring-offset-gray-900 hover:text-indigo-500">
<span v-if="files">{{ filenames }}</span>
<span v-else>Upload a Toggl/Clockify Export</span>
<span v-else>Upload the Export file</span>
<input
id="file-upload"
ref="importFile"

View File

@@ -705,6 +705,7 @@ const UserResource = z
profile_photo_url: z.string(),
timezone: z.string(),
week_start: Weekday,
send_time_entry_still_running_email: z.boolean(),
})
.passthrough();
const UserUpdateRequest = z
@@ -714,6 +715,7 @@ const UserUpdateRequest = z
photo: z.union([z.string(), z.null()]),
timezone: z.string(),
week_start: Weekday,
send_time_entry_still_running_email: z.boolean(),
})
.partial()
.passthrough();

View File

@@ -25,25 +25,21 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
class="fixed top-0 left-0 z-50 w-screen h-screen [height:100dvh] backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
<div class="absolute inset-0 bg-default-background opacity-30" />
<div
class="absolute inset-0 overflow-y-auto overscroll-contain flex items-start justify-center px-2">
<DialogContent
v-bind="forwarded"
:class="
cn(
'my-3 md:my-14 xl:my-24 bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
props.class
)
">
<slot />
</DialogContent>
</div>
</DialogOverlay>
<div
:class="
cn(
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-14 xl:pt-24 justify-center overflow-auto'
)
">
<DialogContent
v-bind="forwarded"
:class="
cn(
'pointer-events-auto bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
props.class
)
">
<slot />
</DialogContent>
</div>
</DialogPortal>
</template>

View File

@@ -1,5 +1,10 @@
import { describe, expect, test } from 'vitest';
import { formatHumanReadableDuration, formatReportingDuration, formatWeekRange } from './time';
import {
formatHumanReadableDuration,
formatMonth,
formatReportingDuration,
formatWeekRange,
} from './time';
const seconds = 14 * 3600 + 45 * 60 + 6; // 14h 45m 06s
@@ -60,3 +65,9 @@ describe('formatWeekRange', () => {
);
});
});
describe('formatMonth', () => {
test('renders the name of the month that the key covers', () => {
expect(formatMonth('2001-02')).toBe('February 2001');
});
});

View File

@@ -245,6 +245,15 @@ export function formatWeekRange(date: string, format?: DateFormat): string {
return `${formatDate(date, format)} - ${formatDate(end, format)}`;
}
/*
* Returns the month that the given key falls in. There is no `DateFormat` variant for a
* month, so this is not affected by the organization date format.
* @param date - a month, in the format of 'YYYY-MM'
*/
export function formatMonth(date: string): string {
return getDayJsInstance()(date).format('MMMM YYYY');
}
/*
* Returns a human readable date format.
* @param date - date in the format of 'YYYY-MM-DD'

View File

@@ -76,6 +76,7 @@ export interface User {
two_factor_confirmed_at: string | null;
timezone: string;
week_start: string;
send_time_entry_still_running_email: boolean;
profile_photo_url: string;
organizations: Organization[];
clients: Client[];

View File

@@ -15,7 +15,12 @@ import {
} from '@heroicons/vue/16/solid';
import { Coffee } from '@lucide/vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { type DateFormat, formatDate, formatWeekRange } from '@/packages/ui/src/utils/time';
import {
type DateFormat,
formatDate,
formatMonth,
formatWeekRange,
} from '@/packages/ui/src/utils/time';
export type GroupingOption =
| 'project'
@@ -27,7 +32,9 @@ export type GroupingOption =
| 'tag'
| 'type'
| 'day'
| 'week';
| 'week'
| 'month'
| 'year';
export const useReportingStore = defineStore('reporting', () => {
// Cache query composables to avoid creating new subscriptions on every call
@@ -94,6 +101,10 @@ export const useReportingStore = defineStore('reporting', () => {
if (type === 'week') {
return formatWeekRange(key, dateFormat);
}
if (type === 'month') {
return formatMonth(key);
}
// A `year` key is already a bare year, so it falls through unchanged.
return key;
}

View File

@@ -0,0 +1,116 @@
import {
getCoreRowModel,
getSortedRowModel,
sortingFns,
type ColumnDef,
type Row,
type SortingFn,
type SortingState,
useVueTable,
} from '@tanstack/vue-table';
import { computed, type ComputedRef } from 'vue';
export type SortDirection = 'asc' | 'desc';
/**
* Comparator for every sortable column: empty values (`null`/`undefined`) sort last in
* both directions and compare as equal, so the tie-break orders them. TanStack's own
* `sortUndefined` cannot do this: `'last'` bypasses the tie-break and the default flips
* empty rows to the top when descending.
*/
function sortEmptyLast<TData>(getSorting: () => SortingState): SortingFn<TData> {
return (rowA: Row<TData>, rowB: Row<TData>, columnId: string) => {
const a = rowA.getValue(columnId);
const b = rowB.getValue(columnId);
if (a == null && b == null) {
return 0;
}
if (a == null || b == null) {
const emptyLast = a == null ? 1 : -1;
const desc = getSorting().find((entry) => entry.id === columnId)?.desc ?? false;
return desc ? -emptyLast : emptyLast;
}
return typeof a === 'string' || typeof b === 'string'
? sortingFns.alphanumeric(rowA, rowB, columnId)
: sortingFns.basic(rowA, rowB, columnId);
};
}
/**
* Requires `id` to be one of the table's sortable columns, so a mistyped id is a compile
* error instead of a column that silently stops sorting. `sortingFn` is forbidden
* because the composable always installs its own comparator.
*/
export type SortableColumnDef<TData, TColumn extends string> = ColumnDef<TData, unknown> & {
id: TColumn;
sortingFn?: never;
};
export function useSortableTable<TData, TColumn extends string>(options: {
data: () => TData[];
columns: () => SortableColumnDef<TData, TColumn>[];
sortColumn: () => TColumn;
sortDirection: () => SortDirection;
tieBreakColumn?: TColumn;
}): {
sortedRows: ComputedRef<TData[]>;
descFirstColumns: ComputedRef<ReadonlySet<TColumn>>;
nextDirection: (column: TColumn) => SortDirection;
} {
const sorting = computed<SortingState>(() => [
{
id: options.sortColumn(),
desc: options.sortDirection() === 'desc',
},
...(options.tieBreakColumn && options.sortColumn() !== options.tieBreakColumn
? [{ id: options.tieBreakColumn, desc: false }]
: []),
]);
const resolvedColumns = computed<ColumnDef<TData, unknown>[]>(() =>
options.columns().map((column) => ({
...column,
sortUndefined: false as const,
sortingFn: sortEmptyLast<TData>(() => sorting.value),
}))
);
const descFirstColumns = computed<ReadonlySet<TColumn>>(
() =>
new Set(
options
.columns()
.filter((column) => column.sortDescFirst)
.map((column) => column.id)
)
);
function nextDirection(column: TColumn): SortDirection {
if (options.sortColumn() === column) {
return options.sortDirection() === 'asc' ? 'desc' : 'asc';
}
return descFirstColumns.value.has(column) ? 'desc' : 'asc';
}
const table = useVueTable({
get data() {
return options.data();
},
get columns() {
return resolvedColumns.value;
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedRows = computed(() => table.getRowModel().rows.map((row) => row.original));
return { sortedRows, descFirstColumns, nextDirection };
}

View File

@@ -0,0 +1,32 @@
import { useStorage } from '@vueuse/core';
import type { SortDirection } from '@/utils/useSortableTable';
export interface TableSortState<TColumn extends string> {
sortColumn: TColumn;
sortDirection: SortDirection;
}
/**
* The sort a table page remembers between visits, persisted in localStorage.
*
* Pages that persist more than the sort (filters, for example) pass the wider defaults
* and get them back on `tableState`; `mergeDefaults` is forwarded to `useStorage` so
* nested state can be merged key by key rather than wholesale.
*/
export function useTableSortState<
TColumn extends string,
TState extends TableSortState<TColumn> = TableSortState<TColumn>,
>(
key: string,
defaults: TState,
mergeDefaults: boolean | ((storageValue: TState, defaults: TState) => TState) = true
) {
const tableState = useStorage<TState>(key, defaults, undefined, { mergeDefaults });
function handleSort(column: TColumn, direction: SortDirection) {
tableState.value.sortColumn = column;
tableState.value.sortDirection = direction;
}
return { tableState, handleSort };
}

View File

@@ -159,4 +159,26 @@ class TimeEntrySendStillRunningMailsCommandTest extends TestCaseWithDatabase
$this->assertSame("Sending still running time entry emails...\n".
"Finished sending 0 still running time entry emails...\n", $output);
}
public function test_does_not_send_emails_to_users_who_disabled_them(): void
{
// Arrange
$user = $this->createUserWithPermission();
$user->user->send_time_entry_still_running_email = false;
$user->user->save();
$timeEntry = TimeEntry::factory()->forMember($user->member)->create([
'start' => Carbon::now()->subHours(8)->subSecond(),
'end' => null,
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails');
// Assert
Mail::assertNothingOutgoing();
$this->assertNull($timeEntry->fresh()->still_active_email_sent_at);
$this->assertSame(Command::SUCCESS, $exitCode);
$this->assertSame("Sending still running time entry emails...\n".
"Finished sending 0 still running time entry emails...\n", Artisan::output());
}
}

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use Closure;
use DateTimeInterface;
use Illuminate\Support\Facades\Storage;
use Illuminate\Testing\TestResponse;
use Tests\TestCaseWithDatabase;
@@ -16,4 +19,26 @@ class ApiEndpointTestAbstract extends TestCaseWithDatabase
}
$response->assertStatus($statusCode);
}
/**
* Replaces the temporary URL builder of the private disk to capture the options
* passed to temporaryUrl. Returns a closure that yields the captured options.
*
* @return Closure(): (array<string, mixed>|null)
*/
protected function captureTemporaryUrlOptions(): Closure
{
$captured = null;
Storage::disk(config('filesystems.private'))->buildTemporaryUrlsUsing(
function (string $path, DateTimeInterface $expiration, array $options) use (&$captured): string {
$captured = $options;
return 'https://storage.fake/'.$path;
}
);
return function () use (&$captured): ?array {
return $captured;
};
}
}

View File

@@ -90,4 +90,35 @@ class ExportEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('success', true);
$this->assertStringContainsString($filepath, $response->json('download_url'));
}
public function test_export_requests_the_download_url_as_an_attachment(): void
{
// Arrange
$user = $this->createUserWithPermission([
'export',
]);
$filepath = 'exports/export_test.zip';
$this->mock(ExportService::class, function (MockInterface $mock) use (&$user, $filepath): void {
$mock->shouldReceive('export')
->withArgs(function (Organization $organization) use (&$user): bool {
return $organization->is($user->organization);
})
->andReturn($filepath)
->once();
});
Passport::actingAs($user->user);
$capturedOptions = $this->captureTemporaryUrlOptions();
// Act
$response = $this->postJson(route('api.v1.export.export', [
'organization' => $user->organization->getKey(),
]));
// Assert
$response->assertStatus(200);
$options = $capturedOptions();
$this->assertIsArray($options);
$this->assertSame('attachment; filename="export_test.zip"', $options['ResponseContentDisposition'] ?? null);
$this->assertSame('https://storage.fake/'.$filepath, $response->json('download_url'));
}
}

View File

@@ -947,6 +947,35 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200);
}
public function test_index_export_endpoint_requests_the_download_url_as_an_attachment(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
$capturedOptions = $this->captureTemporaryUrlOptions();
// Act
$response = $this->getJson(route('api.v1.time-entries.index-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$options = $capturedOptions();
$this->assertIsArray($options);
$this->assertMatchesRegularExpression(
'/^attachment; filename="time-entries-export-.+\.csv"$/',
$options['ResponseContentDisposition'] ?? ''
);
$this->assertStringStartsWith('https://storage.fake/exports/', $response->json('download_url'));
}
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods(): void
{
// Arrange
@@ -1321,6 +1350,38 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoint_requests_the_download_url_as_an_attachment(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
$capturedOptions = $this->captureTemporaryUrlOptions();
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'group' => TimeEntryAggregationType::Client,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Month,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$options = $capturedOptions();
$this->assertIsArray($options);
$this->assertMatchesRegularExpression(
'/^attachment; filename="time-entries-report-.+\.csv"$/',
$options['ResponseContentDisposition'] ?? ''
);
$this->assertStringStartsWith('https://storage.fake/exports/', $response->json('download_url'));
}
public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_with_show_billable_rate(): void
{
// Arrange

View File

@@ -45,6 +45,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
'profile_photo_url' => $data->user->profile_photo_url,
'timezone' => $data->user->timezone,
'week_start' => $data->user->week_start->value,
'send_time_entry_still_running_email' => true,
],
]);
}
@@ -131,7 +132,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonValidationErrors('organization_id');
}
public function test_update_changes_user_name_timezone_and_week_start(): void
public function test_update_changes_user_profile_settings(): void
{
// Arrange
$data = $this->createUserWithPermission();
@@ -142,6 +143,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
'name' => 'Updated Name',
'timezone' => 'America/New_York',
'week_start' => Weekday::Sunday->value,
'send_time_entry_still_running_email' => false,
]);
// Assert
@@ -152,6 +154,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
'name' => 'Updated Name',
'timezone' => 'America/New_York',
'week_start' => Weekday::Sunday->value,
'send_time_entry_still_running_email' => false,
],
]);
@@ -159,6 +162,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$this->assertSame('Updated Name', $user->name);
$this->assertSame('America/New_York', $user->timezone);
$this->assertSame(Weekday::Sunday, $user->week_start);
$this->assertFalse($user->send_time_entry_still_running_email);
}
public function test_update_does_not_change_user_fields_that_are_not_given(): void
@@ -503,6 +507,22 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonValidationErrors(['week_start']);
}
public function test_update_fails_if_send_time_entry_still_running_email_is_not_boolean(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'send_time_entry_still_running_email' => 'not-a-boolean',
]);
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['send_time_entry_still_running_email']);
}
public function test_update_fails_if_photo_is_not_a_string(): void
{
// Arrange

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Web;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
/**
* Tests the attachment downloads of the private disk via Laravel's storage.{disk} route,
* configured with serveUsing/buildTemporaryUrlsUsing in AppServiceProvider::boot.
* These tests use the real private disk, because Storage::fake would replace the disk
* instance and thereby remove that configuration.
*/
class FileDownloadEndpointTest extends EndpointTestAbstract
{
private function privateDisk(): Filesystem
{
return Storage::disk(config('filesystems.private'));
}
public function test_temporary_url_with_attachment_disposition_serves_file_as_attachment(): void
{
// Arrange
$disk = $this->privateDisk();
$disk->put('exports/test-attachment.csv', 'Description,Duration');
$url = $disk->temporaryUrl('exports/test-attachment.csv', now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="test-attachment.csv"',
]);
// Act
$response = $this->get($url);
// Assert
$response->assertOk();
$response->assertDownload('test-attachment.csv');
$disk->delete('exports/test-attachment.csv');
}
public function test_temporary_url_without_options_serves_file_inline(): void
{
// Arrange
$disk = $this->privateDisk();
$disk->put('exports/test-inline.csv', 'Description,Duration');
$url = $disk->temporaryUrl('exports/test-inline.csv', now()->addMinutes(5));
// Act
$response = $this->get($url);
// Assert
$response->assertOk();
$this->assertStringStartsWith('inline', (string) $response->headers->get('Content-Disposition'));
$disk->delete('exports/test-inline.csv');
}
public function test_download_fails_with_tampered_disposition(): void
{
// Arrange
$url = $this->privateDisk()->temporaryUrl('exports/test-tampered.csv', now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="test-tampered.csv"',
]);
// Act
$response = $this->get(str_replace('attachment', 'inline', $url));
// Assert
$response->assertForbidden();
}
public function test_download_fails_with_expired_signature(): void
{
// Arrange
$url = $this->privateDisk()->temporaryUrl('exports/test-expired.csv', now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="test-expired.csv"',
]);
$this->travel(6)->minutes();
// Act
$response = $this->get($url);
// Assert
$response->assertForbidden();
}
public function test_download_fails_if_file_does_not_exist(): void
{
// Arrange
$url = $this->privateDisk()->temporaryUrl('exports/test-missing.csv', now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="test-missing.csv"',
]);
// Act
$response = $this->get($url);
// Assert
$response->assertNotFound();
}
public function test_temporary_url_of_private_local_disk_points_to_storage_route_with_signed_disposition(): void
{
// Act
$url = $this->privateDisk()->temporaryUrl('exports/test-export.pdf', now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="test-export.pdf"',
]);
// Assert
$this->assertStringContainsString('/storage/exports/test-export.pdf', $url);
$this->assertStringContainsString('disposition=attachment', $url);
$this->assertStringContainsString('signature=', $url);
}
public function test_temporary_url_of_s3_disk_includes_content_disposition_from_options(): void
{
// Arrange
config([
'filesystems.disks.s3.key' => 'test-key',
'filesystems.disks.s3.secret' => 'test-secret',
'filesystems.disks.s3.region' => 'us-east-1',
'filesystems.disks.s3.bucket' => 'test-bucket',
]);
// Act
$url = Storage::disk('s3')->temporaryUrl('exports/test-export.pdf', now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="test-export.pdf"',
]);
// Assert
$query = [];
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
$this->assertSame('attachment; filename="test-export.pdf"', $query['response-content-disposition'] ?? null);
$this->assertArrayHasKey('X-Amz-Signature', $query);
}
}

View File

@@ -364,14 +364,27 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$this->assertSame('29/12/2025 - 04/01/2026', $formatted);
}
public function test_format_time_group_key_does_not_change_month_year_and_entity_group_types(): void
public function test_format_time_group_key_formats_a_month_key_and_does_not_change_year_and_entity_group_types(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act & Assert
$this->assertSame('2001-02', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month));
$this->assertSame('February 2001', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month));
$this->assertSame('2001', $this->localizationService->formatTimeGroupKey('2001', TimeEntryAggregationType::Year));
$this->assertSame('some-uuid', $this->localizationService->formatTimeGroupKey('some-uuid', TimeEntryAggregationType::Project));
}
public function test_format_time_group_key_formats_a_month_key_independently_of_the_current_day_of_month(): void
{
// Arrange
// Note: the current day of the month must not leak into the parsed month. The 31st does
// not exist in April, so a leaked day overflows the date into the following month.
$this->travelTo(Carbon::create(2026, 8, 31, 12, 0, 0, 'UTC'));
// Act & Assert
$this->assertSame('February 2001', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month));
$this->assertSame('April 2026', $this->localizationService->formatTimeGroupKey('2026-04', TimeEntryAggregationType::Month));
$this->assertSame('December 2026', $this->localizationService->formatTimeGroupKey('2026-12', TimeEntryAggregationType::Month));
}
}