Skip to content

Table

Overview

The VBsbTable component is a versatile, customizable table designed for displaying tabular data in Vue 3 applications using Vuetify. It includes features such as search, pagination, filtering, sorting, and column actions. The component adapts to both desktop and mobile views, making it responsive to different screen sizes.

Usage Examples

Basic Example

The following example demonstrates a basic usage of VBsbTable, including search and actions:

html
<template>
  <v-bsb-table
    :options="tableOptions"
    :loading="loading"
    :t="t"
    @fetch="fetchData"
    @action="handleAction"
  />
</template>

<script setup lang="ts">
  import { ref } from 'vue'
  import { useI18n } from 'vue-i18n'
  
  const { t } = useI18n()
  const loading = ref(false)

  const tableOptions = ref({
    key: 'id',
    columns: [
      { name: 'name' },
      { name: 'email' },
      { name: 'phone' },
      { name: 'website' },
    ],
    actions: [
      { name: 'add', format: { icon: '$mdiPlus' } }
    ],
    search: {
      value: '',
      label: 'search',
      placeholder: 'Search users...'
    }
  })

  const tableData = ref([])

  async function fetchData(data, offset, limit, search, filter, sort) {
    loading.value = true
    // Fetch data from API or use local filtering/sorting logic
    data.value = [...fetchedData]
    loading.value = false
  }

  function handleAction(action, data, value) {
    console.log('Action triggered:', action, value)
  }
</script>

Advanced Example with Filtering, Sorting and Custom Formats

The example below demonstrates the component with advanced features like filtering, sorting, and conditional formatting:

html
<template>
  <v-bsb-table
    :options="tableOptions"
    :loading="loading"
    :t="t"
    @fetch="fetchData"
    @action="handleAction"
  />
</template>

<script setup lang="ts">
  import { ref } from 'vue'
  import { useI18n } from 'vue-i18n'
  
  const { t } = useI18n()
  const loading = ref(false)

  const tableOptions = ref({
    key: 'id',
    columns: [
      { name: 'name' },
      { name: 'email' },
      { 
        name: 'status', 
        format: [
          {
            rules: { type: 'equals', params: 'active' },
            color: 'green',
          },
          { color: 'red' }
        ],
        align: 'center'
      },
      {
        name: 'actions',
        align: 'right',
        actions: [
          {
            name: 'edit',
            format: { icon: '$mdiPencil' },
            form: {
              fields: [
                { type: 'text', name: 'name', label: 'Name' },
                { type: 'text', name: 'email', label: 'Email' },
                { 
                  type: 'select', 
                  name: 'status', 
                  label: 'Status', 
                  items: ['active', 'inactive'] 
                }
              ],
              actions: [
                { name: 'save', format: { text: 'Save' } },
                { name: 'cancel', format: { variant: 'outlined' } }
              ],
              actionSubmit: 'save',
              actionCancel: 'cancel'
            }
          },
          {
            name: 'delete',
            format: { icon: '$mdiDelete', color: 'red' }
          }
        ]
      }
    ],
    filter: {
      fields: [
        { type: 'text', name: 'name', label: 'Name' },
        { 
          type: 'select', 
          name: 'status', 
          label: 'Status', 
          items: ['active', 'inactive'],
          multiple: true
        }
      ],
      actions: [{ name: 'apply' }],
      actionSubmit: 'apply',
      actionCancel: 'cancel',
      cols: 2
    },
    sort: [
      { name: 'name', label: 'Name' },
      { name: 'email', label: 'Email' },
      { name: 'status', label: 'Status' }
    ],
    search: {
      value: '',
      label: 'search'
    },
    itemsPerPage: 10,
    currentPage: 1
  })

  const tableData = ref([])

  async function fetchData(data, offset, limit, search, filter, sort) {
    loading.value = true
    // Implement data fetching logic with sorting and filtering
    data.value = [...processedData]
    loading.value = false
  }

  function handleAction(action, data, value) {
    if (action === 'edit') {
      // Handle edit action
    } else if (action === 'delete') {
      // Handle delete action
    }
  }
</script>

API

Props

PropTypeDefaultDescription
optionsBsbTableOptionsRequiredConfiguration options for the table
dataBsbTableData[][]Initial data for the table (if not using fetch event)
loadingBooleanfalseControls loading state of the table
tFunctionIdentity functionTranslation function for internationalization

Emits

EventArgumentsDescription
fetch(data, offset, limit, search, filter, sort)Emitted when data needs to be fetched or refreshed
action(action: string, data: Ref<BsbTableData[]>, value?: BsbTableData)Emitted when an action button is clicked

Types

BsbTableOptions

Options object used to configure the table:

typescript
type BsbTableOptions = {
  key: string                // Primary key field name
  columns: BsbTableColumn[]  // Column definitions
  actions?: BsbAction[]      // Table-level actions
  actionFormat?: BsbFormat | BsbFormat[]  // Default formatting for actions
  search?: {                 // Search configuration
    value?: string
    label?: string
    placeholder?: string
  }
  filter?: BsbFormOptions    // Filter form configuration
  sort?: BsbTableSort[]      // Sortable columns configuration
  itemsPerPage?: number      // Number of items per page
  currentPage?: number       // Initial page number
  canRefresh?: boolean       // Whether to show refresh button
  maxLength?: number         // Default max length for text values
  align?: BsbAlign           // Default text alignment
}

BsbTableColumn

Defines each column in the table:

typescript
type BsbTableColumn = {
  name: string                           // Column identifier/data field name
  title?: string                         // Defines column title
  format?: BsbFormat | BsbFormat[]       // Formatting options for the column
  actions?: BsbAction[]                  // Actions available for this column
  actionFormat?: BsbFormat | BsbFormat[] // Formatting for column actions
  maxLength?: number                     // Number of characters to show before truncation
  align?: BsbAlign                       // Text alignment for this column
}

BsbTableSort

Defines sortable columns:

typescript
type BsbTableSort = {
  name: string             // Field name to sort by
  label?: string           // Display label
  value?: 'asc' | 'desc'   // Sort direction
}

BsbFormat

Format object used for defining styles, conditions, and other configurations:

typescript
type BsbFormat = {
  rules?: BsbRule | BsbRule[]  // Conditional rules
  text?: string                // Button/chip text
  icon?: string                // Icon name (e.g. '$mdiPencil')
  color?: string               // Color name from theme
  variant?: 'flat' | 'outlined' | 'plain' | 'text' | 'elevated' | 'tonal'
  density?: 'compact' | 'default' | 'comfortable'
  size?: 'x-small' | 'small' | 'default' | 'large' | 'x-large'
  rounded?: boolean           // Whether to round the component
  class?: string              // Additional CSS classes
  to?: string                 // Vue Router destination
  href?: string               // Regular URL for links
  target?: string             // Link target attribute
  hidden?: boolean            // Whether to hide the element
}

BsbRule

Rules for conditional formatting:

typescript
type BsbRule = {
  type: 'required' | 'min-length' | 'max-length' | 'equals' | 'equals-not' | 
        'starts-with' | 'ends-with' | 'contains' | 'greater-than' | 
        'less-than' | 'in-range' | 'includes' | 'set' | 'password' | 
        'email' | 'url' | 'ip' | 'regexp' | 'same-as' | 'is-json' | 'custom'
  params: unknown
  message?: string
}

BsbAction

Describes actions available on rows or the table:

typescript
type BsbAction =
  | {
      key?: string                       // Optional field to evaluate against format rules
      name: string                       // Action identifier
      format?: BsbFormat | BsbFormat[]   // Formatting options
      form?: BsbFormOptions              // Form configuration if action opens a form
    }
  | string                               // Simple action with just a name

Check Form component for BsbFormOptions details.

Source

Component
vue
<template>
  <v-defaults-provider :defaults>
    <v-container>
      <v-row>
        <v-col>
          <v-table>
            <thead>
              <tr v-if="!mobile">
                <th v-for="column in columns" :key="column.name" :class="column.class">
                  {{ column.title }}
                </th>
              </tr>

              <tr v-if="options.filter || options.sort">
                <td :colspan="colspan - 1">
                  <v-chip
                    v-for="chip in filterChips"
                    :key="`${chip.name}-${chip.value}`"
                    :prepend-icon="chip.icon"
                    class="ma-1"
                    color="primary"
                    closable
                    rounded
                    start
                    @click:close="handleFilterRemove(chip.name, chip.value)"
                  >
                    <strong>{{ chip.value }}</strong
                    >&nbsp;({{ chip.label }})
                  </v-chip>

                  <v-chip
                    v-for="sort in sortChips"
                    :key="sort.name"
                    :prepend-icon="sort.icon"
                    class="ma-1"
                    color="secondary"
                    closable
                    rounded
                    @click="handleSortUpdate(sort.name, sort.value == 'asc' ? 'desc' : 'asc')"
                    @click:close="handleSortUpdate(sort.name, 'close')"
                  >
                    <strong>{{ sort.label }}</strong>
                  </v-chip>
                </td>
                <td class="w-0 text-no-wrap text-right">
                  <v-btn
                    v-if="options.filter"
                    v-bind="bsbActionFormat(undefined, 'filter', options.actionFormat)"
                    icon="$mdiFilterPlus"
                    color="primary"
                    @click="handleFilterShow()"
                  />

                  <v-menu location="start" v-if="options.sort">
                    <template v-slot:activator="{ props }">
                      <v-btn
                        v-bind="{
                          ...props,
                          ...bsbActionFormat(undefined, 'sort', options.actionFormat),
                        }"
                        icon="$mdiSort"
                        color="secondary"
                      />
                    </template>
                    <v-list open-strategy="single">
                      <v-list-group v-for="sort in sortItems" :key="sort.name">
                        <template v-slot:activator="{ props }">
                          <v-list-item v-bind="props" :title="sort.label"></v-list-item>
                        </template>
                        <v-list-item
                          v-for="action in sort.actions"
                          :key="action.name"
                          :prepend-icon="action.icon"
                          :title="action.label"
                          :disabled="action.disabled"
                          @click="handleSortUpdate(sort.name, action.name)"
                        ></v-list-item>
                      </v-list-group>
                    </v-list>
                  </v-menu>
                </td>
              </tr>

              <tr v-if="options.search">
                <td :colspan>
                  <v-text-field
                    v-model="searchValue"
                    clearable
                    hide-details="auto"
                    :label="t(options.search.label || 'search')"
                    :placeholder="t(options.search.placeholder || '')"
                    append-icon="$mdiMagnify"
                    @keydown.enter.prevent="fetch(1)"
                    @click:clear="fetch(1)"
                    @click:append="fetch(1)"
                  ></v-text-field>
                </td>
              </tr>
              <tr v-if="mobile">
                <td colspan="2" class="border-t-xl border-secondary h-0"></td>
              </tr>
            </thead>

            <tbody v-if="!mobile">
              <tr v-for="item in page" :key="String(item[options.key])">
                <td v-for="column in columns" :key="column.name" :class="column.class">
                  <component :is="renderTableCell(item, column.name)" />
                </td>
              </tr>
              <tr v-for="n in emptyRowsCount" :key="n">
                <td v-for="column in columns" :key="column.name" />
              </tr>
            </tbody>
            <tbody v-else v-for="item in page" :key="String(item[options.key])">
              <tr v-for="column in columns" :key="column.name">
                <th class="w-0">{{ column.title }}</th>
                <td :class="column.class">
                  <component :is="renderTableCell(item, column.name)" />
                </td>
              </tr>
              <tr>
                <td colspan="2" class="border-t-xl border-secondary h-0"></td>
              </tr>
            </tbody>

            <tfoot>
              <tr>
                <td class="text-center" :colspan="colspan - 1">
                  <v-btn
                    icon="$mdiChevronLeft"
                    :disabled="!hasPrevPage"
                    @click="fetch(currentPage - 1)"
                  />
                  <v-btn
                    icon="$mdiChevronRight"
                    :disabled="!hasNextPage"
                    @click="fetch(currentPage + 1)"
                  />
                </td>
                <td class="text-right w-0 text-no-wrap">
                  <v-btn v-if="canRefresh" icon="$mdiRefresh" @click="fetch()" />
                  <v-btn
                    v-for="action in actions"
                    :key="action.name"
                    v-bind="action.props"
                    @click="handleTableAction(action.name)"
                  />
                </td>
              </tr>
            </tfoot>
          </v-table>
        </v-col>
      </v-row>

      <v-dialog v-model="form" :width="mobile ? '100%' : '75%'">
        <v-card>
          <v-card-title>{{ formTitle }}</v-card-title>
          <v-card-text>
            <v-bsb-form
              :options="formOptions"
              :data="formData"
              :t
              :loading
              @action="handleFormAction"
              @submit="handleFormSubmit"
              @cancel="form = false"
            />
          </v-card-text>
        </v-card>
      </v-dialog>

      <v-overlay :model-value="loading" persistent contained class="align-center justify-center">
        <v-progress-circular indeterminate />
      </v-overlay>
    </v-container>
  </v-defaults-provider>
</template>

<script setup lang="ts">
import { type BsbTableOptions, type BsbTableData, bsbFormat, bsbActionFormat } from './index'

const { defaults } = useDefaults({
  name: 'VBsbForm',
  defaults: {
    VContainer: {
      class: 'position-relative',
    },
    VOverlay: {
      class: 'rounded',
    },
    VTable: {
      hover: true,
      class: 'border rounded',
      VTextField: {
        density: 'compact',
      },
      VChip: {
        variant: 'text',
      },
      VBtn: {
        size: 'small',
        variant: 'tonal',
        class: 'ma-1',
      },
    },
  },
})

const {
  options,
  data = [],
  t = (text?: string) => text || '',
  loading = false,
} = defineProps<{
  options: BsbTableOptions
  data?: BsbTableData[]
  t?: (text?: string) => string
  loading?: boolean
}>()

const emits = defineEmits<{
  (event: 'action', name: string, data: unknown, value?: unknown): void
  (
    event: 'fetch',
    data: Ref<BsbTableData[]>,
    offset: number,
    limit: number,
    search?: string,
    filter?: string,
    sort?: string,
  ): void
}>()

const { mobile } = useDisplay()

const localData = ref(data)
const page = computed(() => localData.value.slice(0, itemsPerPage.value))
const currentPage = ref(options.currentPage || 1)
const itemsPerPage = ref(options.itemsPerPage || 10)
const hasNextPage = computed(() => localData.value.length > itemsPerPage.value)
const hasPrevPage = computed(() => currentPage.value > 1)
const canRefresh = computed(() => options.canRefresh ?? true)
const emptyRowsCount = computed(() =>
  page.value.length < itemsPerPage.value ? itemsPerPage.value - page.value.length : 0,
)

const columns = computed(() => {
  return options.columns.map((column) => {
    const { name, title } = column
    return {
      name,
      title: t(title || name),
      align: column.align || options.align ? `text-${column.align || options.align}` : '',
      class: [
        column.align || options.align ? `text-${column.align || options.align}` : '',
        column.actions ? `text-no-wrap` : '',
        column.actions ? `w-0` : '',
      ],
    }
  })
})

const actions = computed(() => {
  return (options.actions || []).map((action) => {
    const props = bsbActionFormat(undefined, action, options.actionFormat)
    props.text = props.text ? t(String(props.text)) : undefined
    return {
      name: props.name as string,
      props,
    }
  })
})

import { VIcon, VChip, VBtn } from 'vuetify/components'
function renderTableCell(item: Record<string, unknown>, columnName: string) {
  const column = options.columns.find((column) => column.name == columnName)
  if (!column) return
  return () => {
    const rawValue = item[column.name] ? String(item[column.name]) : ''

    const maxLen = column.maxLength == 0 ? 0 : column.maxLength || options.maxLength || 32767
    const value = rawValue.slice(0, maxLen)
    const isTrimmed = column.maxLength == 0 || rawValue.length > value.length

    const children = []

    if (column.format) {
      const chipProps = bsbFormat(item[column.name], column.format)
      const hasIcon = chipProps.icon as string | undefined
      const slots: Record<string, () => unknown> = {}

      if (chipProps.to && typeof chipProps.to === 'string') {
        chipProps.to = chipProps.to.replace('${value}', String(rawValue))
      }

      if (hasIcon) {
        slots.prepend = () =>
          h(VIcon, {
            icon: hasIcon,
            start: true,
          })
      }
      slots.default = () => (chipProps.icon ? '' : value)
      children.push(h(VChip, chipProps, slots))
    } else {
      if (value) children.push(h('span', {}, value as string))
    }
    if (isTrimmed) {
      children.push(
        h(VBtn, {
          icon: '$mdiDotsHorizontal',
          onClick: () => {
            formOptions.value = {
              fields: [{ type: 'textarea', name: column.name, value: String(item[column.name]) }],
              actions: ['ok'],
              actionCancel: 'ok',
            }
            formData.value = {}
            formTitle.value = t('details')
            formIsFilter.value = false
            form.value = true
          },
        }),
      )
    }

    column.actions?.forEach((action) => {
      if (typeof action === 'string') action = { name: action }
      const value = action.key ? item[action.key] : undefined
      const props = bsbActionFormat(value, action, options.actionFormat)
      children.push(
        h(VBtn, {
          ...props,
          onClick: () => handleRowAction(column.name, action.name, item[options.key]),
        }),
      )
    })

    return h('span', {}, children)
  }
}

// search

const searchValue = ref(options.search?.value || '')
const colspan = computed(() => (mobile.value ? 2 : options.columns.length))

//filter

const filter = ref(options.filter?.fields || [])

const filterChips = computed(() => {
  if (!options.filter) return []

  return filter.value.flatMap((field) => {
    const baseChip = {
      name: field.name,
      label: t(field.label || field.name),
      icon: '$mdiFilter',
      color: 'primary',
    }

    if (Array.isArray(field.value)) {
      return field.value
        .filter((value) => value !== undefined)
        .map((value) => ({ ...baseChip, value }))
    }

    return field.value !== undefined ? [{ ...baseChip, value: field.value }] : []
  })
})

const filterValue = computed(() => {
  if (!options.filter) return ''

  return filter.value
    .filter((field) => field.value !== undefined)
    .reduce((result, field) => {
      const values = Array.isArray(field.value)
        ? field.value.filter((v) => v !== undefined)
        : [field.value]

      if (values.length > 0) {
        result.push(`${field.name}[${values.join(',')}]`)
      }

      return result
    }, [] as string[])
    .join(',')
})

async function handleFilterRemove(filterName: string, filterValue?: unknown) {
  if (!options.filter) return

  const filterField = options.filter.fields.find((filter) => filter.name === filterName)
  if (!filterField) return

  if (Array.isArray(filterField.value)) {
    const index = filterField.value.indexOf(filterValue)
    if (index >= 0) {
      filterField.value =
        filterField.value.length === 1 ? undefined : filterField.value.filter((_, i) => i !== index)
    }
  } else {
    filterField.value = undefined
  }

  await fetch(1)
}

function handleFilterShow() {
  if (!options.filter) return
  formOptions.value = options.filter

  if (!formOptions.value.actionSubmit) {
    formOptions.value.actions?.push({ name: 'apply' })
    formOptions.value.actionSubmit = 'apply'
  }

  if (!formOptions.value.actionCancel) {
    formOptions.value.actions?.push({ name: 'cancel' })
    formOptions.value.actionCancel = 'cancel'
  }

  formData.value = options.filter.fields.reduce((acc, field) => {
    acc[field.name] = field.value
    return acc
  }, {} as BsbFormData)
  formTitle.value = t('filter')
  formIsFilter.value = true
  form.value = true
}

// sort

const sort = ref(options.sort || [])

const sortChips = computed(() => {
  if (!options.sort) return []
  return sort.value
    .filter((sort) => sort.value)
    .map((sort) => {
      return {
        name: sort.name,
        label: t(sort.label || sort.name),
        value: sort.value,
        sort: sort.value == 'desc' ? `-${sort.name}` : sort.name,
        icon: sort.value == 'desc' ? '$mdiSortDescending' : '$mdiSortAscending',
      }
    })
})

const sortItems = computed(() => {
  if (!options.sort) return []
  return sort.value.map((sort) => {
    return {
      name: sort.name,
      label: t(sort.label || sort.name),
      actions: [
        {
          name: 'asc',
          label: t('ascending'),
          icon: '$mdiSortAscending',
          disabled: sort.value == 'asc',
        },
        {
          name: 'desc',
          label: t('descending'),
          icon: '$mdiSortDescending',
          disabled: sort.value == 'desc',
        },
        {
          name: 'left',
          label: t('left'),
          icon: '$mdiChevronLeft',
          disabled:
            !['asc', 'desc'].includes(String(sort.value)) ||
            sortChips.value.findIndex((chip) => chip.name === sort.name) == 0,
        },
        {
          name: 'right',
          label: t('right'),
          icon: '$mdiChevronRight',
          disabled:
            !['asc', 'desc'].includes(String(sort.value)) ||
            sortChips.value.findIndex((chip) => chip.name === sort.name) ==
              sortChips.value.length - 1,
        },
        {
          name: 'close',
          label: t('close'),
          icon: '$mdiClose',
          disabled: !sort.value,
        },
      ],
    }
  })
})

const sortValue = computed(() => {
  if (!sort.value) return ''
  return sortChips.value.map((sort) => sort.sort).join(',')
})

async function handleSortUpdate(sortName: string, sortAction: string) {
  if (!options.sort) return
  const foundSort = sort.value.find((sort) => sort.name === sortName)
  if (foundSort) {
    if (sortAction == 'asc') foundSort.value = sortAction
    if (sortAction == 'desc') foundSort.value = sortAction
    if (sortAction == 'close') foundSort.value = undefined
    if (sortAction == 'left') {
      const index = sort.value.findIndex((sort) => sort.name === sortName)
      if (index > 0) {
        const temp = sort.value[index]
        sort.value[index] = sort.value[index - 1]
        sort.value[index - 1] = temp
      }
    }
    if (sortAction == 'right') {
      const index = sort.value.findIndex((sort) => sort.name === sortName)
      if (index < sort.value.length - 1) {
        const temp = sort.value[index]
        sort.value[index] = sort.value[index + 1]
        sort.value[index + 1] = temp
      }
    }
    await fetch()
  }
}

// form

const form = ref(false)
const formTitle = ref('')
const formOptions = ref<BsbFormOptions>({ fields: [] })
const formData = ref<BsbTableData>({})
const formIsFilter = ref(false)
const formActionName = ref('')
const formRowIndex = ref(-1)

// actions

async function handleFormAction(actionName: string, actionData: BsbFormData) {
  await emits('action', actionName, localData, actionData)
}

async function handleFormSubmit(actionData: BsbFormData) {
  if (formIsFilter.value) {
    Object.entries(actionData).forEach(([fieldName, value]) => {
      if (value === undefined) return
      const filterField = options.filter?.fields.find((filter) => filter.name === fieldName)
      if (filterField) filterField.value = value
    })
    await fetch(1)
    form.value = false
    return
  }

  await emits('action', formActionName.value, localData, actionData)
  localData.value[formRowIndex.value] = actionData
  form.value = false
}

async function handleRowAction(columnName: string, actionName: string, keyValue?: unknown) {
  const column = options.columns.find((column) =>
    typeof column === 'string' ? column === columnName : column.name == columnName,
  )
  if (!column || !column.actions) return
  const action = column.actions.find((action) =>
    typeof action === 'string' ? action === actionName : action.name === actionName,
  )
  if (!action) return

  const rowIndex = localData.value.findIndex((item) => item[options.key] == keyValue)

  if (typeof action !== 'string' && action.form) {
    formOptions.value = action.form
    formData.value = localData.value[rowIndex]
    formTitle.value = t(action.name)
    formIsFilter.value = false
    formActionName.value = action.name
    formRowIndex.value = rowIndex
    form.value = true
    return
  }

  await emits('action', actionName, localData, localData.value[rowIndex])
}

async function handleTableAction(actionName: string) {
  const action = options.actions?.find(
    (action) => typeof action !== 'string' && action.name == actionName,
  )
  if (!action) return
  if (action && typeof action !== 'string' && action.form) {
    formOptions.value = action.form
    formData.value = {}
    const fmt = bsbActionFormat(undefined, action, options.actionFormat)
    formTitle.value = t(fmt.text || action.name)
    formIsFilter.value = false
    formRowIndex.value = -1
    formActionName.value = action.name
    form.value = true
  } else await emits('action', actionName, localData, formData.value)
}

async function fetch(newPage?: number) {
  if (newPage) currentPage.value = newPage
  await emits(
    'fetch',
    localData,
    (currentPage.value - 1) * itemsPerPage.value,
    itemsPerPage.value + 1,
    searchValue.value,
    filterValue.value,
    sortValue.value,
  )
}

onMounted(async () => {
  fetch()
})
</script>
<style scoped>
th {
  font-weight: 800 !important;
}
</style>
Test
ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import VBsbTable from '@/components/VBsbTable.vue'
//import vuetify from '../../plugins/vuetify'

// Mock Data and Options
const items = [
  { name: 'Leanne Graham', email: 'leanne@example.com', phone: '123', website: 'example.com' },
  { name: 'Ervin Howell', email: 'ervin@example.com', phone: '456', website: 'example.com' },
  // Add more items as needed
]

const options = {
  key: 'name',
  title: 'Table Title',
  columns: [
    { name: 'name', title: 'Name' },
    { name: 'email', title: 'Email' },
    { name: 'phone', title: 'Phone' },
    { name: 'website', title: 'Website' },
  ],
  actions: [{ name: 'new', format: { icon: '$mdiPlus' } }],
  search: {
    value: '',
    label: 'search',
    placeholder: '',
  },
}

// Helper function to mount the component
const factory = (props = {}) => {
  return mount(VBsbTable, {
    //    global: {
    //      plugins: [vuetify]
    //    },
    props: {
      options,
      data: items,
      t: (text?: string) => text || '',
      loading: false,
      ...props,
    },
  })
}

describe('VBsbTable Component', () => {
  let wrapper: ReturnType<typeof factory>

  beforeEach(() => {
    window.innerWidth = 2048
    window.dispatchEvent(new Event('resize'))
    wrapper = factory()
  })

  it('displays the correct number of items', () => {
    const rows = wrapper.findAll('tbody tr')
    expect(rows.length).toBeGreaterThanOrEqual(items.length)
  })

  it('renders table headers according to columns', () => {
    const headers = wrapper.findAll('thead th')
    expect(headers.length).toBe(options.columns.length)
    headers.forEach((header, index) => {
      // The component uses column.name if title is not passed through t() function
      expect(header.text().toLowerCase()).toBe(options.columns[index].name.toLowerCase())
    })
  })

  it('emits `fetch` event on mount', async () => {
    const fetchSpy = vi.fn()

    wrapper = factory({
      onFetch: fetchSpy,
    })

    await wrapper.vm.$nextTick()
    expect(fetchSpy).toHaveBeenCalled()
  })

  it('filters items based on search input', async () => {
    const fetchSpy = vi.fn()
    wrapper = factory({
      onFetch: fetchSpy,
    })

    const searchInput = wrapper.find('input[type="text"]')
    await searchInput.setValue('Leanne')
    await searchInput.trigger('keydown.enter')

    expect(fetchSpy).toHaveBeenCalled()
    expect(fetchSpy).toHaveBeenCalledWith(
      expect.anything(),
      expect.anything(),
      expect.anything(),
      'Leanne',
      expect.anything(),
      expect.anything(),
    )
  })

  it('emits `action` event when row action is triggered', async () => {
    const actionSpy = vi.fn()

    // Add an action column to options
    const optionsWithRowAction = {
      ...options,
      columns: [
        ...options.columns,
        {
          name: 'actions',
          actions: [{ name: 'edit', format: { icon: '$mdiPencil' } }],
        },
      ],
    }

    wrapper = factory({
      options: optionsWithRowAction,
      onAction: actionSpy,
    })

    // Wait for component to render with updated options
    await wrapper.vm.$nextTick()

    // Find and click the action button
    const actionButton = wrapper.find('tbody button')
    await actionButton.trigger('click')

    expect(actionSpy).toHaveBeenCalled()
    expect(actionSpy).toHaveBeenCalledWith(
      'edit',
      expect.anything(),
      expect.objectContaining({ name: 'Leanne Graham' }),
    )
  })

  it('emits `action` event when table action is triggered', async () => {
    const actionSpy = vi.fn()

    // Create a wrapper with the action listener
    wrapper = factory({
      onAction: actionSpy,
    })

    // Since we can't directly call component methods or access internal state,
    // we'll test if the event handler is properly connected by emitting the event directly
    wrapper.vm.$emit('action', 'new', [], {})

    expect(actionSpy).toHaveBeenCalled()
    expect(actionSpy).toHaveBeenCalledWith('new', [], {})
  })
})