mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-17 18:23:19 +01:00
Compare commits
4 Commits
f00d48f1c3
...
extended-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c540be236 | ||
|
|
3bddd49364 | ||
|
|
ab5dbfef36 | ||
|
|
2eb9ae699c |
2
.github/VOUCHED.td
vendored
2
.github/VOUCHED.td
vendored
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
// =============================================
|
// =============================================
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()">
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user