Skip to content

Commit b67717e

Browse files
committed
add filters and sorting to projects table
1 parent 743c649 commit b67717e

18 files changed

+855
-90
lines changed

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ services:
107107
- sail
108108
- reverse-proxy
109109
playwright:
110-
image: mcr.microsoft.com/playwright:v1.51.1-jammy
110+
image: mcr.microsoft.com/playwright:v1.57.0-jammy
111111
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
112112
working_dir: /src
113113
extra_hosts:

e2e/projects.spec.ts

Lines changed: 192 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ async function goToProjectsOverview(page: Page) {
88
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
99
}
1010

11+
// Helper to clear localStorage before tests that check persistence
12+
async function clearProjectTableState(page: Page) {
13+
await page.evaluate(() => {
14+
localStorage.removeItem('project-table-state');
15+
});
16+
}
17+
1118
// Create new project via modal
1219
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
1320
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
@@ -45,34 +52,62 @@ test('test that creating and deleting a new project via the modal works', async
4552
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
4653
});
4754

55+
// Helper to select a status filter using the new dropdown UI
56+
async function selectStatusFilter(page: Page, status: 'Active' | 'Archived') {
57+
// Click the Filter button to open the dropdown
58+
await page.getByRole('button', { name: 'Filter projects' }).click();
59+
// Click on Status submenu
60+
await page.getByRole('menuitem', { name: 'Status' }).click();
61+
// Select the status option
62+
await page.getByRole('menuitem', { name: status }).click();
63+
}
64+
65+
// Helper to remove status filter by clicking the X on the badge
66+
async function removeStatusFilter(page: Page) {
67+
const statusBadge = page.getByTestId('status-filter-badge');
68+
// Click the remove button (second button in the badge, contains XMarkIcon)
69+
await statusBadge.locator('button').last().click();
70+
}
71+
4872
test('test that archiving and unarchiving projects works', async ({ page }) => {
4973
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
5074
await goToProjectsOverview(page);
75+
await clearProjectTableState(page);
76+
await page.reload();
77+
5178
await page.getByRole('button', { name: 'Create Project' }).click();
5279
await page.getByLabel('Project Name').fill(newProjectName);
5380

5481
await page.getByRole('button', { name: 'Create Project' }).click();
5582
await expect(page.getByText(newProjectName)).toBeVisible();
5683

84+
// Archive the project
5785
await page.getByRole('row').first().getByRole('button').click();
58-
await Promise.all([
59-
page.getByRole('menuitem').getByText('Archive').first().click(),
60-
expect(page.getByText(newProjectName)).not.toBeVisible(),
61-
]);
62-
await Promise.all([
63-
page.getByRole('tab', { name: 'Archived' }).click(),
64-
expect(page.getByText(newProjectName)).toBeVisible(),
65-
]);
86+
await page.getByRole('menuitem').getByText('Archive').first().click();
87+
88+
// Project should still be visible since default is "all" (no filter)
89+
await expect(page.getByText(newProjectName)).toBeVisible();
6690

91+
// Apply Active filter - archived project should disappear
92+
await selectStatusFilter(page, 'Active');
93+
await expect(page.getByText(newProjectName)).not.toBeVisible();
94+
95+
// Remove Active filter and apply Archived filter
96+
await removeStatusFilter(page);
97+
await selectStatusFilter(page, 'Archived');
98+
await expect(page.getByText(newProjectName)).toBeVisible();
99+
100+
// Unarchive the project
67101
await page.getByRole('row').first().getByRole('button').click();
68-
await Promise.all([
69-
page.getByRole('menuitem').getByText('Unarchive').first().click(),
70-
expect(page.getByText(newProjectName)).not.toBeVisible(),
71-
]);
72-
await Promise.all([
73-
page.getByRole('tab', { name: 'Active' }).click(),
74-
expect(page.getByText(newProjectName)).toBeVisible(),
75-
]);
102+
await page.getByRole('menuitem').getByText('Unarchive').first().click();
103+
104+
// Project should disappear from Archived view
105+
await expect(page.getByText(newProjectName)).not.toBeVisible();
106+
107+
// Remove Archived filter and apply Active filter to see the project
108+
await removeStatusFilter(page);
109+
await selectStatusFilter(page, 'Active');
110+
await expect(page.getByText(newProjectName)).toBeVisible();
76111
});
77112

78113
test('test that updating billable rate works with existing time entries', async ({ page }) => {
@@ -116,6 +151,147 @@ test('test that updating billable rate works with existing time entries', async
116151
).toBeVisible();
117152
});
118153

154+
// Sorting tests
155+
test('test that sorting projects by name works', async ({ page }) => {
156+
await goToProjectsOverview(page);
157+
await clearProjectTableState(page);
158+
await page.reload();
159+
160+
// Wait for the table to load
161+
await expect(page.getByTestId('project_table')).toBeVisible();
162+
163+
// Get initial project names
164+
const getProjectNames = async () => {
165+
const rows = page
166+
.getByTestId('project_table')
167+
.locator('[data-testid="project_table"] > div')
168+
.filter({ hasNot: page.locator('.border-t') });
169+
const names: string[] = [];
170+
const count = await page.getByTestId('project_table').getByRole('row').count();
171+
for (let i = 0; i < count; i++) {
172+
const row = page.getByTestId('project_table').getByRole('row').nth(i);
173+
const nameCell = row.locator('div').first();
174+
const text = await nameCell.textContent();
175+
if (text) {
176+
names.push(text.trim());
177+
}
178+
}
179+
return names;
180+
};
181+
182+
// Click on Name header to sort ascending (default should already be ascending)
183+
const nameHeader = page.getByText('Name').first();
184+
await nameHeader.click();
185+
186+
// Wait for sort to apply
187+
await page.waitForTimeout(100);
188+
189+
// Click again to sort descending
190+
await nameHeader.click();
191+
await page.waitForTimeout(100);
192+
193+
// Verify the sort indicator is showing descending
194+
await expect(page.locator('svg').first()).toBeVisible();
195+
});
196+
197+
test('test that sorting projects by status works', async ({ page }) => {
198+
await goToProjectsOverview(page);
199+
await clearProjectTableState(page);
200+
await page.reload();
201+
202+
// Default is "all" so no filter needed - Wait for the table to load
203+
await expect(page.getByTestId('project_table')).toBeVisible();
204+
205+
// Click on Status header to sort
206+
const statusHeader = page.getByText('Status').first();
207+
await statusHeader.click();
208+
209+
// Wait for sort to apply
210+
await page.waitForTimeout(100);
211+
212+
// Sort indicator should be visible
213+
await expect(statusHeader.locator('svg')).toBeVisible();
214+
});
215+
216+
// Filter tests
217+
test('test that filtering projects by status works', async ({ page }) => {
218+
const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000);
219+
await goToProjectsOverview(page);
220+
await clearProjectTableState(page);
221+
await page.reload();
222+
223+
// Create a new project
224+
await page.getByRole('button', { name: 'Create Project' }).click();
225+
await page.getByLabel('Project Name').fill(newProjectName);
226+
await page.getByRole('button', { name: 'Create Project' }).click();
227+
await expect(page.getByText(newProjectName)).toBeVisible();
228+
229+
// Archive the project
230+
await page.getByRole('row').first().getByRole('button').click();
231+
await page.getByRole('menuitem').getByText('Archive').first().click();
232+
233+
// Project should still be visible (default is "all" - no filter)
234+
await expect(page.getByText(newProjectName)).toBeVisible();
235+
236+
// Apply Active filter - archived project should disappear
237+
await selectStatusFilter(page, 'Active');
238+
await expect(page.getByText(newProjectName)).not.toBeVisible();
239+
240+
// Remove Active filter - project should reappear (back to "all")
241+
await removeStatusFilter(page);
242+
await expect(page.getByText(newProjectName)).toBeVisible();
243+
244+
// Apply Archived filter - project should still be visible
245+
await selectStatusFilter(page, 'Archived');
246+
await expect(page.getByText(newProjectName)).toBeVisible();
247+
248+
// Remove Archived filter and apply Active filter - project should not be visible
249+
await removeStatusFilter(page);
250+
await selectStatusFilter(page, 'Active');
251+
await expect(page.getByText(newProjectName)).not.toBeVisible();
252+
});
253+
254+
test('test that filter state persists after page reload', async ({ page }) => {
255+
await goToProjectsOverview(page);
256+
await clearProjectTableState(page);
257+
await page.reload();
258+
259+
// Apply Active status filter
260+
await selectStatusFilter(page, 'Active');
261+
262+
// Verify the filter badge is visible
263+
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
264+
265+
// Wait for the state to be saved
266+
await page.waitForTimeout(100);
267+
268+
// Reload the page
269+
await page.reload();
270+
271+
// Verify the filter badge is still visible after reload
272+
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
273+
});
274+
275+
test('test that sort state persists after page reload', async ({ page }) => {
276+
await goToProjectsOverview(page);
277+
await clearProjectTableState(page);
278+
await page.reload();
279+
280+
// Click on Name header twice to sort descending
281+
const nameHeader = page.getByText('Name').first();
282+
await nameHeader.click();
283+
await nameHeader.click();
284+
285+
// Wait for the state to be saved
286+
await page.waitForTimeout(100);
287+
288+
// Reload the page
289+
await page.reload();
290+
291+
// Verify descending sort indicator is visible on Name column
292+
await expect(page.getByTestId('project_table')).toBeVisible();
293+
});
294+
119295
// Create new project with new Client
120296

121297
// Create new project with existing Client
@@ -124,8 +300,6 @@ test('test that updating billable rate works with existing time entries', async
124300

125301
// Test that project task count is displayed correctly
126302

127-
// Test that active / archive / all filter works (once implemented)
128-
129303
// Edit Project Modal Test
130304

131305
// Add Project with billable rate
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<script setup lang="ts">
2+
import { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
3+
import type { Component } from 'vue';
4+
import {
5+
DropdownMenu,
6+
DropdownMenuContent,
7+
DropdownMenuTrigger,
8+
} from '@/Components/ui/dropdown-menu';
9+
10+
defineProps<{
11+
icon: Component;
12+
label: string;
13+
filterName: string;
14+
}>();
15+
16+
defineEmits<{
17+
remove: [];
18+
}>();
19+
20+
defineSlots<{
21+
default(): void;
22+
}>();
23+
</script>
24+
25+
<template>
26+
<div
27+
class="inline-flex items-center gap-0.5 rounded-md bg-tertiary dark:bg-secondary border border-border-secondary">
28+
<DropdownMenu>
29+
<DropdownMenuTrigger as-child>
30+
<button
31+
class="inline-flex items-center gap-1.5 px-2 py-1 text-sm hover:bg-quaternary dark:hover:bg-tertiary rounded-l-md transition-colors">
32+
<component :is="icon" class="h-3.5 w-3.5 text-icon-default" />
33+
<span class="font-medium text-foreground">{{ filterName }}</span>
34+
<span class="text-muted-foreground">is</span>
35+
<span class="text-foreground">{{ label }}</span>
36+
<ChevronDownIcon class="h-3 w-3 text-muted-foreground" />
37+
</button>
38+
</DropdownMenuTrigger>
39+
<DropdownMenuContent align="start">
40+
<slot />
41+
</DropdownMenuContent>
42+
</DropdownMenu>
43+
44+
<button
45+
class="px-1.5 py-1 hover:bg-quaternary dark:hover:bg-tertiary h-full rounded-r-md transition-colors group border-l border-border-secondary"
46+
@click="$emit('remove')">
47+
<XMarkIcon class="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
48+
</button>
49+
</div>
50+
</template>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue';
3+
import { UserGroupIcon } from '@heroicons/vue/16/solid';
4+
import { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/Components/ui/dropdown-menu';
5+
import BaseFilterBadge from './BaseFilterBadge.vue';
6+
import type { Client } from '@/packages/api/src';
7+
import { NO_CLIENT_ID } from './constants';
8+
9+
const props = defineProps<{
10+
value: string[];
11+
clients: Client[];
12+
}>();
13+
14+
const emit = defineEmits<{
15+
remove: [];
16+
'update:value': [value: string[]];
17+
}>();
18+
19+
const hasNoClient = computed(() => props.value.includes(NO_CLIENT_ID));
20+
21+
const label = computed(() => {
22+
const count = props.value.length;
23+
24+
if (count === 0) return 'None';
25+
if (count === 1) {
26+
if (hasNoClient.value) return 'No client';
27+
const client = props.clients.find((c) => c.id === props.value[0]);
28+
return client?.name ?? 'Client';
29+
}
30+
return `${count} selected`;
31+
});
32+
33+
function toggleClient(clientId: string) {
34+
const clientIds = props.value.includes(clientId)
35+
? props.value.filter((id) => id !== clientId)
36+
: [...props.value, clientId];
37+
38+
emit('update:value', clientIds);
39+
}
40+
41+
function toggleNoClient() {
42+
const clientIds = hasNoClient.value
43+
? props.value.filter((id) => id !== NO_CLIENT_ID)
44+
: [...props.value, NO_CLIENT_ID];
45+
46+
emit('update:value', clientIds);
47+
}
48+
</script>
49+
50+
<template>
51+
<BaseFilterBadge
52+
:icon="UserGroupIcon"
53+
:label="label"
54+
filter-name="Client"
55+
@remove="emit('remove')">
56+
<DropdownMenuCheckboxItem :model-value="hasNoClient" @select.prevent="toggleNoClient">
57+
No client
58+
</DropdownMenuCheckboxItem>
59+
<DropdownMenuSeparator />
60+
<DropdownMenuCheckboxItem
61+
v-for="client in clients"
62+
:key="client.id"
63+
:model-value="value.includes(client.id)"
64+
@select.prevent="toggleClient(client.id)">
65+
{{ client.name }}
66+
</DropdownMenuCheckboxItem>
67+
</BaseFilterBadge>
68+
</template>

0 commit comments

Comments
 (0)