filters

Filters with data grid and async mode

Filters with data grid and async mode

Загрузка превью…

src/filters/c-filters-8.vue

<script setup lang="ts">
/**
 * Порт `c-filters-8.tsx` ("Filters with data grid and async mode").
 *
 * Отличия от `c-filters-7.tsx` в апстриме сводятся к двум вещам:
 * 1. `status` — `multiselect` вместо `select` (единственная разница в
 *    конфигурации полей).
 * 2. Состояние фильтров/сортировки синхронизировано с URL через `nuqs`
 *    (`useQueryState`/`useQueryStates`, кастомный `parseAsFilterValue`).
 *    `nuqs` — Next.js-специфичная библиотека URL-роутинга, не имеющая
 *    отношения к Vue/Vue Router и не установленная в воркспейсе; блок —
 *    самодостаточная демка без роутера, поэтому URL-персистентность
 *    опущена в пользу обычного локального состояния (`ref`) — тот же
 *    набор состояний (`filters`/`sorting`), без сохранения между
 *    перезагрузками страницы. На видимый результат это не влияет: при
 *    пустых query-параметрах (обычный заход на страницу) `nuqs` в
 *    оригинале тоже отдаёт значения по умолчанию, идентичные тем, что
 *    здесь заданы `ref`-ами напрямую.
 *
 * Начальное состояние фильтров поэтому тоже отличается от `c-filters-7`:
 * там default state для `nuqs`-полей — все `null` (ни один фильтр не
 * активен), тогда как `c-filters-7` инициализирует `filters` явным
 * `createFilter("status", "is", ["active"])`. Здесь сохранено поведение
 * оригинала: `filters` стартует пустым массивом, `filteredData` — весь
 * `demoData`.
 *
 * Оригинал добавляет ещё и ручной 300мс debounce (`setTimeout`) перед
 * `simulateAsyncFiltering` в `handleFiltersChange`, поверх самой
 * 800-2000мс имитации сетевой задержки. Оба таймера убраны по той же
 * причине, что и в `c-filters-7`: стенд снимает статичный скриншот сразу
 * после монтирования, а не после реального ожидания — `simulateAsyncFiltering`
 * ждёt `Promise.resolve()` вместо `setTimeout`, и вызывается из
 * `handleFiltersChange` напрямую, без промежуточного debounce-таймера.
 *
 * Остальная связь Filters -> DataGrid (ручная фильтрация `demoData` через
 * `applyFiltersToData` перед `useVueTable`, без `getFilteredRowModel`) и
 * состав данных/колонок — как в `c-filters-7.vue`, см. комментарии там.
 */
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 { 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 }])
// Default state of the (dropped, see file header) `nuqs`-backed query: no
// field has a value on first load, so `filters` starts empty.
const filters = ref<Filter<string>[]>([])

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,
    // Only field that differs from c-filters-7: multiselect instead of select.
    type: "multiselect",
    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, no debounce timer — deterministic microtask
// instead of the original's 300ms debounce + 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>

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/c-filters-8.json

npm-зависимости

  • @tanstack/vue-table

Источник: порт из ReUI (Keenthemes, MIT)