data-grid

Data grid with CRUD features

Data grid with CRUD features

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

src/data-grid/c-data-grid-22.vue

<!--
  Порт `c-data-grid-22.tsx` ("Data grid with search/status filter and row actions").

  Расхождения с апстримом:
  - Аватарки (`images.unsplash.com`) и флаги (`flagcdn.com`) — реальные
    сетевые запросы, недетерминированы в headless-стенде; заменены
    локальным 1x1 data-URI (см. docs/PORTING.md §5 "picsum.photos").
  - `useCopyToClipboard` (реестровый React-хук) не имеет прямого Vue-порта
    в @revueui/ui — реализован тривиально прямо в блоке
    (`navigator.clipboard.writeText`), как в `c-button-42.vue`.
  - Оригинал зовёт `toast.success(...)` из `sonner` по клику "Copy ID" —
    пакет (`vue-sonner`) не входит в зависимости воркспейса и не влияет на
    статичный скриншот (тост никогда не монтируется в состоянии покоя,
    см. `packages/blocks/src/sonner/index.ts`), поэтому вызов не перенесён.
  - `IconPlaceholder` заменена инлайновым `<svg>` (lucide-react v0.545.0:
    "search", "x", "funnel", "user-plus", "more-horizontal"/"ellipsis").
  - Поиск/фильтр по статусу/дропдаун действий реализованы по оригиналу
    (`ref`/`computed`), но статичный скриншот снимает состояние покоя:
    пустой поиск, ни одного выбранного статуса, меню закрыты.
-->
<script setup lang="ts">
import { computed, h, ref } from "vue"
import { Badge } from "@/components/reui/badge"
import { DataGrid, DataGridColumnHeader, DataGridContainer, DataGridPagination, DataGridScrollArea, DataGridTable, DataGridTableRowSelect, DataGridTableRowSelectAll } from "@/components/reui/data-grid"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import {
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useVueTable,
} from "@tanstack/vue-table"
import type { ColumnDef, PaginationState, Row, SortingState } from "@tanstack/vue-table"

const PIXEL =
  "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs="

type Status = "Active" | "Inactive" | "Pending" | "Blocked"

interface IData {
  id: string
  name: string
  availability: "online" | "away" | "busy" | "offline"
  avatar: string
  status: Status
  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: PIXEL, status: "Active", flag: "us", email: "alex@apple.com", company: "Apple", role: "CEO", joined: "Jan, 2024", location: "United States", balance: 5143.03 },
  { id: "2", name: "Sarah Chen", availability: "away", avatar: PIXEL, status: "Inactive", flag: "gb", email: "sarah@openai.com", company: "OpenAI", role: "CTO", joined: "Mar, 2023", location: "United Kingdom", balance: 4321.87 },
  { id: "3", name: "Michael Rodriguez", availability: "busy", avatar: PIXEL, status: "Blocked", flag: "ca", email: "michael@meta.com", company: "Meta", role: "Designer", joined: "Jun, 2022", location: "Canada", balance: 7654.98 },
  { id: "4", name: "Emma Wilson", availability: "offline", avatar: PIXEL, status: "Inactive", flag: "au", email: "emma@tesla.com", company: "Tesla", role: "Developer", joined: "Sep, 2024", location: "Australia", balance: 3456.45 },
  { id: "5", name: "David Kim", availability: "online", avatar: PIXEL, status: "Active", flag: "de", email: "david@sap.com", company: "SAP", role: "Lawyer", joined: "Nov, 2023", location: "Germany", balance: 9876.54 },
  { id: "6", name: "Aron Thompson", availability: "away", avatar: PIXEL, status: "Pending", flag: "my", email: "aron@keenthemes.com", company: "Keenthemes", role: "Director", joined: "Feb, 2022", location: "Malaysia", balance: 6214.22 },
  { id: "7", name: "James Brown", availability: "busy", avatar: PIXEL, status: "Inactive", flag: "es", email: "james@bbva.es", company: "BBVA", role: "Product Manager", joined: "Aug, 2024", location: "Spain", balance: 5321.77 },
]

function copyToClipboard(text: string) {
  if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return
  navigator.clipboard.writeText(text)
}

function MoreHorizontalIcon() {
  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: "lucide lucide-ellipsis",
    },
    [
      h("circle", { cx: "12", cy: "12", r: "1" }),
      h("circle", { cx: "19", cy: "12", r: "1" }),
      h("circle", { cx: "5", cy: "12", r: "1" }),
    ]
  )
}

function renderActionsCell(row: Row<IData>) {
  return h(DropdownMenu, {}, () => [
    h(DropdownMenuTrigger, { asChild: true }, () =>
      h(Button, { class: "size-7", size: "icon", variant: "ghost" }, () => h(MoreHorizontalIcon))
    ),
    h(DropdownMenuContent, { side: "bottom", align: "start" }, () => [
      h(DropdownMenuItem, { onClick: () => {} }, () => "Edit"),
      h(DropdownMenuItem, { onClick: () => copyToClipboard(row.original.id) }, () => "Copy ID"),
      h(DropdownMenuSeparator),
      h(DropdownMenuItem, { variant: "destructive", onClick: () => {} }, () => "Delete"),
    ]),
  ])
}

const pagination = ref<PaginationState>({ pageIndex: 0, pageSize: 5 })
const sorting = ref<SortingState>([{ id: "name", desc: true }])
const searchQuery = ref("")
const selectedStatuses = ref<string[]>([])

const filteredData = computed(() =>
  demoData.filter((item) => {
    const matchesStatus = !selectedStatuses.value.length || selectedStatuses.value.includes(item.status)
    const searchLower = searchQuery.value.toLowerCase()
    const matchesSearch =
      !searchQuery.value ||
      Object.values(item).join(" ").toLowerCase().includes(searchLower)
    return matchesStatus && matchesSearch
  })
)

const statusCounts = computed(() =>
  demoData.reduce<Record<string, number>>((acc, item) => {
    acc[item.status] = (acc[item.status] || 0) + 1
    return acc
  }, {})
)

function handleStatusChange(checked: boolean, value: string) {
  selectedStatuses.value = checked
    ? [...selectedStatuses.value, value]
    : selectedStatuses.value.filter((v) => v !== value)
}

const columns: ColumnDef<IData>[] = [
  {
    accessorKey: "id",
    id: "id",
    header: () => h(DataGridTableRowSelectAll),
    cell: (ctx) => h(DataGridTableRowSelect, { row: ctx.row }),
    enableSorting: false,
    size: 35,
    meta: { headerClassName: "", cellClassName: "" },
    enableResizing: false,
  },
  {
    accessorKey: "name",
    id: "name",
    header: (ctx) => h(DataGridColumnHeader, { title: "User", visibility: true, column: ctx.column as never }),
    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" }, ctx.row.original.email),
        ]),
      ]),
    size: 260,
    meta: { autoSize: true },
    enableSorting: true,
    enableHiding: false,
    enableResizing: true,
  },
  {
    accessorKey: "location",
    id: "location",
    header: (ctx) => h(DataGridColumnHeader, { title: "Location", visibility: true, column: ctx.column as never }),
    cell: (ctx) =>
      h("div", { class: "flex items-center gap-1.5" }, [
        h("img", { src: PIXEL, alt: ctx.row.original.flag, class: "size-4 rounded-full object-cover" }),
        h("div", { class: "text-foreground font-medium" }, ctx.row.original.location),
      ]),
    size: 150,
    meta: { headerClassName: "", cellClassName: "text-start" },
    enableSorting: true,
    enableHiding: true,
    enableResizing: true,
  },
  {
    accessorKey: "role",
    id: "role",
    header: (ctx) => h(DataGridColumnHeader, { title: "Role", visibility: true, column: ctx.column as never }),
    cell: (ctx) => h("div", { class: "text-foreground font-medium" }, ctx.row.original.role),
    size: 150,
    enableSorting: true,
    enableHiding: true,
    enableResizing: true,
  },
  {
    accessorKey: "joined",
    id: "joined",
    header: (ctx) => h(DataGridColumnHeader, { title: "Joined", visibility: true, column: ctx.column as never }),
    cell: (ctx) => h("div", { class: "text-foreground font-medium" }, ctx.row.original.joined),
    size: 150,
    enableSorting: true,
    enableHiding: true,
    enableResizing: true,
  },
  {
    accessorKey: "status",
    id: "status",
    header: (ctx) => h(DataGridColumnHeader, { title: "Status", visibility: true, column: ctx.column as never }),
    cell: (ctx) => {
      const status = ctx.row.original.status
      if (status === "Active") return h(Badge, { variant: "success-outline" }, () => "Approved")
      if (status === "Blocked") return h(Badge, { variant: "destructive-outline" }, () => "Blocked")
      if (status === "Inactive") return h(Badge, { variant: "info-outline" }, () => "Inactive")
      return h(Badge, { variant: "warning-outline" }, () => "Pending")
    },
    size: 100,
    enableSorting: true,
    enableHiding: true,
    enableResizing: true,
  },
  {
    id: "actions",
    header: "",
    cell: (ctx) => renderActionsCell(ctx.row),
    size: 60,
    enableSorting: false,
    enableHiding: false,
    enableResizing: false,
  },
]

const table = useVueTable({
  get data() {
    return filteredData.value
  },
  columns,
  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 columns.map((column) => column.id as string)
    },
  },
  onColumnOrderChange: () => {},
  onPaginationChange: (updater) => {
    pagination.value = typeof updater === "function" ? updater(pagination.value) : updater
  },
  onSortingChange: (updater) => {
    sorting.value = typeof updater === "function" ? updater(sorting.value) : updater
  },
  getCoreRowModel: getCoreRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  getSortedRowModel: getSortedRowModel(),
})
</script>

<template>
  <DataGrid
    :table="table"
    :record-count="filteredData?.length || 0"
    :table-layout="{ columnsPinnable: true, columnsResizable: true, columnsMovable: true, columnsVisibility: true }"
  >
    <Card class="w-full gap-3 py-0">
      <CardHeader class="flex items-center justify-between px-3.5 py-2">
        <div class="flex items-center gap-2.5">
          <InputGroup class="w-48">
            <InputGroupAddon align="inline-start">
              <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="lucide lucide-search">
                <path d="m21 21-4.34-4.34" />
                <circle cx="11" cy="11" r="8" />
              </svg>
            </InputGroupAddon>

            <InputGroupInput placeholder="Search..." v-model="searchQuery" />

            <InputGroupAddon v-if="searchQuery.length > 0" align="inline-end">
              <InputGroupButton aria-label="Copy" title="Copy" size="icon-xs" @click="searchQuery = ''">
                <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="lucide lucide-x">
                  <path d="M18 6 6 18" />
                  <path d="m6 6 12 12" />
                </svg>
              </InputGroupButton>
            </InputGroupAddon>
          </InputGroup>
          <Popover>
            <PopoverTrigger as-child>
              <Button variant="outline">
                <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="lucide lucide-funnel">
                  <path d="M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z" />
                </svg>
                Status
                <Badge v-if="selectedStatuses.length > 0" size="sm" variant="info-outline">{{ selectedStatuses.length }}</Badge>
              </Button>
            </PopoverTrigger>
            <PopoverContent class="w-40" align="start">
              <div class="space-y-3">
                <div class="text-muted-foreground text-xs font-medium">Filters</div>
                <div class="space-y-3">
                  <div v-for="status in Object.keys(statusCounts)" :key="status" class="flex items-center gap-2.5">
                    <Checkbox
                      :id="status"
                      :model-value="selectedStatuses.includes(status)"
                      @update:model-value="(checked) => handleStatusChange(checked === true, status)"
                    />
                    <Label :for="status" class="flex grow items-center justify-between gap-1.5 font-normal">
                      {{ status }}
                      <span class="text-muted-foreground">{{ statusCounts[status] }}</span>
                    </Label>
                  </div>
                </div>
              </div>
            </PopoverContent>
          </Popover>
        </div>
        <CardAction>
          <Button>
            <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="lucide lucide-user-plus">
              <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
              <circle cx="9" cy="7" r="4" />
              <line x1="19" x2="19" y1="8" y2="14" />
              <line x1="22" x2="16" y1="11" y2="11" />
            </svg>
            Add new
          </Button>
        </CardAction>
      </CardHeader>
      <CardContent class="p-0">
        <Card class="p-0">
          <DataGridContainer>
            <DataGridScrollArea>
              <DataGridTable />
            </DataGridScrollArea>
          </DataGridContainer>
        </Card>
      </CardContent>
      <CardFooter class="border-none bg-transparent! px-3.5 py-2">
        <DataGridPagination />
      </CardFooter>
    </Card>
  </DataGrid>
</template>

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/c-data-grid-22.json

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

  • @tanstack/vue-table

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