polish projects search ui, fix empty states

This commit is contained in:
Gregor Vostrak
2026-08-27 18:21:38 +02:00
parent 9958e1c09c
commit fb7d945c50
6 changed files with 95 additions and 55 deletions

View File

@@ -1302,17 +1302,22 @@ test('test that searching projects by name works', async ({ page, ctx }) => {
await expect(page.getByText(matchingProjectName)).toBeVisible({ timeout: 10000 }); await expect(page.getByText(matchingProjectName)).toBeVisible({ timeout: 10000 });
await expect(page.getByText(otherProjectName)).toBeVisible(); await expect(page.getByText(otherProjectName)).toBeVisible();
const searchInput = page.getByRole('searchbox', { name: 'Search projects' });
// Searching is case insensitive and matches part of the name // Searching is case insensitive and matches part of the name
await page.getByTestId('project_search').fill('searchable project ' + suffix); await searchInput.fill('SEARCHABLE');
await expect(page.getByText(matchingProjectName)).toBeVisible(); await expect(page.getByText(matchingProjectName)).toBeVisible();
await expect(page.getByText(otherProjectName)).not.toBeVisible(); await expect(page.getByText(otherProjectName)).not.toBeVisible();
// A term that matches nothing empties the table // A term that matches nothing empties the table
await page.getByTestId('project_search').fill('no project has this name'); await searchInput.fill('no project has this name');
await expect(page.getByText(matchingProjectName)).not.toBeVisible(); await expect(page.getByText(matchingProjectName)).not.toBeVisible();
await expect(page.getByText(otherProjectName)).not.toBeVisible();
await expect(page.getByText('No matching projects')).toBeVisible();
await expect(page.getByRole('button', { name: 'Create your First Project' })).not.toBeVisible();
// Clearing the search restores both projects // Clearing the search restores both projects
await page.getByTestId('project_search').fill(''); await searchInput.fill('');
await expect(page.getByText(matchingProjectName)).toBeVisible(); await expect(page.getByText(matchingProjectName)).toBeVisible();
await expect(page.getByText(otherProjectName)).toBeVisible(); await expect(page.getByText(otherProjectName)).toBeVisible();
}); });

View File

@@ -7,6 +7,7 @@ import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue'; import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue'; import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import Pagination from '@/Components/Common/Pagination.vue'; import Pagination from '@/Components/Common/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn = export type SortColumn =
| 'name' | 'name'
@@ -34,12 +35,17 @@ import {
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!); const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const props = defineProps<{ const props = withDefaults(
defineProps<{
projects: Project[]; projects: Project[];
showBillableRate: boolean; showBillableRate: boolean;
sortColumn: SortColumn; sortColumn: SortColumn;
sortDirection: SortDirection; sortDirection: SortDirection;
}>(); isFiltered?: boolean;
isLoading?: boolean;
}>(),
{ isFiltered: false, isLoading: false }
);
const emit = defineEmits<{ const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection]; sort: [column: SortColumn, direction: SortDirection];
@@ -128,6 +134,28 @@ const paginatedProjects = computed(() => {
return sortedProjects.value.slice(start, start + PAGE_SIZE); return sortedProjects.value.slice(start, start + PAGE_SIZE);
}); });
const emptyState = computed(() => {
if (props.isFiltered) {
return {
title: 'No matching projects',
description: 'Try a different search term or adjust your filters.',
showCreateButton: false,
};
}
if (!canCreateProjects()) {
return {
title: 'You are not a member of any projects',
description: 'Ask your manager to add you to a project as a team member.',
showCreateButton: false,
};
}
return {
title: 'No projects found',
description: 'Create your first project now!',
showCreateButton: true,
};
});
const showCreateProjectModal = ref(false); const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> { async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
@@ -161,24 +189,21 @@ const gridTemplate = computed(() => {
:sort-direction="props.sortDirection" :sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns" :desc-first-columns="descFirstColumns"
@sort="handleSort"></ProjectTableHeading> @sort="handleSort"></ProjectTableHeading>
<div v-if="sortedProjects.length === 0" class="col-span-full py-24 text-center"> <div
v-if="props.isLoading"
class="col-span-full flex justify-center items-center py-24">
<LoadingSpinner></LoadingSpinner>
</div>
<div
v-else-if="sortedProjects.length === 0"
class="col-span-full py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon> <FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold"> <h3 class="text-text-primary font-semibold">{{ emptyState.title }}</h3>
{{
canCreateProjects()
? 'No projects found'
: 'You are not a member of any projects'
}}
</h3>
<p class="pb-5 max-w-md mx-auto text-sm pt-1"> <p class="pb-5 max-w-md mx-auto text-sm pt-1">
{{ {{ emptyState.description }}
canCreateProjects()
? 'Create your first project now!'
: 'Ask your manager to add you to a project as a team member.'
}}
</p> </p>
<SecondaryButton <SecondaryButton
v-if="canCreateProjects()" v-if="emptyState.showCreateButton"
:icon="PlusIcon" :icon="PlusIcon"
@click="showCreateProjectModal = true" @click="showCreateProjectModal = true"
>Create your First Project >Create your First Project

View File

@@ -30,7 +30,7 @@ function handleSort(column: SortColumn) {
<template> <template>
<TableHeading> <TableHeading>
<SortableTableHeaderCell <SortableTableHeaderCell
class="pr-3 pl-4 sm:pl-6 lg:pl-8 3xl:pl-12" class="pr-3 pl-2 sm:pl-4 lg:pl-6"
column="name" column="name"
v-bind="sortState" v-bind="sortState"
@sort="handleSort"> @sort="handleSort">
@@ -58,7 +58,7 @@ function handleSort(column: SortColumn) {
<SortableTableHeaderCell column="visibility" v-bind="sortState" @sort="handleSort"> <SortableTableHeaderCell column="visibility" v-bind="sortState" @sort="handleSort">
Visibility Visibility
</SortableTableHeaderCell> </SortableTableHeaderCell>
<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-2 sm:pr-4 lg:pr-6">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>
</div> </div>
</TableHeading> </TableHeading>

View File

@@ -88,13 +88,13 @@ const showEditProjectModal = ref(false);
<ContextMenuTrigger as-child> <ContextMenuTrigger as-child>
<TableRow :href="route('projects.show', { project: project.id })"> <TableRow :href="route('projects.show', { project: project.id })">
<div <div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
<div <div
:style="{ :style="{
backgroundColor: project.color, backgroundColor: project.color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`, boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}" }"
class="w-3 h-3 rounded-full"></div> class="w-3 h-3 ml-1 rounded-full"></div>
<span class="overflow-ellipsis overflow-hidden"> <span class="overflow-ellipsis overflow-hidden">
{{ project.name }} {{ project.name }}
</span> </span>
@@ -155,7 +155,7 @@ const showEditProjectModal = ref(false);
</template> </template>
</div> </div>
<div <div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-2 sm:pr-4 lg:pr-6">
<ProjectMoreOptionsDropdown <ProjectMoreOptionsDropdown
:project="project" :project="project"
@edit="showEditProjectModal = true" @edit="showEditProjectModal = true"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { ref } from 'vue';
import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid'; import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid';
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue'; import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
import { import {
@@ -81,23 +81,18 @@ function toggleNoClient() {
clientIds, clientIds,
}); });
} }
const hasActiveFilters = computed(() => {
return (
props.filters.status !== 'all' ||
props.filters.visibility !== 'all' ||
props.filters.clientIds.length > 0
);
});
</script> </script>
<template> <template>
<DropdownMenu v-model:open="open"> <DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<Button variant="ghost" size="xs" aria-label="Filter projects"> <!-- -ml-1.5 cancels the icon's inset inside the button so it sits flush with the toolbar's left padding -->
<ListFilterIcon <Button
:class="[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']" /> variant="ghost"
<span v-if="!hasActiveFilters" class="text-nowrap">Filter</span> size="icon"
class="-ml-1.5 h-7 w-7 flex-shrink-0"
aria-label="Filter projects">
<ListFilterIcon class="h-4 w-4 text-icon-default" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-56"> <DropdownMenuContent align="start" class="w-56">

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid'; import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
import { Search } from '@lucide/vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue'; import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
@@ -27,7 +29,7 @@ import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
// Fetch data using TanStack Query // Fetch data using TanStack Query
const { projects } = useProjectsQuery(); const { projects, isLoading: projectsLoading } = useProjectsQuery();
const { clients } = useClientsQuery(); const { clients } = useClientsQuery();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!); const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
@@ -62,8 +64,7 @@ const { tableState, handleSort } = useTableSortState<SortColumn, ProjectTableSta
}) })
); );
// Search is intentionally not persisted in tableState, so a reload never leaves // Not persisted, so a reload never starts silently filtered
// the table silently filtered by a term the user has forgotten about.
const search = ref(''); const search = ref('');
// Filter projects based on current filters // Filter projects based on current filters
@@ -109,6 +110,15 @@ const filteredProjects = computed(() => {
}); });
}); });
const hasActiveFilters = computed(() => {
return (
search.value.trim() !== '' ||
tableState.value.filters.status !== 'all' ||
tableState.value.filters.visibility !== 'all' ||
tableState.value.filters.clientIds.length > 0
);
});
// Helper functions for active filters // Helper functions for active filters
function removeStatusFilter() { function removeStatusFilter() {
tableState.value.filters.status = 'all'; tableState.value.filters.status = 'all';
@@ -141,8 +151,7 @@ const showBillableRate = computed(() => {
<template> <template>
<AppLayout title="Projects" data-testid="projects_view"> <AppLayout title="Projects" data-testid="projects_view">
<MainContainer <MainContainer class="py-3 sm:pt-5 flex justify-between items-center">
class="py-3 sm:pt-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6"> <div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle> <PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
</div> </div>
@@ -169,14 +178,6 @@ const showBillableRate = computed(() => {
:clients="clients" :clients="clients"
@update:filters="tableState.filters = $event" /> @update:filters="tableState.filters = $event" />
<!-- Same style as the dropdown search inputs, see MultiselectDropdown.vue -->
<input
v-model="search"
type="search"
data-testid="project_search"
placeholder="Search for a Project..."
class="w-60 h-8 rounded-md border border-input-border bg-input-background px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none" />
<!-- Active Filters --> <!-- Active Filters -->
<ProjectStatusFilterBadge <ProjectStatusFilterBadge
v-if="tableState.filters.status !== 'all'" v-if="tableState.filters.status !== 'all'"
@@ -203,11 +204,25 @@ const showBillableRate = computed(() => {
:clients="clients" :clients="clients"
@remove="removeClientFilter" @remove="removeClientFilter"
@update:value="tableState.filters.clientIds = $event as string[]" /> @update:value="tableState.filters.clientIds = $event as string[]" />
<div class="relative">
<Search
class="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-icon-default" />
<TextInput
v-model="search"
size="sm"
type="search"
aria-label="Search projects"
placeholder="Search projects..."
class="w-60 border-transparent bg-transparent pl-7 shadow-none placeholder:text-text-tertiary hover:bg-black/5 focus-visible:bg-input-background dark:hover:bg-white/5 [&::-webkit-search-cancel-button]:hidden" />
</div>
</div> </div>
</MainContainer> </MainContainer>
<ProjectTable <ProjectTable
:show-billable-rate="showBillableRate" :show-billable-rate="showBillableRate"
:is-filtered="hasActiveFilters"
:is-loading="projectsLoading"
:projects="filteredProjects" :projects="filteredProjects"
:sort-column="tableState.sortColumn" :sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection" :sort-direction="tableState.sortDirection"