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

@@ -7,6 +7,7 @@ import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import Pagination from '@/Components/Common/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn =
| 'name'
@@ -34,12 +35,17 @@ import {
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const props = defineProps<{
projects: Project[];
showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const props = withDefaults(
defineProps<{
projects: Project[];
showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
isFiltered?: boolean;
isLoading?: boolean;
}>(),
{ isFiltered: false, isLoading: false }
);
const emit = defineEmits<{
sort: [column: SortColumn, direction: SortDirection];
@@ -128,6 +134,28 @@ const paginatedProjects = computed(() => {
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);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
@@ -161,24 +189,21 @@ const gridTemplate = computed(() => {
:sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@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>
<h3 class="text-text-primary font-semibold">
{{
canCreateProjects()
? 'No projects found'
: 'You are not a member of any projects'
}}
</h3>
<h3 class="text-text-primary font-semibold">{{ emptyState.title }}</h3>
<p class="pb-5 max-w-md mx-auto text-sm pt-1">
{{
canCreateProjects()
? 'Create your first project now!'
: 'Ask your manager to add you to a project as a team member.'
}}
{{ emptyState.description }}
</p>
<SecondaryButton
v-if="canCreateProjects()"
v-if="emptyState.showCreateButton"
:icon="PlusIcon"
@click="showCreateProjectModal = true"
>Create your First Project

View File

@@ -30,7 +30,7 @@ function handleSort(column: SortColumn) {
<template>
<TableHeading>
<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"
v-bind="sortState"
@sort="handleSort">
@@ -58,7 +58,7 @@ function handleSort(column: SortColumn) {
<SortableTableHeaderCell column="visibility" v-bind="sortState" @sort="handleSort">
Visibility
</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>
</div>
</TableHeading>

View File

@@ -88,13 +88,13 @@ const showEditProjectModal = ref(false);
<ContextMenuTrigger as-child>
<TableRow :href="route('projects.show', { project: project.id })">
<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
:style="{
backgroundColor: project.color,
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">
{{ project.name }}
</span>
@@ -155,7 +155,7 @@ const showEditProjectModal = ref(false);
</template>
</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
:project="project"
@edit="showEditProjectModal = true"

View File

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

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
import { Search } from '@lucide/vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { computed, ref } from 'vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
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';
// Fetch data using TanStack Query
const { projects } = useProjectsQuery();
const { projects, isLoading: projectsLoading } = useProjectsQuery();
const { clients } = useClientsQuery();
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
// the table silently filtered by a term the user has forgotten about.
// Not persisted, so a reload never starts silently filtered
const search = ref('');
// 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
function removeStatusFilter() {
tableState.value.filters.status = 'all';
@@ -141,8 +151,7 @@ const showBillableRate = computed(() => {
<template>
<AppLayout title="Projects" data-testid="projects_view">
<MainContainer
class="py-3 sm:pt-5 border-b border-default-background-separator flex justify-between items-center">
<MainContainer class="py-3 sm:pt-5 flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
</div>
@@ -169,14 +178,6 @@ const showBillableRate = computed(() => {
:clients="clients"
@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 -->
<ProjectStatusFilterBadge
v-if="tableState.filters.status !== 'all'"
@@ -203,11 +204,25 @@ const showBillableRate = computed(() => {
:clients="clients"
@remove="removeClientFilter"
@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>
</MainContainer>
<ProjectTable
:show-billable-rate="showBillableRate"
:is-filtered="hasActiveFilters"
:is-loading="projectsLoading"
:projects="filteredProjects"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"