Compare commits

..

4 Commits

Author SHA1 Message Date
Gregor Vostrak
2c540be236 add formatting support for months grouping 2026-08-20 16:41:42 +02:00
Andrew Herron
3bddd49364 Add "Week" grouping option to reporting 2026-08-19 16:28:59 +10:00
Andrew Herron
ab5dbfef36 Format date group labels in all aggregate exports 2026-07-30 11:56:27 +00:00
Andrew Herron
2eb9ae699c Add "Date" grouping option to reporting 2026-07-30 10:49:45 +00:00
32 changed files with 544 additions and 981 deletions

2
.github/VOUCHED.td vendored
View File

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

View File

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

View File

@@ -158,21 +158,6 @@ return [
'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15), '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 | Encryption Key

View File

@@ -1,230 +0,0 @@
<?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

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

View File

@@ -11,7 +11,7 @@ import {
updateMemberBillableRateViaApi, updateMemberBillableRateViaApi,
updateOrganizationSettingViaApi, updateOrganizationSettingViaApi,
} from './utils/api'; } from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table'; import { getTableRowNames } from './utils/table';
// Tests that invite + accept members need more time // Tests that invite + accept members need more time
test.describe.configure({ timeout: 45000 }); test.describe.configure({ timeout: 45000 });
@@ -779,18 +779,20 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
// Sorting Tests // 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 }) => { 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 createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
await page.waitForTimeout(1100);
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast'); await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
await goToMembersPage(page); await goToMembersPage(page);
await clearTableState(page, 'member-table-state'); await clearMemberTableState(page);
await page.reload(); await page.reload();
const table = page.getByTestId('member_table'); const table = page.getByTestId('member_table');
@@ -812,24 +814,20 @@ test('test that sorting members by name, role, and status works', async ({ page,
const ownerIdx = names.indexOf('John Doe'); const ownerIdx = names.indexOf('John Doe');
const placeholderIdx = names.indexOf('AAA SortFirst'); const placeholderIdx = names.indexOf('AAA SortFirst');
expect(ownerIdx).toBeLessThan(placeholderIdx); expect(ownerIdx).toBeLessThan(placeholderIdx);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
await roleHeader.click(); // desc: Placeholder first await roleHeader.click(); // desc: Placeholder first
names = await getTableRowNames(table); names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe')); expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
// -- Status sorting -- // -- Status sorting --
const statusHeader = table.getByText('Status').first(); const statusHeader = table.getByText('Status').first();
await statusHeader.click(); // asc: Active(0) < Inactive(1) await statusHeader.click(); // asc: Active(0) < Inactive(1)
names = await getTableRowNames(table); names = await getTableRowNames(table);
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst')); 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 await statusHeader.click(); // desc: Inactive first
names = await getTableRowNames(table); names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe')); 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 -- // -- Email: just verify sort indicator appears --
const emailHeader = table.getByText('Email').first(); const emailHeader = table.getByText('Email').first();
@@ -839,7 +837,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 }) => { test('test that member sort state persists after page reload', async ({ page }) => {
await goToMembersPage(page); await goToMembersPage(page);
await clearTableState(page, 'member-table-state'); await clearMemberTableState(page);
await page.reload(); await page.reload();
const table = page.getByTestId('member_table'); const table = page.getByTestId('member_table');
@@ -877,7 +875,7 @@ test('test that sorting members by billable rate works', async ({ page, ctx }) =
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000); await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
await goToMembersPage(page); await goToMembersPage(page);
await clearTableState(page, 'member-table-state'); await clearMemberTableState(page);
await page.reload(); await page.reload();
const table = page.getByTestId('member_table'); const table = page.getByTestId('member_table');

View File

@@ -13,12 +13,18 @@ import {
archiveProjectViaApi, archiveProjectViaApi,
updateOrganizationSettingViaApi, updateOrganizationSettingViaApi,
} from './utils/api'; } from './utils/api';
import { clearTableState, getSeededRowOrder } from './utils/table';
async function goToProjectsOverview(page: Page) { async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); 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 // Create new project via modal
test('test that creating and deleting a new project via the modal works', async ({ page }) => { 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); const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
@@ -78,7 +84,7 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx })
await createProjectViaApi(ctx, { name: newProjectName }); await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
@@ -474,7 +480,7 @@ test('test that sorting projects by all columns works', async ({ page, ctx }) =>
}); });
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible(); await expect(page.getByTestId('project_table')).toBeVisible();
await expect(page.getByText('AAA Project')).toBeVisible(); await expect(page.getByText('AAA Project')).toBeVisible();
@@ -603,7 +609,7 @@ test('test that filtering projects by status works', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: newProjectName }); await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
@@ -634,7 +640,7 @@ test('test that filtering projects by status works', async ({ page, ctx }) => {
test('test that filter state persists after page reload', async ({ page }) => { test('test that filter state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
// Apply Active status filter // Apply Active status filter
@@ -650,96 +656,9 @@ test('test that filter state persists after page reload', async ({ page }) => {
await expect(page.getByTestId('status-filter-badge')).toBeVisible(); 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 }) => { test('test that sort state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
// Click on Name header twice to sort descending // Click on Name header twice to sort descending
@@ -1195,7 +1114,7 @@ test.describe('Projects Pagination', () => {
); );
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
// Default sort is name asc; first 15 projects (00–14) should be on page 1. // Default sort is name asc; first 15 projects (00–14) should be on page 1.
@@ -1249,7 +1168,7 @@ test.describe('Projects Pagination', () => {
); );
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible(); await expect(page.getByTestId('project_table')).toBeVisible();
@@ -1266,7 +1185,7 @@ test.describe('Projects Pagination', () => {
); );
await goToProjectsOverview(page); await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state'); await clearProjectTableState(page);
await page.reload(); await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 }); await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });

View File

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

View File

@@ -7,56 +7,13 @@ import {
createPublicProjectViaApi, createPublicProjectViaApi,
createTaskViaApi, createTaskViaApi,
createClientViaApi, createClientViaApi,
createTimeEntryViaApi,
updateOrganizationSettingViaApi, updateOrganizationSettingViaApi,
type TestContext,
} from './utils/api'; } from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table';
async function goToProjectsOverview(page: Page) { async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); 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 }) => { 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); const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page); await goToProjectsOverview(page);
@@ -344,59 +301,6 @@ test('test that creating a new project from the task create modal project dropdo
await expect(page.getByTestId('task_table')).toContainText(newTaskName); 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 // Employee Permission Tests
// ============================================= // =============================================

View File

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

View File

@@ -1,4 +1,4 @@
import type { Locator, Page } from '@playwright/test'; import type { Locator } from '@playwright/test';
/** /**
* Extract the first cell's text content from each row in a table. * Extract the first cell's text content from each row in a table.
@@ -14,22 +14,3 @@ export async function getTableRowNames(table: Locator): Promise<string[]> {
} }
return names; 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": { "Billing": {
"repository": "solidtime-io/extension-billing", "repository": "solidtime-io/extension-billing",
"ref": "v0.0.6" "ref": "v0.0.3"
}, },
"Services": { "Services": {
"repository": "solidtime-io/extension-services", "repository": "solidtime-io/extension-services",
"ref": "v0.0.2" "ref": "v0.0.1"
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",

View File

@@ -11,13 +11,14 @@ import Pagination from '@/Components/Common/Pagination.vue';
import { canCreateClients } from '@/utils/permissions'; import { canCreateClients } from '@/utils/permissions';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { import {
useSortableTable, useVueTable,
type SortableColumnDef, getCoreRowModel,
type SortDirection, getSortedRowModel,
} from '@/utils/useSortableTable'; type SortingState,
} from '@tanstack/vue-table';
export type SortColumn = 'name' | 'projects_count' | 'status'; export type SortColumn = 'name' | 'projects_count' | 'status';
export type { SortDirection } from '@/utils/useSortableTable'; export type SortDirection = 'asc' | 'desc';
const props = defineProps<{ const props = defineProps<{
clients: Client[]; clients: Client[];
@@ -43,7 +44,17 @@ const projectCountMap = computed(() => {
return map; return map;
}); });
const columns = computed<SortableColumnDef<Client, SortColumn>[]>(() => [ // 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(() => [
{ {
id: 'name', id: 'name',
accessorFn: (row: Client) => row.name.toLowerCase(), accessorFn: (row: Client) => row.name.toLowerCase(),
@@ -59,22 +70,41 @@ const columns = computed<SortableColumnDef<Client, SortColumn>[]>(() => [
}, },
]); ]);
const { const descFirstColumns = new Set<SortColumn>(
sortedRows: sortedClients, columns.value
descFirstColumns, .filter((c) => 'sortDescFirst' in c && c.sortDescFirst)
nextDirection, .map((c) => c.id as SortColumn)
} = useSortableTable({ );
data: () => props.clients,
columns: () => columns.value,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
function handleSort(column: SortColumn) { function handleSort(column: SortColumn) {
emit('sort', column, nextDirection(column)); if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
} }
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. // Client-side pagination: the full list is in memory, only one page is mounted at a time.
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
const currentPage = ref(1); const currentPage = ref(1);

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue'; import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
const props = defineProps<{ const props = defineProps<{
@@ -9,29 +9,57 @@ const props = defineProps<{
descFirstColumns: ReadonlySet<SortColumn>; descFirstColumns: ReadonlySet<SortColumn>;
}>(); }>();
defineEmits<{ const emit = defineEmits<{
sort: [column: SortColumn]; 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> </script>
<template> <template>
<TableHeading> <TableHeading>
<SortableTableHeaderCell <div
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12" 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"
column="name" @click="handleSort('name')">
v-bind="props"
@sort="$emit('sort', $event)">
Name Name
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<SortableTableHeaderCell <ChevronUpIcon v-else-if="isChevronUp('name')" class="w-4 h-4" />
column="projects_count" <span v-else class="w-4 h-4"></span>
v-bind="props" </div>
@sort="$emit('sort', $event)"> <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')">
Projects Projects
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('projects_count')" class="w-4 h-4" />
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)"> <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')">
Status Status
</SortableTableHeaderCell> <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="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <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> <span class="sr-only">Edit</span>
</div> </div>

View File

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

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue'; import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
const props = defineProps<{ const props = defineProps<{
@@ -9,35 +9,73 @@ const props = defineProps<{
descFirstColumns: ReadonlySet<SortColumn>; descFirstColumns: ReadonlySet<SortColumn>;
}>(); }>();
defineEmits<{ const emit = defineEmits<{
sort: [column: SortColumn]; 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> </script>
<template> <template>
<TableHeading> <TableHeading>
<SortableTableHeaderCell <div
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12" 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"
column="name" @click="handleSort('name')">
v-bind="props"
@sort="$emit('sort', $event)">
Name Name
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<SortableTableHeaderCell column="email" v-bind="props" @sort="$emit('sort', $event)"> <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')">
Email Email
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('email')" class="w-4 h-4" />
<SortableTableHeaderCell column="role" v-bind="props" @sort="$emit('sort', $event)"> <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')">
Role Role
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('role')" class="w-4 h-4" />
<SortableTableHeaderCell <ChevronUpIcon v-else-if="isChevronUp('role')" class="w-4 h-4" />
column="billable_rate" <span v-else class="w-4 h-4"></span>
v-bind="props" </div>
@sort="$emit('sort', $event)"> <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')">
Billable Rate Billable Rate
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)"> <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')">
Status Status
</SortableTableHeaderCell> <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="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background"> <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> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -16,7 +16,7 @@ export type SortColumn =
| 'billable_rate' | 'billable_rate'
| 'status' | 'status'
| 'visibility'; | 'visibility';
export type { SortDirection } from '@/utils/useSortableTable'; export type SortDirection = 'asc' | 'desc';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src'; import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
@@ -27,10 +27,11 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { import {
useSortableTable, useVueTable,
type SortableColumnDef, getCoreRowModel,
type SortDirection, getSortedRowModel,
} from '@/utils/useSortableTable'; type SortingState,
} from '@tanstack/vue-table';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!); const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
@@ -56,16 +57,28 @@ const clientNameMap = computed(() => {
return map; 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. // Define column accessors for sorting.
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first, // Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
// while text columns default to ascending (A-Z) on first click (chevron down). // while text columns default to ascending (A-Z) on first click (chevron down).
const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [ const columns = computed(() => [
{ {
id: 'name', id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(), accessorFn: (row: Project) => row.name.toLowerCase(),
}, },
{ {
id: 'client_name', id: 'client_name',
sortUndefined: 'last' as const,
accessorFn: (row: Project) => { accessorFn: (row: Project) => {
if (!row.client_id) return undefined; if (!row.client_id) return undefined;
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase(); return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
@@ -74,11 +87,12 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
{ {
id: 'spent_time', id: 'spent_time',
sortDescFirst: true, sortDescFirst: true,
accessorFn: (row: Project) => row.spent_time, accessorFn: (row: Project) => row.spent_time ?? 0,
}, },
{ {
id: 'progress', id: 'progress',
sortDescFirst: true, sortDescFirst: true,
sortUndefined: 'last' as const,
accessorFn: (row: Project) => { accessorFn: (row: Project) => {
if (!row.estimated_time) return undefined; if (!row.estimated_time) return undefined;
return (row.spent_time / row.estimated_time) * 100; return (row.spent_time / row.estimated_time) * 100;
@@ -87,7 +101,7 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
{ {
id: 'billable_rate', id: 'billable_rate',
sortDescFirst: true, sortDescFirst: true,
accessorFn: (row: Project) => row.billable_rate, accessorFn: (row: Project) => row.billable_rate ?? 0,
}, },
{ {
id: 'status', id: 'status',
@@ -99,22 +113,40 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
}, },
]); ]);
const { // Columns with sortDescFirst get desc as default direction on first click.
sortedRows: sortedProjects, const descFirstColumns = new Set<SortColumn>(
descFirstColumns, columns.value.filter((c) => c.sortDescFirst).map((c) => c.id as SortColumn)
nextDirection, );
} = useSortableTable({
data: () => props.projects,
columns: () => columns.value,
sortColumn: () => props.sortColumn,
sortDirection: () => props.sortDirection,
tieBreakColumn: 'name',
});
function handleSort(column: SortColumn) { function handleSort(column: SortColumn) {
emit('sort', column, nextDirection(column)); if (props.sortColumn === column) {
emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc');
} else {
emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc');
}
} }
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. // Client-side pagination: the full list is in memory, only one page is mounted at a time.
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
const currentPage = ref(1); const currentPage = ref(1);

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue'; import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
const props = defineProps<{ const props = defineProps<{
@@ -15,51 +14,90 @@ const emit = defineEmits<{
sort: [column: SortColumn]; 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) { function handleSort(column: SortColumn) {
emit('sort', column); 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> </script>
<template> <template>
<TableHeading> <TableHeading>
<SortableTableHeaderCell <div
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12" 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"
column="name" @click="handleSort('name')">
v-bind="sortState"
@sort="handleSort">
Name Name
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('name')" class="w-4 h-4" />
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort"> <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')">
Client Client
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('client_name')" class="w-4 h-4" />
<SortableTableHeaderCell column="spent_time" v-bind="sortState" @sort="handleSort"> <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')">
Total Time Total Time
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('spent_time')" class="w-4 h-4" />
<SortableTableHeaderCell column="progress" v-bind="sortState" @sort="handleSort"> <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')">
Progress Progress
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('progress')" class="w-4 h-4" />
<SortableTableHeaderCell <ChevronUpIcon v-else-if="isChevronUp('progress')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
v-if="showBillableRate" v-if="showBillableRate"
column="billable_rate" 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"
v-bind="sortState" @click="handleSort('billable_rate')">
@sort="handleSort">
Billable Rate Billable Rate
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('billable_rate')" class="w-4 h-4" />
<SortableTableHeaderCell column="status" v-bind="sortState" @sort="handleSort"> <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')">
Status Status
</SortableTableHeaderCell> <ChevronDownIcon v-if="isChevronDown('status')" class="w-4 h-4" />
<SortableTableHeaderCell column="visibility" v-bind="sortState" @sort="handleSort"> <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')">
Visibility Visibility
</SortableTableHeaderCell> <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"> <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> <span class="sr-only">Edit</span>
</div> </div>
</TableHeading> </TableHeading>
</template> </template>
<style scoped></style>

View File

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

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue'; import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Tag/TagTable.vue';
const props = defineProps<{ const props = defineProps<{
@@ -9,20 +9,41 @@ const props = defineProps<{
descFirstColumns: ReadonlySet<SortColumn>; descFirstColumns: ReadonlySet<SortColumn>;
}>(); }>();
defineEmits<{ const emit = defineEmits<{
sort: [column: SortColumn]; 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> </script>
<template> <template>
<TableHeading> <TableHeading>
<SortableTableHeaderCell <div
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12" 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"
column="name" @click="handleSort('name')">
v-bind="props"
@sort="$emit('sort', $event)">
Name Name
</SortableTableHeaderCell> <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="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <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> <span class="sr-only">Edit</span>
</div> </div>

View File

@@ -8,62 +8,12 @@ import TaskTableHeading from '@/Components/Common/Task/TaskTableHeading.vue';
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue'; import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
import { canCreateTasks } from '@/utils/permissions'; import { canCreateTasks } from '@/utils/permissions';
import type { Task } from '@/packages/api/src'; 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<{ const props = defineProps<{
projectId: string; projectId: string;
tasks: Task[]; 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); const createTask = ref(false);
</script> </script>
@@ -80,12 +30,8 @@ const createTask = ref(false);
1fr minmax(80px, auto) minmax(120px, auto) minmax(50px, auto) 1fr minmax(80px, auto) minmax(120px, auto) minmax(50px, auto)
80px; 80px;
"> ">
<TaskTableHeading <TaskTableHeading></TaskTableHeading>
:sort-column="sortColumn" <div v-if="tasks.length === 0" class="col-span-5 py-24 text-center">
: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> <PlusCircleIcon class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
<h3 class="text-text-primary font-semibold">No tasks found</h3> <h3 class="text-text-primary font-semibold">No tasks found</h3>
<p v-if="canCreateTasks()" class="pb-5">Create your first task now!</p> <p v-if="canCreateTasks()" class="pb-5">Create your first task now!</p>
@@ -96,7 +42,7 @@ const createTask = ref(false);
>Create your First Task >Create your First Task
</SecondaryButton> </SecondaryButton>
</div> </div>
<template v-for="task in sortedTasks" :key="task.id"> <template v-for="task in tasks" :key="task.id">
<TaskTableRow :task="task"></TaskTableRow> <TaskTableRow :task="task"></TaskTableRow>
</template> </template>
</div> </div>

View File

@@ -1,37 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue'; 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> </script>
<template> <template>
<TableHeading> <TableHeading>
<SortableTableHeaderCell <div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
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 Task Name
</SortableTableHeaderCell> </div>
<SortableTableHeaderCell column="spent_time" v-bind="props" @sort="$emit('sort', $event)"> <div class="px-3 py-1.5 text-left text-text-tertiary">Total Time</div>
Total Time <div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
</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="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"> <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> <span class="sr-only">Edit</span>
</div> </div>
</TableHeading> </TableHeading>
</template> </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 PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateClients } from '@/utils/permissions'; import { canCreateClients } from '@/utils/permissions';
import { TabBar, TabBarItem } from '@/packages/ui/src'; import { TabBar, TabBarItem } from '@/packages/ui/src';
import { useTableSortState } from '@/utils/useTableSortState'; import { useStorage } from '@vueuse/core';
import type { SortColumn } from '@/Components/Common/Client/ClientTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Client/ClientTable.vue';
const { clients } = useClientsQuery(); const { clients } = useClientsQuery();
@@ -20,10 +20,25 @@ const activeTab = ref<'active' | 'archived'>('active');
const createClient = ref(false); const createClient = ref(false);
const { tableState, handleSort } = useTableSortState<SortColumn>('client-table-state', { interface ClientTableState {
sortColumn: 'name', sortColumn: SortColumn;
sortDirection: 'asc', 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 shownClients = computed(() => { const shownClients = computed(() => {
return clients.value.filter((client) => { 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 PageTitle from '@/Components/Common/PageTitle.vue';
import InvitationTable from '@/Components/Common/Invitation/InvitationTable.vue'; import InvitationTable from '@/Components/Common/Invitation/InvitationTable.vue';
import { canCreateInvitations } from '@/utils/permissions'; import { canCreateInvitations } from '@/utils/permissions';
import { useTableSortState } from '@/utils/useTableSortState'; import { useStorage } from '@vueuse/core';
import type { SortColumn } from '@/Components/Common/Member/MemberTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
const inviteMember = ref(false); const inviteMember = ref(false);
@@ -23,10 +23,25 @@ defineProps<{
const activeTab = ref<'all' | 'invitations'>('all'); const activeTab = ref<'all' | 'invitations'>('all');
const { tableState, handleSort } = useTableSortState<SortColumn>('member-table-state', { interface MemberTableState {
sortColumn: 'name', sortColumn: SortColumn;
sortDirection: 'asc', 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;
}
</script> </script>
<template> <template>

View File

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

View File

@@ -18,7 +18,6 @@ import { getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useTableSortState } from '@/utils/useTableSortState';
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue'; import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue'; import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
import ProjectVisibilityFilterBadge from '@/Components/Common/Project/ProjectVisibilityFilterBadge.vue'; import ProjectVisibilityFilterBadge from '@/Components/Common/Project/ProjectVisibilityFilterBadge.vue';
@@ -42,7 +41,7 @@ interface ProjectTableState {
}; };
} }
const { tableState, handleSort } = useTableSortState<SortColumn, ProjectTableState>( const tableState = useStorage<ProjectTableState>(
'project-table-state', 'project-table-state',
{ {
sortColumn: 'name', sortColumn: 'name',
@@ -53,15 +52,21 @@ const { tableState, handleSort } = useTableSortState<SortColumn, ProjectTableSta
visibility: 'all', visibility: 'all',
}, },
}, },
// The filters are merged key by key so a stored value missing a newer filter still undefined,
// picks up its default instead of the whole object falling back. {
(storage, defaults) => ({ mergeDefaults: (storage, defaults) => ({
...defaults, ...defaults,
...storage, ...storage,
filters: { ...defaults.filters, ...storage.filters }, filters: { ...defaults.filters, ...storage.filters },
}) }),
}
); );
function handleSort(column: SortColumn, direction: SortDirection) {
tableState.value.sortColumn = column;
tableState.value.sortDirection = direction;
}
// Filter projects based on current filters // Filter projects based on current filters
const filteredProjects = computed(() => { const filteredProjects = computed(() => {
return projects.value.filter((project) => { return projects.value.filter((project) => {

View File

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

View File

@@ -198,7 +198,7 @@ const showResultModal = ref(false);
for="file-upload" 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"> 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-if="files">{{ filenames }}</span>
<span v-else>Upload the Export file</span> <span v-else>Upload a Toggl/Clockify Export</span>
<input <input
id="file-upload" id="file-upload"
ref="importFile" ref="importFile"

View File

@@ -25,21 +25,25 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay <DialogOverlay
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"> 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">
<div class="absolute inset-0 bg-default-background opacity-30" /> <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> </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> </DialogPortal>
</template> </template>

View File

@@ -1,116 +0,0 @@
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

@@ -1,32 +0,0 @@
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 };
}