From d29f5706ce2713a5549887244b50338b60511fa7 Mon Sep 17 00:00:00 2001 From: Niklaus Bucher Date: Fri, 21 Aug 2026 14:16:58 +0200 Subject: [PATCH] Add sorting to tasks table --- e2e/tasks.spec.ts | 102 ++++++++++++++++++ e2e/utils/api.ts | 12 ++- .../js/Components/Common/Task/TaskTable.vue | 87 ++++++++++++++- .../Common/Task/TaskTableHeading.vue | 57 +++++++++- resources/js/Pages/ProjectShow.vue | 29 ++++- 5 files changed, 277 insertions(+), 10 deletions(-) diff --git a/e2e/tasks.spec.ts b/e2e/tasks.spec.ts index a5bfaa21..86855a15 100644 --- a/e2e/tasks.spec.ts +++ b/e2e/tasks.spec.ts @@ -7,13 +7,62 @@ import { createPublicProjectViaApi, createTaskViaApi, createClientViaApi, + createTimeEntryViaApi, updateOrganizationSettingViaApi, + type TestContext, } from './utils/api'; +import { getTableRowNames } from './utils/table'; async function goToProjectsOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); } +async function clearTaskTableState(page: Page) { + await page.evaluate(() => { + localStorage.removeItem('task-table-state'); + }); +} + +async function createSortableTasks(ctx: TestContext) { + const project = await createProjectViaApi(ctx, { name: 'Task Sorting Project' }); + const taskA = await createTaskViaApi(ctx, { + name: 'AAA Sorting Task', + project_id: project.id, + estimated_time: 36000, + }); + const taskB = await createTaskViaApi(ctx, { + name: 'BBB Sorting Task', + project_id: project.id, + estimated_time: 14400, + }); + const taskC = await createTaskViaApi(ctx, { + name: 'CCC Sorting Task', + project_id: project.id, + }); + + expect(taskA.estimated_time).toBe(36000); + expect(taskB.estimated_time).toBe(14400); + expect(taskC.estimated_time).toBeNull(); + + await createTimeEntryViaApi(ctx, { + duration: '1h', + projectId: project.id, + taskId: taskA.id, + }); + await createTimeEntryViaApi(ctx, { + duration: '2h', + projectId: project.id, + taskId: taskB.id, + }); + await createTimeEntryViaApi(ctx, { + duration: '3h', + projectId: project.id, + taskId: taskC.id, + }); + + return { project, taskA, taskB, taskC }; +} + test('test that creating and deleting a new task in a new project works', async ({ page }) => { const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); @@ -301,6 +350,59 @@ test('test that creating a new project from the task create modal project dropdo await expect(page.getByTestId('task_table')).toContainText(newTaskName); }); +// ============================================= +// Sorting Tests +// ============================================= + +test('test that sorting tasks by name, total time and progress works', async ({ page, ctx }) => { + const { project, taskA, taskB, taskC } = await createSortableTasks(ctx); + await goToProjectsOverview(page); + await clearTaskTableState(page); + 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 clearTaskTableState(page); + 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 // ============================================= diff --git a/e2e/utils/api.ts b/e2e/utils/api.ts index ac5ad1b5..17666c9e 100644 --- a/e2e/utils/api.ts +++ b/e2e/utils/api.ts @@ -357,7 +357,7 @@ export async function createProjectWithClientViaApi( export async function createTaskViaApi( ctx: TestContext, - data: { name: string; project_id: string } + data: { name: string; project_id: string; estimated_time?: number } ) { const response = await ctx.request.post( `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/tasks`, @@ -365,12 +365,20 @@ export async function createTaskViaApi( data: { name: data.name, project_id: data.project_id, + ...(data.estimated_time !== undefined + ? { estimated_time: data.estimated_time } + : {}), }, } ); expect(response.status()).toBe(201); const body = await response.json(); - return body.data as { id: string; name: string; project_id: string }; + return body.data as { + id: string; + name: string; + project_id: string; + estimated_time: number | null; + }; } export async function markTaskDoneViaApi(ctx: TestContext, task: { id: string; name: string }) { diff --git a/resources/js/Components/Common/Task/TaskTable.vue b/resources/js/Components/Common/Task/TaskTable.vue index 9eaec200..931bb1fb 100644 --- a/resources/js/Components/Common/Task/TaskTable.vue +++ b/resources/js/Components/Common/Task/TaskTable.vue @@ -2,18 +2,93 @@ import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import { PlusCircleIcon } from '@heroicons/vue/24/solid'; import { PlusIcon } from '@heroicons/vue/16/solid'; -import { ref } from 'vue'; +import { computed, ref } from 'vue'; import TaskTableRow from '@/Components/Common/Task/TaskTableRow.vue'; import TaskTableHeading from '@/Components/Common/Task/TaskTableHeading.vue'; import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue'; import { canCreateTasks } from '@/utils/permissions'; import type { Task } from '@/packages/api/src'; +import { + getCoreRowModel, + getSortedRowModel, + type SortingState, + useVueTable, +} from '@tanstack/vue-table'; + +export type SortColumn = 'name' | 'spent_time' | 'progress'; +export type SortDirection = 'asc' | 'desc'; const props = defineProps<{ projectId: string; tasks: Task[]; + sortColumn: SortColumn; + sortDirection: SortDirection; }>(); +const emit = defineEmits<{ + sort: [column: SortColumn, direction: SortDirection]; +}>(); + +const sorting = computed(() => [ + { + id: props.sortColumn, + desc: props.sortDirection === 'desc', + }, + ...(props.sortColumn !== 'name' ? [{ id: 'name', desc: false }] : []), +]); + +const columns = computed(() => [ + { + id: 'name', + accessorFn: (row: Task) => row.name.toLowerCase(), + }, + { + id: 'spent_time', + sortDescFirst: true, + accessorFn: (row: Task) => row.spent_time ?? 0, + }, + { + id: 'progress', + sortDescFirst: true, + sortUndefined: 'last' as const, + accessorFn: (row: Task) => { + if (!row.estimated_time) return undefined; + return (row.spent_time / row.estimated_time) * 100; + }, + }, +]); + +const descFirstColumns = new Set( + columns.value.filter((column) => column.sortDescFirst).map((column) => column.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.tasks; + }, + get columns() { + return columns.value; + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + state: { + get sorting() { + return sorting.value; + }, + }, + manualSorting: false, +}); + +const sortedTasks = computed(() => table.getRowModel().rows.map((row) => row.original)); + const createTask = ref(false); @@ -30,8 +105,12 @@ const createTask = ref(false); 1fr minmax(80px, auto) minmax(120px, auto) minmax(50px, auto) 80px; "> - -
+ +

No tasks found

Create your first task now!

@@ -42,7 +121,7 @@ const createTask = ref(false); >Create your First Task
-