filters
Filters with data grid
Filters with data grid
Загрузка превью…
src/filters/c-filters-7.vue
<script setup lang="ts">
/**
* Порт `c-filters-7.tsx` ("Filters with data grid").
*
* Связь Filters -> DataGrid: `applyFiltersToData` — та же чистая функция,
* что и в оригинале (переключатель по `operator`, применяется
* последовательно по каждому активному фильтру к локальному массиву
* `demoData`), передана в `useVueTable` через `get data()` вместо
* `getFilteredRowModel`: сам оригинал фильтрует данные вручную ДО того, как
* они попадают в `useReactTable` (`data={filteredData}`), а не через
* колоночный `filterFn`/`getFilteredRowModel` — тот же приём перенесён как
* есть, `useVueTable` здесь тоже не получает `getFilteredRowModel`.
*
* Оригинал симулирует сетевую задержку 800-2000мс (`setTimeout` +
* `Math.random()`) на каждое изменение фильтров. Задача явно требует убрать
* реальные задержки, влияющие на рендер стенда: `simulateAsyncFiltering`
* оставлен асинхронным (сохраняет форму `isLoading` -> await -> результат),
* но ждёт разрешённый `Promise.resolve()` вместо `setTimeout` со случайной
* длительностью. На первый рендер это не влияет ни в оригинале (там
* async-путь тоже не запускается на mount, только при `onChange`), ни здесь.
*
* Аватарки (`images.unsplash.com`, детерминированы по ID фото) оставлены
* как реальные сетевые запросы — тот же выбор, что и в `block-card`/
* `c-data-grid-6` (см. docs/PORTING.md §5 "picsum.photos... не unsplash").
* `flagcdn.com/{flag}.svg` — недетерминированный сетевой запрос в headless-
* стенде, заменён локальным 1x1 data-URI `PIXEL`, тот же приём, что и в
* `c-data-grid-1`/`c-data-grid-21`.
*
* Демо-данные урезаны с 12 до 7 строк (первые 7 записей апстрима, id 1-7).
*/
import { h, ref, type VNode } from "vue"
import { Alert, AlertTitle } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { DataGrid, DataGridColumnHeader, DataGridContainer, DataGridPagination, DataGridScrollArea, DataGridTable } from "@/components/reui/data-grid"
import { createFilter, Filters, type Filter, type FilterFieldConfig } from "@/components/reui/filters"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useVueTable,
} from "@tanstack/vue-table"
import type { ColumnDef, PaginationState, SortingState } from "@tanstack/vue-table"
const PIXEL =
"data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs="
interface IData {
id: string
name: string
availability: "online" | "away" | "busy" | "offline"
avatar: string
status: "active" | "inactive"
flag: string
email: string
company: string
role: string
joined: string
location: string
balance: number
}
const demoData: IData[] = [
{
id: "1",
name: "Alex Johnson",
availability: "online",
avatar: "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "us",
email: "alex@apple.com",
company: "Apple",
role: "CEO",
joined: "Jan, 2024",
location: "San Francisco, USA",
balance: 5143.03,
},
{
id: "2",
name: "Sarah Chen",
availability: "away",
avatar: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "gb",
email: "sarah@openai.com",
company: "OpenAI",
role: "CTO",
joined: "Mar, 2023",
location: "London, UK",
balance: 4321.87,
},
{
id: "3",
name: "Michael Rodriguez",
availability: "busy",
avatar: "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "ca",
email: "michael@meta.com",
company: "Meta",
role: "Designer",
joined: "Jun, 2022",
location: "Toronto, Canada",
balance: 7654.98,
},
{
id: "4",
name: "Emma Wilson",
availability: "offline",
avatar: "https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "au",
email: "emma@tesla.com",
company: "Tesla",
role: "Developer",
joined: "Sep, 2024",
location: "Sydney, Australia",
balance: 3456.45,
},
{
id: "5",
name: "David Kim",
availability: "online",
avatar: "https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "de",
email: "david@sap.com",
company: "SAP",
role: "Lawyer",
joined: "Nov, 2023",
location: "Berlin, Germany",
balance: 9876.54,
},
{
id: "6",
name: "Aron Thompson",
availability: "away",
avatar: "https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "my",
email: "aron@keenthemes.com",
company: "Keenthemes",
role: "Director",
joined: "Feb, 2022",
location: "Kuala Lumpur, MY",
balance: 6214.22,
},
{
id: "7",
name: "James Brown",
availability: "busy",
avatar: "https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "es",
email: "james@bbva.es",
company: "BBVA",
role: "Product Manager",
joined: "Aug, 2024",
location: "Barcelona, Spain",
balance: 5321.77,
},
]
function icon(cls: string, children: VNode[]) {
return h(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
class: cls,
},
children
)
}
function userIcon() {
return icon("size-3.5", [
h("path", { d: "M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" }),
h("circle", { cx: "12", cy: "7", r: "4" }),
])
}
function mailIcon() {
return icon("size-3.5", [
h("path", { d: "m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7" }),
h("rect", { x: "2", y: "4", width: "20", height: "16", rx: "2" }),
])
}
function buildingIcon() {
return icon("size-3.5", [
h("path", { d: "M12 10h.01" }),
h("path", { d: "M12 14h.01" }),
h("path", { d: "M12 6h.01" }),
h("path", { d: "M16 10h.01" }),
h("path", { d: "M16 14h.01" }),
h("path", { d: "M16 6h.01" }),
h("path", { d: "M8 10h.01" }),
h("path", { d: "M8 14h.01" }),
h("path", { d: "M8 6h.01" }),
h("path", { d: "M9 22v-3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" }),
h("rect", { x: "4", y: "2", width: "16", height: "20", rx: "2" }),
])
}
function mapPinIcon() {
return icon("size-3.5", [
h("path", {
d: "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0",
}),
h("circle", { cx: "12", cy: "10", r: "3" }),
])
}
function listFilterIcon() {
return icon("", [
h("path", { d: "M2 5h20" }),
h("path", { d: "M6 12h12" }),
h("path", { d: "M9 19h6" }),
])
}
function funnelXIcon() {
return icon("", [
h("path", {
d: "M12.531 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14v6a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341l.427-.473",
}),
h("path", { d: "m16.5 3.5 5 5" }),
h("path", { d: "m21.5 3.5-5 5" }),
])
}
function circleAlertIcon() {
return icon("", [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("line", { x1: "12", x2: "12", y1: "8", y2: "12" }),
h("line", { x1: "12", x2: "12.01", y1: "16", y2: "16" }),
])
}
function AvailabilityStatus(availability: string) {
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "bg-green-500"
case "away":
return "bg-yellow-500"
case "busy":
return "bg-red-500"
case "offline":
return "bg-gray-400"
default:
return "bg-gray-400"
}
}
const getStatusLabel = (status: string) => {
switch (status) {
case "online":
return "Online"
case "away":
return "Away"
case "busy":
return "Busy"
case "offline":
return "Offline"
default:
return "Unknown"
}
}
return h("div", { class: "flex items-center gap-1.5" }, [
h("div", { class: `size-2 rounded-full ${getStatusColor(availability)}` }),
h("span", { class: "text-muted-foreground text-sm" }, getStatusLabel(availability)),
])
}
// Helper to check if a filter has meaningful values
function getActiveFilters(filters: Filter<string>[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (values.every((value) => typeof value === "string" && value.trim() === "")) return false
if (values.every((value) => value === null || value === undefined)) return false
if (values.every((value) => Array.isArray(value) && (value as unknown[]).length === 0))
return false
return true
})
}
const pagination = ref<PaginationState>({ pageIndex: 0, pageSize: 5 })
const sorting = ref<SortingState>([{ id: "name", desc: false }])
const filters = ref<Filter<string>[]>([createFilter("status", "is", ["active"])])
const isLoading = ref(false)
const filteredData = ref<IData[]>(applyFiltersToData(filters.value))
const fields: FilterFieldConfig<string>[] = [
{
key: "name",
label: "Name",
icon: userIcon,
type: "text",
class: "w-40",
placeholder: "Search names...",
},
{
key: "email",
label: "Email",
icon: mailIcon,
type: "text",
class: "w-48",
placeholder: "user@example.com",
},
{
key: "company",
label: "Company",
icon: buildingIcon,
type: "select",
searchable: true,
class: "w-[180px]",
options: [
{ value: "Apple", label: "Apple" },
{ value: "OpenAI", label: "OpenAI" },
{ value: "Meta", label: "Meta" },
{ value: "Tesla", label: "Tesla" },
{ value: "SAP", label: "SAP" },
{ value: "Keenthemes", label: "Keenthemes" },
{ value: "BBVA", label: "BBVA" },
{ value: "Sony", label: "Sony" },
{ value: "LVMH", label: "LVMH" },
{ value: "ENI", label: "ENI" },
{ value: "Vale", label: "Vale" },
{ value: "Tata", label: "Tata" },
],
},
{
key: "role",
label: "Role",
icon: userIcon,
type: "select",
searchable: true,
class: "w-[160px]",
options: [
{ value: "CEO", label: "CEO" },
{ value: "CTO", label: "CTO" },
{ value: "Designer", label: "Designer" },
{ value: "Developer", label: "Developer" },
{ value: "Lawyer", label: "Lawyer" },
{ value: "Director", label: "Director" },
{ value: "Product Manager", label: "Product Manager" },
{ value: "Marketing Lead", label: "Marketing Lead" },
{ value: "Data Scientist", label: "Data Scientist" },
{ value: "Engineer", label: "Engineer" },
{ value: "Software Engineer", label: "Software Engineer" },
{ value: "Sales Manager", label: "Sales Manager" },
],
},
{
key: "status",
label: "Status",
icon: userIcon,
type: "select",
searchable: false,
class: "w-[140px]",
options: [
{ value: "active", label: "Active", icon: () => h("div", { class: "size-2 rounded-full bg-green-500" }) },
{ value: "inactive", label: "Inactive", icon: () => h("div", { class: "bg-destructive size-2 rounded-full" }) },
{ value: "archived", label: "Archived", icon: () => h("div", { class: "size-2 rounded-full bg-zinc-400" }) },
],
},
{
key: "availability",
label: "Availability",
icon: userIcon,
type: "select",
searchable: false,
class: "w-[160px]",
options: [
{
value: "online",
label: "Online",
icon: () =>
h("div", { class: "flex items-center gap-2" }, [
h("div", { class: "size-2 rounded-full bg-green-500" }),
h("span", "Online"),
]),
},
{
value: "away",
label: "Away",
icon: () =>
h("div", { class: "flex items-center gap-2" }, [
h("div", { class: "size-2 rounded-full bg-yellow-500" }),
h("span", "Away"),
]),
},
{
value: "busy",
label: "Busy",
icon: () =>
h("div", { class: "flex items-center gap-2" }, [
h("div", { class: "size-2 rounded-full bg-red-500" }),
h("span", "Busy"),
]),
},
{
value: "offline",
label: "Offline",
icon: () =>
h("div", { class: "flex items-center gap-2" }, [
h("div", { class: "size-2 rounded-full bg-gray-400" }),
h("span", "Offline"),
]),
},
],
},
{
key: "location",
label: "Location",
icon: mapPinIcon,
type: "text",
class: "w-40",
placeholder: "Search locations...",
},
]
// Apply filters to data (shared function, ported verbatim from the switch on `operator`)
function applyFiltersToData(newFilters: Filter<string>[]): IData[] {
let filtered = [...demoData]
const activeFilters = getActiveFilters(newFilters)
activeFilters.forEach((filter) => {
const { field, operator, values } = filter
filtered = filtered.filter((item) => {
const fieldValue = item[field as keyof IData]
switch (operator) {
case "is":
return values.includes(fieldValue as string)
case "is_not":
return !values.includes(fieldValue as string)
case "contains":
return values.some((value) => String(fieldValue).toLowerCase().includes(String(value).toLowerCase()))
case "not_contains":
return !values.some((value) => String(fieldValue).toLowerCase().includes(String(value).toLowerCase()))
case "equals":
return fieldValue === values[0]
case "not_equals":
return fieldValue !== values[0]
case "greater_than":
return Number(fieldValue) > Number(values[0])
case "less_than":
return Number(fieldValue) < Number(values[0])
case "greater_than_or_equal":
return Number(fieldValue) >= Number(values[0])
case "less_than_or_equal":
return Number(fieldValue) <= Number(values[0])
case "between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) >= min && Number(fieldValue) <= max
}
return true
case "not_between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) < min || Number(fieldValue) > max
}
return true
case "before":
return new Date(String(fieldValue)) < new Date(String(values[0]))
case "after":
return new Date(String(fieldValue)) > new Date(String(values[0]))
default:
return true
}
})
})
return filtered
}
// ponytail: no real network — deterministic microtask instead of the
// original's random 800-2000ms setTimeout, so this component's rendered
// state is stable rather than a timing simulation.
async function simulateAsyncFiltering(newFilters: Filter<string>[]) {
isLoading.value = true
await Promise.resolve()
filteredData.value = applyFiltersToData(newFilters)
isLoading.value = false
}
function handleFiltersChange(newFilters: Filter<string>[]) {
const oldActive = getActiveFilters(filters.value)
const newActive = getActiveFilters(newFilters)
filters.value = newFilters
if (JSON.stringify(oldActive) === JSON.stringify(newActive)) {
return
}
pagination.value = { ...pagination.value, pageIndex: 0 }
simulateAsyncFiltering(newFilters)
}
function clearFilters() {
filters.value = []
simulateAsyncFiltering([])
}
const columns: ColumnDef<IData>[] = [
{
accessorKey: "name",
id: "name",
header: (ctx) => h(DataGridColumnHeader, { column: ctx.column as never, title: "Staff" }),
cell: (ctx) =>
h("div", { class: "flex items-center gap-3" }, [
h(Avatar, { class: "size-8" }, () => [
h(AvatarImage, { src: ctx.row.original.avatar, alt: ctx.row.original.name }),
h(AvatarFallback, () => ctx.row.original.name.split(" ").map((n) => n[0]).join("")),
]),
h("div", { class: "space-y-px" }, [
h("div", { class: "text-foreground font-medium" }, ctx.row.original.name),
h("div", { class: "text-muted-foreground truncate text-xs" }, ctx.row.original.email),
]),
]),
size: 200,
enableSorting: true,
enableHiding: false,
meta: {
skeleton: h("div", { class: "flex items-center gap-3" }, [
h(Skeleton, { class: "size-8 rounded-full" }),
h("div", { class: "space-y-1" }, [h(Skeleton, { class: "h-4 w-24" }), h(Skeleton, { class: "h-4 w-16" })]),
]),
},
},
{
accessorKey: "company",
id: "company",
header: (ctx) => h(DataGridColumnHeader, { column: ctx.column as never, title: "Company" }),
cell: (info) => info.getValue() as string,
size: 150,
enableSorting: true,
enableHiding: false,
meta: {
skeleton: h(Skeleton, { class: "h-4 w-20" }),
},
},
{
accessorKey: "role",
id: "role",
header: (ctx) => h(DataGridColumnHeader, { column: ctx.column as never, title: "Occupation" }),
cell: (info) => info.getValue() as string,
size: 125,
enableSorting: true,
enableHiding: false,
meta: {
skeleton: h(Skeleton, { class: "h-4 w-16" }),
},
},
{
accessorKey: "status",
id: "status",
header: "Status",
cell: (ctx) => {
const status = ctx.row.original.status
if (status === "active") return h(Badge, { variant: "success-outline" }, () => "Active")
if (status === "inactive") return h(Badge, { variant: "destructive-outline" }, () => "Inactive")
return undefined
},
size: 100,
meta: {
skeleton: h(Skeleton, { class: "h-4 w-16 rounded-full" }),
},
},
{
accessorKey: "availability",
id: "availability",
header: "Availability",
cell: (ctx) => AvailabilityStatus(ctx.row.original.availability),
size: 120,
enableSorting: true,
meta: {
skeleton: h("div", { class: "flex items-center gap-1.5" }, [
h(Skeleton, { class: "size-4 rounded-full" }),
h(Skeleton, { class: "h-3.5 w-12" }),
]),
},
},
{
accessorKey: "location",
id: "location",
header: (ctx) => h(DataGridColumnHeader, { column: ctx.column as never, title: "Location" }),
cell: (ctx) =>
h("div", { class: "flex items-center gap-2" }, [
h("img", { src: PIXEL, alt: ctx.row.original.flag, class: "size-4 rounded-full object-cover" }),
h("span", ctx.row.original.location),
]),
size: 180,
enableSorting: true,
meta: {
skeleton: h("div", { class: "flex items-center gap-2" }, [
h(Skeleton, { class: "size-4 rounded" }),
h(Skeleton, { class: "h-3.5 w-24" }),
]),
},
},
{
accessorKey: "balance",
id: "balance",
header: (ctx) => h(DataGridColumnHeader, { column: ctx.column as never, title: "Balance" }),
cell: (ctx) => h("span", { class: "font-medium" }, `$${ctx.row.original.balance.toLocaleString()}`),
size: 120,
enableSorting: true,
meta: {
skeleton: h(Skeleton, { class: "h-4 w-16" }),
},
},
]
const columnOrder = ref<string[]>(columns.map((column) => column.id as string))
const table = useVueTable({
columns,
get data() {
return filteredData.value
},
get pageCount() {
return Math.ceil((filteredData.value?.length || 0) / pagination.value.pageSize)
},
getRowId: (row: IData) => row.id,
state: {
get pagination() {
return pagination.value
},
get sorting() {
return sorting.value
},
get columnOrder() {
return columnOrder.value
},
},
onColumnOrderChange: (updater) => {
columnOrder.value = typeof updater === "function" ? updater(columnOrder.value) : updater
},
onPaginationChange: (updater) => {
pagination.value = typeof updater === "function" ? updater(pagination.value) : updater
},
onSortingChange: (updater) => {
sorting.value = typeof updater === "function" ? updater(sorting.value) : updater
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
</script>
<template>
<div class="w-full self-start">
<!-- Filters Section -->
<div class="mb-3.5 flex items-start gap-2.5">
<div class="flex-1">
<Filters :filters="filters" :fields="fields" size="sm" @change="handleFiltersChange">
<template #trigger>
<Button variant="outline" size="icon-sm">
<component :is="listFilterIcon" />
</Button>
</template>
</Filters>
</div>
<Button v-if="filters.length > 0" variant="outline" size="sm" :disabled="isLoading" @click="clearFilters">
<component :is="funnelXIcon" />
Clear
</Button>
</div>
<!-- Data Grid -->
<DataGrid
:table="table"
:is-loading="isLoading"
loading-mode="skeleton"
:record-count="filteredData?.length || 0"
:table-layout="{ dense: true, columnsMovable: true }"
>
<div class="w-full space-y-2.5">
<DataGridContainer>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
<DataGridPagination />
</div>
</DataGrid>
<!-- Async Info Alert -->
<Alert variant="success" class="mt-5">
<component :is="circleAlertIcon" />
<AlertTitle>Async Mode: Simulated API Delay of <strong>800-2000ms</strong></AlertTitle>
</Alert>
</div>
</template>