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:
commit
f966b27349
10 changed files with 269 additions and 15 deletions
|
|
@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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',
|
||||
|
|
|
|||
127
resources/js/components/labels/LabelFilter.vue
Normal file
127
resources/js/components/labels/LabelFilter.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue