diff --git a/e2e/clients.spec.ts b/e2e/clients.spec.ts index 761f215e..1e073bab 100644 --- a/e2e/clients.spec.ts +++ b/e2e/clients.spec.ts @@ -8,6 +8,7 @@ import { createProjectViaApi, createPublicProjectViaApi, } from './utils/api'; +import { getTableRowNames } from './utils/table'; async function goToClientsOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/clients'); @@ -131,6 +132,96 @@ test('test that deleting a client via actions menu works', async ({ page, ctx }) await expect(page.getByTestId('client_table')).not.toContainText(clientName); }); +// ============================================= +// Sorting Tests +// ============================================= + +async function clearClientTableState(page: Page) { + await page.evaluate(() => { + localStorage.removeItem('client-table-state'); + }); +} + +test('test that sorting clients by name and status works', async ({ page, ctx }) => { + await createClientViaApi(ctx, { name: 'AAA SortClient' }); + await createClientViaApi(ctx, { name: 'ZZZ SortClient' }); + + await goToClientsOverview(page); + await clearClientTableState(page); + await page.reload(); + + const table = page.getByTestId('client_table'); + await expect(table).toBeVisible(); + + // -- Name sorting (default is name asc) -- + let names = await getTableRowNames(table); + expect(names.indexOf('AAA SortClient')).toBeLessThan(names.indexOf('ZZZ SortClient')); + + const nameHeader = table.getByText('Name').first(); + await nameHeader.click(); // toggle to desc + names = await getTableRowNames(table); + expect(names.indexOf('ZZZ SortClient')).toBeLessThan(names.indexOf('AAA SortClient')); + + // -- Status sorting -- + const statusHeader = table.getByText('Status').first(); + await statusHeader.click(); // asc + await expect(statusHeader.locator('svg')).toBeVisible(); + await statusHeader.click(); // desc + await expect(statusHeader.locator('svg')).toBeVisible(); +}); + +test('test that sorting clients by project count works', async ({ page, ctx }) => { + const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' }); + const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' }); + + // Create projects for the first client + await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id }); + await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id }); + + await goToClientsOverview(page); + await clearClientTableState(page); + await page.reload(); + + const table = page.getByTestId('client_table'); + await expect(table).toBeVisible(); + + // Click Projects header - first click should sort desc (most projects first) + const projectsHeader = table.getByText('Projects').first(); + await projectsHeader.click(); + await expect(projectsHeader.locator('svg')).toBeVisible(); + let names = await getTableRowNames(table); + expect(names.indexOf('ManyProjects Client')).toBeLessThan( + names.indexOf('NoProjects Client') + ); + + // Second click toggles to asc (least projects first) + await projectsHeader.click(); + names = await getTableRowNames(table); + expect(names.indexOf('NoProjects Client')).toBeLessThan( + names.indexOf('ManyProjects Client') + ); +}); + +test('test that client sort state persists after page reload', async ({ page }) => { + await goToClientsOverview(page); + await clearClientTableState(page); + await page.reload(); + + const table = page.getByTestId('client_table'); + await expect(table).toBeVisible(); + + const nameHeader = table.getByText('Name').first(); + await nameHeader.click(); // toggle to desc + await expect(nameHeader.locator('svg')).toBeVisible(); + + await page.reload(); + + await expect(page.getByTestId('client_table')).toBeVisible(); + await expect( + page.getByTestId('client_table').getByText('Name').first().locator('svg') + ).toBeVisible(); +}); + // ============================================= // Employee Permission Tests // ============================================= diff --git a/e2e/members.spec.ts b/e2e/members.spec.ts index b5cf4ec0..50086d6a 100644 --- a/e2e/members.spec.ts +++ b/e2e/members.spec.ts @@ -5,7 +5,12 @@ import { expect, test } from '../playwright/fixtures'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import type { Page } from '@playwright/test'; import { inviteAndAcceptMember } from './utils/members'; -import { createPlaceholderMemberViaImportApi } from './utils/api'; +import { + createPlaceholderMemberViaImportApi, + getMembersViaApi, + updateMemberBillableRateViaApi, +} from './utils/api'; +import { getTableRowNames } from './utils/table'; // Tests that invite + accept members need more time test.describe.configure({ timeout: 45000 }); @@ -487,6 +492,125 @@ test('test that accepted invitation disappears from invitations tab', async ({ p await expect(page.getByText(memberEmail)).not.toBeVisible(); }); +// ============================================= +// Sorting Tests +// ============================================= + +// Helper to clear localStorage before tests that check sorting +async function clearMemberTableState(page: Page) { + await page.evaluate(() => { + localStorage.removeItem('member-table-state'); + }); +} + +test('test that sorting members by name, role, and status works', async ({ page, ctx }) => { + // Create two placeholder members with names that sort predictably around "John Doe" + await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst'); + await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast'); + + await goToMembersPage(page); + await clearMemberTableState(page); + await page.reload(); + + const table = page.getByTestId('member_table'); + await expect(table).toBeVisible(); + + // -- Name sorting (default is already name asc after clearing state) -- + const nameHeader = table.getByText('Name').first(); + let names = await getTableRowNames(table); + expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast')); + + await nameHeader.click(); // toggle to desc + names = await getTableRowNames(table); + expect(names.indexOf('ZZZ SortLast')).toBeLessThan(names.indexOf('AAA SortFirst')); + + // -- Role sorting -- + const roleHeader = table.getByText('Role').first(); + await roleHeader.click(); // asc: Owner(0) < Placeholder(4) + names = await getTableRowNames(table); + const ownerIdx = names.indexOf('John Doe'); + const placeholderIdx = names.indexOf('AAA SortFirst'); + expect(ownerIdx).toBeLessThan(placeholderIdx); + + await roleHeader.click(); // desc: Placeholder first + names = await getTableRowNames(table); + expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe')); + + // -- Status sorting -- + const statusHeader = table.getByText('Status').first(); + await statusHeader.click(); // asc: Active(0) < Inactive(1) + names = await getTableRowNames(table); + expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst')); + + await statusHeader.click(); // desc: Inactive first + names = await getTableRowNames(table); + expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe')); + + // -- Email: just verify sort indicator appears -- + const emailHeader = table.getByText('Email').first(); + await emailHeader.click(); + await expect(emailHeader.locator('svg')).toBeVisible(); +}); + +test('test that member sort state persists after page reload', async ({ page }) => { + await goToMembersPage(page); + await clearMemberTableState(page); + await page.reload(); + + const table = page.getByTestId('member_table'); + await expect(table).toBeVisible(); + + // Click Role header twice to set descending sort + const roleHeader = table.getByText('Role').first(); + await roleHeader.click(); + await expect(roleHeader.locator('svg')).toBeVisible(); + await roleHeader.click(); + await expect(roleHeader.locator('svg')).toBeVisible(); + + // Reload the page + await page.reload(); + + // Verify the sort indicator is still visible on Role column + await expect(page.getByTestId('member_table')).toBeVisible(); + await expect( + page.getByTestId('member_table').getByText('Role').first().locator('svg') + ).toBeVisible(); +}); + +test('test that sorting members by billable rate works', async ({ page, ctx }) => { + // Create two placeholder members and set different billable rates + await createPlaceholderMemberViaImportApi(ctx, 'HighRate Member'); + await createPlaceholderMemberViaImportApi(ctx, 'LowRate Member'); + + const members = await getMembersViaApi(ctx); + const highRateMember = members.find((m) => m.name === 'HighRate Member'); + const lowRateMember = members.find((m) => m.name === 'LowRate Member'); + expect(highRateMember).toBeDefined(); + expect(lowRateMember).toBeDefined(); + + await updateMemberBillableRateViaApi(ctx, highRateMember!.id, 20000); + await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000); + + await goToMembersPage(page); + await clearMemberTableState(page); + await page.reload(); + + const table = page.getByTestId('member_table'); + await expect(table).toBeVisible(); + + // First click = desc (highest first), null rates last + const billableHeader = table.getByText('Billable Rate').first(); + await billableHeader.click(); + await expect(billableHeader.locator('svg')).toBeVisible(); + let names = await getTableRowNames(table); + expect(names.indexOf('HighRate Member')).toBeLessThan(names.indexOf('LowRate Member')); + + // Second click = asc (lowest first), null rates still last + await billableHeader.click(); + names = await getTableRowNames(table); + expect(names.indexOf('LowRate Member')).toBeLessThan(names.indexOf('HighRate Member')); +}); + // ============================================= // Employee Permission Tests // ============================================= @@ -522,7 +646,7 @@ test.describe('Employee Sidebar Navigation', () => { }); // Member table is empty — no rows rendered (only headers) - await expect(employee.page.getByTestId('client_table').locator('[role="row"]')).toHaveCount( + await expect(employee.page.getByTestId('member_table').locator('[role="row"]')).toHaveCount( 0 ); diff --git a/e2e/tags.spec.ts b/e2e/tags.spec.ts index 26995641..0846f0cd 100644 --- a/e2e/tags.spec.ts +++ b/e2e/tags.spec.ts @@ -3,6 +3,7 @@ import type { Page } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; import { createTagViaApi } from './utils/api'; +import { getTableRowNames } from './utils/table'; async function goToTagsOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/tags'); @@ -89,6 +90,57 @@ test('test that multiple tags can be created via API and displayed in the table' await expect(page.getByTestId('tag_table')).toContainText(tagName2); }); +// ============================================= +// Sorting Tests +// ============================================= + +async function clearTagTableState(page: Page) { + await page.evaluate(() => { + localStorage.removeItem('tag-table-state'); + }); +} + +test('test that sorting tags by name works', async ({ page, ctx }) => { + await createTagViaApi(ctx, { name: 'AAA SortTag' }); + await createTagViaApi(ctx, { name: 'ZZZ SortTag' }); + + await goToTagsOverview(page); + await clearTagTableState(page); + await page.reload(); + + const table = page.getByTestId('tag_table'); + await expect(table).toBeVisible(); + + // Default is name asc + let names = await getTableRowNames(table); + expect(names.indexOf('AAA SortTag')).toBeLessThan(names.indexOf('ZZZ SortTag')); + + const nameHeader = table.getByText('Name').first(); + await nameHeader.click(); // toggle to desc + names = await getTableRowNames(table); + expect(names.indexOf('ZZZ SortTag')).toBeLessThan(names.indexOf('AAA SortTag')); +}); + +test('test that tag sort state persists after page reload', async ({ page }) => { + await goToTagsOverview(page); + await clearTagTableState(page); + await page.reload(); + + const table = page.getByTestId('tag_table'); + await expect(table).toBeVisible(); + + const nameHeader = table.getByText('Name').first(); + await nameHeader.click(); // toggle to desc + await expect(nameHeader.locator('svg')).toBeVisible(); + + await page.reload(); + + await expect(page.getByTestId('tag_table')).toBeVisible(); + await expect( + page.getByTestId('tag_table').getByText('Name').first().locator('svg') + ).toBeVisible(); +}); + // ============================================= // Employee Permission Tests // ============================================= diff --git a/e2e/utils/api.ts b/e2e/utils/api.ts index 8043498e..731bf973 100644 --- a/e2e/utils/api.ts +++ b/e2e/utils/api.ts @@ -345,6 +345,36 @@ export async function createProjectMemberViaApi( return body.data as { id: string; billable_rate: number | null }; } +export async function getMembersViaApi(ctx: TestContext) { + const response = await ctx.request.get( + `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members` + ); + expect(response.status()).toBe(200); + const body = await response.json(); + return body.data as Array<{ + id: string; + name: string; + email: string; + role: string; + billable_rate: number | null; + is_placeholder: boolean; + }>; +} + +export async function updateMemberBillableRateViaApi( + ctx: TestContext, + memberId: string, + billableRate: number | null +) { + const response = await ctx.request.put( + `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/members/${memberId}`, + { data: { billable_rate: billableRate } } + ); + expect(response.status()).toBe(200); + const body = await response.json(); + return body.data; +} + // ────────────────────────────────────────────────── // Composite helpers (matching existing UI helper signatures) // ────────────────────────────────────────────────── diff --git a/resources/js/Components/Common/Client/ClientTable.vue b/resources/js/Components/Common/Client/ClientTable.vue index da95ca27..dc5e14c9 100644 --- a/resources/js/Components/Common/Client/ClientTable.vue +++ b/resources/js/Components/Common/Client/ClientTable.vue @@ -2,17 +2,102 @@ import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import { UserCircleIcon } from '@heroicons/vue/24/solid'; import { PlusIcon } from '@heroicons/vue/16/solid'; -import { type Component, ref } from 'vue'; +import { type Component, computed, ref } from 'vue'; import { type Client } from '@/packages/api/src'; import ClientTableRow from '@/Components/Common/Client/ClientTableRow.vue'; import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'; import ClientTableHeading from '@/Components/Common/Client/ClientTableHeading.vue'; import { canCreateClients } from '@/utils/permissions'; +import { useProjectsQuery } from '@/utils/useProjectsQuery'; +import { + useVueTable, + getCoreRowModel, + getSortedRowModel, + type SortingState, +} from '@tanstack/vue-table'; -defineProps<{ +export type SortColumn = 'name' | 'projects_count' | 'status'; +export type SortDirection = 'asc' | 'desc'; + +const props = defineProps<{ clients: Client[]; + sortColumn: SortColumn; + sortDirection: SortDirection; }>(); + +const emit = defineEmits<{ + sort: [column: SortColumn, direction: SortDirection]; +}>(); + const createClient = ref(false); + +const { projects } = useProjectsQuery(); + +const projectCountMap = computed(() => { + const map = new Map(); + projects.value.forEach((project) => { + if (project.client_id) { + map.set(project.client_id, (map.get(project.client_id) ?? 0) + 1); + } + }); + return map; +}); + +const sorting = computed(() => [ + { + id: props.sortColumn, + desc: props.sortDirection === 'desc', + }, +]); + +const columns = computed(() => [ + { + id: 'name', + accessorFn: (row: Client) => row.name.toLowerCase(), + }, + { + id: 'projects_count', + sortDescFirst: true, + accessorFn: (row: Client) => projectCountMap.value.get(row.id) ?? 0, + }, + { + id: 'status', + accessorFn: (row: Client) => (row.is_archived ? 1 : 0), + }, +]); + +const descFirstColumns = new Set( + columns.value.filter((c) => 'sortDescFirst' in c && c.sortDescFirst).map((c) => c.id as SortColumn) +); + +function handleSort(column: SortColumn) { + if (props.sortColumn === column) { + emit('sort', column, props.sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + emit('sort', column, descFirstColumns.has(column) ? 'desc' : 'asc'); + } +} + +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); +});