feat(posts): multi-select label filter on the posts list

Adds a combobox-style filter to the posts index toolbar so users can
narrow All / Scheduled / Posted / Drafts views by one or more labels.

- `PostController::index` accepts `?labels[]=<id>` and applies
  `whereHas('labels', whereIn(...))` (OR semantics across selected labels).
  Workspace labels are exposed to the page (sorted by name) and the
  selected set comes back under `filters.labels`.
- New `LabelFilter.vue` component reuses the existing Popover + Command
  pattern (matching `FontPicker` in the Brand settings page). Trigger
  renders the selected `LabelBadge`s inline (mirroring how each post row
  already displays its labels): 1-3 shown directly, 4+ shown as the
  first three plus a "+N" overflow indicator. Clear button has a
  tooltip and `cursor-pointer`, and stops `click`/`pointerdown`/
  `mousedown` so it doesn't reopen the Popover.
- Existing search debounce is shared with the new label watcher via a
  single `buildFilterUrl` helper. URL is updated with `preserveState +
  replace` so the back stack stays clean.
- i18n in en / pt-BR / es: `filter_by_label`, `label_search_placeholder`,
  `no_labels`, `clear_label_filter`.

Tests: 4 new index tests covering the labels prop exposure, single-label
filter, multi-label OR filter, and blank-id sanitization. Full suite:
1509 passed, 2 skipped, 0 failed.
This commit is contained in:
Paulo Castellano 2026-05-14 09:57:55 -03:00
parent 154a202b84
commit af96cb0a0e
10 changed files with 267 additions and 15 deletions

View file

@ -57,12 +57,22 @@ public function index(Request $request, ?string $status = null): Response|Redire
$query->where('content', 'ilike', "%{$search}%");
}
$labelIds = array_values(array_filter(
(array) $request->input('labels', []),
fn ($id) => is_string($id) && $id !== '',
));
if (! empty($labelIds)) {
$query->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]);