Skip to content

Commit e42f8f8

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

File tree

13 files changed

+822
-62
lines changed

13 files changed

+822
-62
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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue';
3+
import {
4+
XMarkIcon,
5+
UserGroupIcon,
6+
CircleStackIcon,
7+
ChevronDownIcon,
8+
} from '@heroicons/vue/16/solid';
9+
import {
10+
DropdownMenu,
11+
DropdownMenuContent,
12+
DropdownMenuItem,
13+
DropdownMenuTrigger,
14+
DropdownMenuCheckboxItem,
15+
DropdownMenuSeparator,
16+
} from '@/Components/ui/dropdown-menu';
17+
import type { Client } from '@/packages/api/src';
18+
19+
type FilterType = 'status' | 'client';
20+
21+
const props = defineProps<{
22+
type: FilterType;
23+
value: 'active' | 'archived' | 'all' | string[];
24+
clients?: Client[];
25+
noClient?: boolean;
26+
}>();
27+
28+
const emit = defineEmits<{
29+
remove: [];
30+
'update:value': [value: 'active' | 'archived' | 'all' | string[]];
31+
'update:noClient': [value: boolean];
32+
}>();
33+
34+
const statusOptions = [
35+
{ id: 'active' as const, name: 'Active' },
36+
{ id: 'archived' as const, name: 'Archived' },
37+
];
38+
39+
const icon = computed(() => {
40+
if (props.type === 'status') return CircleStackIcon;
41+
return UserGroupIcon;
42+
});
43+
44+
const label = computed(() => {
45+
if (props.type === 'status') {
46+
return statusOptions.find((opt) => opt.id === props.value)?.name ?? 'Status';
47+
}
48+
49+
if (props.type === 'client' && Array.isArray(props.value)) {
50+
const clientCount = props.value.length + (props.noClient ? 1 : 0);
51+
52+
if (clientCount === 0) return 'None';
53+
if (clientCount === 1) {
54+
if (props.noClient) return 'No client';
55+
const client = props.clients?.find((c) => c.id === props.value[0]);
56+
return client?.name ?? 'Client';
57+
}
58+
return `${clientCount} selected`;
59+
}
60+
61+
return 'Filter';
62+
});
63+
64+
function updateStatus(status: 'active' | 'archived' | 'all') {
65+
emit('update:value', status);
66+
}
67+
68+
function toggleClient(clientId: string) {
69+
if (!Array.isArray(props.value)) return;
70+
71+
const clientIds = props.value.includes(clientId)
72+
? props.value.filter((id) => id !== clientId)
73+
: [...props.value, clientId];
74+
75+
emit('update:value', clientIds);
76+
}
77+
78+
function toggleNoClient() {
79+
emit('update:noClient', !props.noClient);
80+
}
81+
</script>
82+
83+
<template>
84+
<div
85+
class="inline-flex items-center gap-0.5 rounded-md bg-tertiary dark:bg-secondary border border-border-secondary">
86+
<!-- Filter Type and Value -->
87+
<DropdownMenu>
88+
<DropdownMenuTrigger as-child>
89+
<button
90+
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">
91+
<component :is="icon" class="h-3.5 w-3.5 text-icon-default" />
92+
<span class="font-medium text-foreground">
93+
{{ type === 'status' ? 'Status' : 'Client' }}
94+
</span>
95+
<span class="text-muted-foreground">is</span>
96+
<span class="text-foreground">{{ label }}</span>
97+
<ChevronDownIcon class="h-3 w-3 text-muted-foreground" />
98+
</button>
99+
</DropdownMenuTrigger>
100+
<DropdownMenuContent align="start">
101+
<!-- Status Options -->
102+
<template v-if="type === 'status' && typeof value === 'string'">
103+
<DropdownMenuItem
104+
v-for="option in statusOptions"
105+
:key="option.id"
106+
:class="[value === option.id && 'bg-accent text-accent-foreground']"
107+
@click="updateStatus(option.id)">
108+
{{ option.name }}
109+
</DropdownMenuItem>
110+
</template>
111+
112+
<!-- Client Options -->
113+
<template v-if="type === 'client' && Array.isArray(value) && clients">
114+
<DropdownMenuCheckboxItem :checked="noClient" @select.prevent="toggleNoClient">
115+
No client
116+
</DropdownMenuCheckboxItem>
117+
<DropdownMenuSeparator />
118+
<DropdownMenuCheckboxItem
119+
v-for="client in clients"
120+
:key="client.id"
121+
:checked="value.includes(client.id)"
122+
@select.prevent="toggleClient(client.id)">
123+
{{ client.name }}
124+
</DropdownMenuCheckboxItem> </template
125+
>#
126+
</DropdownMenuContent>
127+
</DropdownMenu>
128+
129+
<!-- Remove Button -->
130+
<button
131+
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"
132+
@click="emit('remove')">
133+
<XMarkIcon class="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
134+
</button>
135+
</div>
136+
</template>

0 commit comments

Comments
 (0)