Merge pull request #34 from trypostit/feat/posts-label-filter

feat(posts): multi-select label filter on the posts list
This commit is contained in:
Paulo Castellano 2026-05-14 10:03:02 -03:00 committed by GitHub
commit f966b27349
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 269 additions and 15 deletions

View file

@ -57,12 +57,24 @@ public function index(Request $request, ?string $status = null): Response|Redire
$query->where('content', 'ilike', "%{$search}%");
}
$labelIds = $request->collect('labels')
->filter(fn ($id) => is_string($id) && $id !== '')
->values()
->all();
$query->when($labelIds, fn ($q) => $q->whereHas(
'labels',
fn ($q) => $q->whereIn('workspace_labels.id', $labelIds),
));
return Inertia::render('posts/Index', [
'workspace' => $workspace,
'posts' => Inertia::scroll(fn () => $query->latest('scheduled_at')->paginate(config('app.pagination.default'))),
'currentStatus' => $status,
'labels' => $workspace->labels()->orderBy('name')->get(['id', 'name', 'color']),
'filters' => [
'search' => $request->input('search', ''),
'labels' => $labelIds,
],
]);
}

View file

@ -9,6 +9,10 @@
'no_search_results' => 'No posts match your search',
'try_different_search' => 'Try a different keyword or clear the search.',
'start_creating' => 'Start by creating your first post.',
'filter_by_label' => 'Filter by label',
'label_search_placeholder' => 'Search labels...',
'no_labels' => 'No labels found.',
'clear_label_filter' => 'Clear label filter',
'table' => [
'post' => 'Post',
'status' => 'Status',

View file

@ -9,6 +9,10 @@
'no_search_results' => 'Ningún post coincide con tu búsqueda',
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
'start_creating' => 'Empieza creando tu primer post.',
'filter_by_label' => 'Filtrar por etiqueta',
'label_search_placeholder' => 'Buscar etiquetas...',
'no_labels' => 'No se encontraron etiquetas.',
'clear_label_filter' => 'Limpiar filtro de etiquetas',
'table' => [
'post' => 'Post',
'status' => 'Estado',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -9,6 +9,10 @@
'no_search_results' => 'Nenhum post corresponde à sua busca',
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
'start_creating' => 'Comece criando seu primeiro post.',
'filter_by_label' => 'Filtrar por label',
'label_search_placeholder' => 'Buscar labels...',
'no_labels' => 'Nenhuma label encontrada.',
'clear_label_filter' => 'Limpar filtro de labels',
'table' => [
'post' => 'Post',
'status' => 'Status',

View file

@ -0,0 +1,127 @@
<script setup lang="ts">
import { IconCheck, IconChevronDown, IconTag, IconX } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import LabelBadge from '@/components/labels/LabelBadge.vue';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
interface Label {
id: string;
name: string;
color: string;
}
interface Props {
labels: Label[];
}
const props = defineProps<Props>();
const selectedIds = defineModel<string[]>({ required: true });
const open = ref(false);
const selectedLabels = computed<Label[]>(() =>
props.labels.filter((l) => selectedIds.value.includes(l.id)),
);
const isSelected = (id: string) => selectedIds.value.includes(id);
const toggle = (id: string) => {
selectedIds.value = isSelected(id)
? selectedIds.value.filter((existing) => existing !== id)
: [...selectedIds.value, id];
};
const clear = () => {
selectedIds.value = [];
};
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
type="button"
variant="outline"
role="combobox"
:aria-expanded="open"
class="justify-between gap-2 font-normal"
>
<IconTag class="size-4 shrink-0 opacity-60" />
<template v-if="selectedLabels.length === 0">
<span class="text-foreground/70">{{ trans('posts.filter_by_label') }}</span>
</template>
<template v-else>
<div class="flex flex-wrap items-center gap-1">
<LabelBadge
v-for="label in selectedLabels.slice(0, 3)"
:key="label.id"
:label="label"
/>
<span
v-if="selectedLabels.length > 3"
class="text-xs font-bold text-foreground/60"
>+{{ selectedLabels.length - 3 }}</span>
</div>
</template>
<TooltipProvider v-if="selectedIds.length" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="ml-1 inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded text-foreground/60 hover:text-foreground"
:aria-label="trans('posts.clear_label_filter')"
@click.stop="clear"
@pointerdown.stop
@mousedown.stop
>
<IconX class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{{ trans('posts.clear_label_filter') }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<IconChevronDown v-else class="size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-[--reka-popover-trigger-width] min-w-[220px] p-0" align="start">
<Command>
<CommandInput :placeholder="trans('posts.label_search_placeholder')" />
<CommandList>
<CommandEmpty>{{ trans('posts.no_labels') }}</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="label in labels"
:key="label.id"
:value="label.name"
@select="toggle(label.id)"
>
<LabelBadge :label="label" />
<IconCheck
:class="cn('ml-auto size-4', isSelected(label.id) ? 'opacity-100' : 'opacity-0')"
/>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>

View file

@ -8,6 +8,7 @@ import { create as createPost, destroy as destroyPost, duplicate as duplicatePos
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import LabelBadge from '@/components/labels/LabelBadge.vue';
import LabelFilter from '@/components/labels/LabelFilter.vue';
import PageHeader from '@/components/PageHeader.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -85,29 +86,38 @@ interface Props {
workspace: Workspace;
posts: ScrollPosts;
currentStatus: string | null;
labels: Label[];
filters: {
search: string;
labels: string[];
};
}
const props = defineProps<Props>();
const searchQuery = ref(props.filters.search);
const selectedLabelIds = ref<string[]>(props.filters.labels ?? []);
const search = debounce(() => {
const buildFilterUrl = () => {
const url = props.currentStatus ? postsIndex.url(props.currentStatus) : postsIndex.url();
router.get(
url,
{ search: searchQuery.value || undefined },
{
search: searchQuery.value || undefined,
labels: selectedLabelIds.value.length ? selectedLabelIds.value : undefined,
},
{
preserveState: true,
preserveScroll: true,
replace: true,
},
);
}, 300);
};
const search = debounce(buildFilterUrl, 300);
watch(searchQuery, () => search());
watch(selectedLabelIds, () => buildFilterUrl(), { deep: true });
const pageTitle = computed(() => {
if (props.currentStatus) {
@ -148,6 +158,8 @@ const handleCopyId = (post: Post) => copyToClipboard(post.id, trans('posts.actio
const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
const hasActiveFilters = computed(() => hasActiveSearch.value || selectedLabelIds.value.length > 0);
const refreshPosts = () => router.reload({ only: ['posts'], reset: ['posts'] });
useWorkspaceEcho(
@ -165,13 +177,17 @@ useWorkspaceEcho(
<!-- Toolbar -->
<div class="flex items-center justify-between gap-3">
<div class="relative">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-foreground/60" />
<Input
v-model="searchQuery"
:placeholder="trans('posts.search')"
class="w-64 pl-9"
/>
<div class="flex items-center gap-3">
<div class="relative">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-foreground/60" />
<Input
v-model="searchQuery"
:placeholder="trans('posts.search')"
class="w-64 pl-9"
/>
</div>
<LabelFilter v-if="labels.length" v-model="selectedLabelIds" :labels="labels" />
</div>
<Link :href="createPost.url()">
@ -182,8 +198,8 @@ useWorkspaceEcho(
<EmptyState
v-if="posts.data.length === 0"
:icon="IconFileText"
:title="hasActiveSearch ? $t('posts.no_search_results') : $t('posts.no_posts')"
:description="hasActiveSearch ? $t('posts.try_different_search') : $t('posts.start_creating')"
:title="hasActiveFilters ? $t('posts.no_search_results') : $t('posts.no_posts')"
:description="hasActiveFilters ? $t('posts.try_different_search') : $t('posts.start_creating')"
/>
<div v-else>

View file

@ -50,6 +50,93 @@
);
});
test('posts index exposes workspace labels for filter dropdown', function () {
WorkspaceLabel::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
WorkspaceLabel::factory()->create(); // belongs to a different workspace; must not leak.
$response = $this->actingAs($this->user)->get(route('app.posts.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('labels', 3)
->where('filters.labels', [])
);
});
test('posts index filters posts by a single label id', function () {
$label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$taggedPost = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$taggedPost->labels()->attach($label);
Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$response = $this->actingAs($this->user)
->get(route('app.posts.index', ['labels' => [$label->id]]));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('posts.data', 1)
->where('posts.data.0.id', $taggedPost->id)
->where('filters.labels', [$label->id])
);
});
test('posts index filters posts by multiple labels (OR semantics)', function () {
$marketing = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$sales = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$unrelated = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$postWithMarketing = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$postWithMarketing->labels()->attach($marketing);
$postWithSales = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$postWithSales->labels()->attach($sales);
$postWithUnrelated = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$postWithUnrelated->labels()->attach($unrelated);
$response = $this->actingAs($this->user)
->get(route('app.posts.index', ['labels' => [$marketing->id, $sales->id]]));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('posts.data', 2)
->where('filters.labels', [$marketing->id, $sales->id])
);
});
test('posts index ignores blank label query params', function () {
Post::factory()->count(2)->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$response = $this->actingAs($this->user)
->get(route('app.posts.index', ['labels' => ['']]));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('posts.data', 2)
->where('filters.labels', [])
);
});
test('posts index redirects to create workspace if no workspace', function () {
$this->user->update(['current_workspace_id' => null]);