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, conditional formatting, and actions. The component can be used for desktop and mobile views, making it adaptable to responsive layouts.
Usage Examples
Basic Example
The following example demonstrates a basic usage of VBsbTable
, including search, conditional formatting, and actions:
<template>
<v-bsb-table
searchable
:options="tableOptions"
:items="tableData"
@action="handleAction"
@beforeFetch="beforeFetchHandler"
@afterFetch="afterFetchHandler"
@submit="submitHandler"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const tableOptions = ref({
title: 'User Data Table',
columns: [
{ column: 'name', title: 'Name' },
{ column: 'email', title: 'Email' },
{ column: 'phone', title: 'Phone' },
{ column: 'website', title: 'Website' },
],
actions: [{ action: 'new', format: { icon: '$mdiPlus' } }],
})
const tableData = ref([
{
name: 'Leanne Graham',
email: 'leanne@example.com',
phone: '123-456',
website: 'example.com',
},
{ name: 'Ervin Howell', email: 'ervin@example.com', phone: '456-789', website: 'example.com' },
])
function handleAction(action) {
console.log('Action triggered:', action)
}
function beforeFetchHandler(data) {
console.log('Before fetch:', data)
}
function afterFetchHandler(data) {
console.log('After fetch:', data)
}
function submitHandler(data) {
console.log('Form submit:', data)
}
</script>
Advanced Example with Conditional Formatting and Form Actions
The example below demonstrates the component with custom actions and conditional formatting for specific data fields:
<template>
<v-bsb-table
searchable
:options="tableOptions"
:items="tableData"
@action="handleAction"
@submit="submitHandler"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const tableOptions = ref({
title: 'Employee Data',
columns: [
{
column: 'name',
title: 'Employee Name',
format: { condition: 'starts-with', params: 'A', color: 'blue' },
},
{ column: 'email', title: 'Email', format: { condition: 'email', color: 'green' } },
{ column: 'role', title: 'Role' },
],
actions: [
{ action: 'edit', format: { icon: '$mdiPencil', text: 'Edit' } },
{ action: 'delete', format: { icon: '$mdiDelete', color: 'red', variant: 'flat' } },
],
})
const tableData = ref([
{ name: 'Alice Johnson', email: 'alice@example.com', role: 'Manager' },
{ name: 'Bob Smith', email: 'bob@example.com', role: 'Developer' },
])
function handleAction(action) {
console.log('Action triggered:', action)
}
function submitHandler(data) {
console.log('Form submitted:', data)
}
</script>
API
Props
Prop | Type | Default | Description |
---|---|---|---|
title | String | "" | Title for the table. |
searchable | Boolean | false | If true , enables a search bar. |
searchLabel | String | "Search" | Label for the search input field. |
searchPlaceholder | String | "" | Placeholder for the search input field. |
refreshable | Boolean | true | If true , adds a refresh button to the footer. |
items | Array<BsbTableItem> | Required | Array of items (data rows) to display in the table. |
itemsPerPage | Number | 10 | Number of items displayed per page. |
currentPage | Number | 1 | Current page of the table. |
options | BsbTableOptions | {} | Configuration options for columns, actions, and more. |
shorten | Number | null | Length to which text fields will be shortened. |
navigationFormat | BsbTableFormat | Object with styling defaults | Format options for navigation buttons. |
actionFormat | BsbTableFormat | Object with styling defaults | Format options for action buttons. |
api | String | "" | Base URL for API requests. If provided, enables server-side data handling. |
Emits
Event | Arguments | Description |
---|---|---|
action | { action: string, item: BsbTableItem } | Emitted when an action button is clicked. |
beforeFetch | { items: Array, itemsPerPage: number, currentPage: number, newPage: number, search: string } | Fired before fetching items. |
afterFetch | { items: Array, itemsPerPage: number, currentPage: number, newPage: number, search: string } | Fired after fetching items. |
submit | Record<string, unknown> | Emitted with form data when a form is submitted in an action. |
Exposes
Method | Description |
---|---|
fetch | Method to manually trigger data fetching and pagination. |
Types
BsbTableItem
An object representing a single item (row) in the table. The object keys should match the column identifiers in options.columns
.
BsbTableFormat
Format object used for defining styles, icons, and other configurations for table actions, navigation, and conditional formatting.
export type BsbTableFormat = {
condition?:
| 'required'
| 'min-length'
| 'max-length'
| 'equals'
| 'equals-not'
| 'starts-with'
| 'ends-with'
| 'greater-than'
| 'less-than'
| 'in-range'
| 'set'
| 'password'
| 'email'
| 'url'
| 'ip'
| 'regexp'
| 'same-as'
| 'is-json'
| 'custom'
params?: unknown
text?: string
icon?: string
color?: string
variant?: 'flat' | 'outlined' | 'plain' | 'text' | 'elevated' | 'tonal'
density?: 'compact' | 'default' | 'comfortable'
size?: 'x-small' | 'small' | 'default' | 'large' | 'x-large'
class?: string
to?: string
target?: string
}
BsbTableOptions
Options object used to configure the table columns, actions, and overall layout.
export type BsbTableOptions = {
title?: string
columns: Array<BsbTableColumn>
actions?: Array<BsbTableAction>
}
BsbTableColumn
Defines each column in the table, including the title, formatting, and optional actions.
export type BsbTableColumn = {
primary?: boolean // Indicates if this column contains the primary key
column: string // Column identifier
title?: string // Display title for the column
actions?: Array<BsbTableAction> // Actions available for this column
format?: Array<BsbTableFormat> | BsbTableFormat // Formatting options
shorten?: number // Number of characters to show before truncating
}
BsbTableAction
Describes each action that can be performed on a row item, including its label, icon, and any associated form.
export type BsbTableAction = {
action: string
format?: BsbTableFormat
form?: BsbFormOptions
condition?: BsbTableCondition[] | BsbTableCondition // Conditions that determine action visibility
}
BsbTableCondition
Defines conditions for showing/hiding actions based on item values.
export type BsbTableCondition = {
type: 'equals' | 'not-equals' | 'greater-than' | 'less-than' | 'in-range'
name: string // Name of the field to check
value: unknown // Value to compare against
}
Check Form component for BsbFormOptions
Source
Component
<template>
<v-text-field
v-show="props.searchable && showTable"
v-model="search"
clearable
hide-details="auto"
:label="t(props.searchLabel)"
:placeholder="props.searchPlaceholder ? t(props.searchPlaceholder) : ''"
append-icon="$mdiMagnify"
@keydown.enter.prevent="fetch(1)"
@click:clear="fetch(1)"
@click:append="fetch(1)"
></v-text-field>
<v-defaults-provider
:defaults="{
VBtn: {
color: props.actionFormat.color,
variant: props.actionFormat.variant,
density: props.actionFormat.density,
size: props.actionFormat.size,
class: props.actionFormat.class,
},
}"
>
<v-sheet v-if="mobile" color="transparent">
<v-table v-if="currentItems.length == 0">
<tbody>
<tr>
<td>{{ t('no.data') }}</td>
</tr>
</tbody>
</v-table>
<v-table class="mb-4" v-for="item in currentItems" :key="String(item[0])">
<tbody>
<tr v-for="column in columns" :key="column.column">
<th class="th-width-auto">{{ column.title }}</th>
<td :class="column.actions ? 'text-right' : ''">
<v-bsb-table-cell :column :item :shorten @action="onAction" />
</td>
</tr>
</tbody>
</v-table>
</v-sheet>
<v-sheet v-else color="transparent">
<v-table>
<thead>
<tr>
<th v-for="column in columns" :key="column.column">{{ column.title }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in currentItems" :key="String(item[0])">
<td
v-for="column in columns"
:key="column.column"
:class="column.actions ? 'td-width-auto text-right' : ''"
>
<v-bsb-table-cell :column :item :shorten @action="onAction" />
</td>
</tr>
<tr v-for="item in dummyItems" :key="String(item[0])">
<td v-for="column in columns" :key="column.column"></td>
</tr>
</tbody>
</v-table>
</v-sheet>
</v-defaults-provider>
<v-defaults-provider
:defaults="{
VBtn: {
color: props.navigationFormat.color,
variant: props.navigationFormat.variant,
density: props.navigationFormat.density,
size: props.navigationFormat.size,
class: props.navigationFormat.class,
},
}"
>
<v-table class="transparent">
<tfoot>
<tr>
<td class="text-center">
<v-btn
:disabled="!hasPrevPage"
icon="$mdiChevronLeft"
@click="fetch(currentPage - 1)"
></v-btn>
<v-btn
:disabled="!hasNextPage"
icon="$mdiChevronRight"
@click="fetch(currentPage + 1)"
></v-btn>
</td>
<td v-if="props.refreshable" class="td-width-auto">
<v-btn icon="$mdiRefresh" @click="fetch()"></v-btn>
</td>
<td class="text-right td-width-auto">
<v-btn
data-cy="action"
v-for="action in actions"
:key="action.action"
:text="action.format?.text ? t(action.format?.text) : ''"
:icon="action.format?.icon"
:color="action.format?.color"
:variant="action.format?.variant"
:density="action.format?.density"
:size="action.format?.size"
@click="onAction(action, {})"
></v-btn>
</td>
</tr>
</tfoot>
</v-table>
</v-defaults-provider>
<v-dialog activator="parent" v-if="showForm">
<v-sheet class="pa-4">
<v-bsb-form :data="formData" :options="formOptions" @cancel="onCancel" @submit="onSubmit" />
</v-sheet>
</v-dialog>
<v-overlay v-model="loading" contained persistent />
</template>
<script setup lang="ts">
import type { BsbFormOptions } from './VBsbForm.vue'
import VBsbTableCell from './VBsbTableCell.vue'
const { t } = useI18n()
export type BsbTableItem = Record<string, unknown>
export type BsbTableFormat = {
condition?:
| 'required'
| 'min-length'
| 'max-length'
| 'equals'
| 'equals-not'
| 'starts-with'
| 'ends-with'
| 'greater-than'
| 'less-than'
| 'in-range'
| 'set'
| 'password'
| 'email'
| 'url'
| 'ip'
| 'regexp'
| 'same-as'
| 'is-json'
| 'custom'
params?: unknown
text?: string
icon?: string
color?: string
variant?: 'flat' | 'outlined' | 'plain' | 'text' | 'elevated' | 'tonal'
density?: 'compact' | 'default' | 'comfortable'
size?: 'x-small' | 'small' | 'default' | 'large' | 'x-large'
class?: string
to?: string
target?: string
}
export type BsbTableCondition = {
type: 'equals' | 'not-equals' | 'greater-than' | 'less-than' | 'in-range'
name: string
value: unknown
}
export type BsbTableAction = {
action: string
format?: BsbTableFormat
form?: BsbFormOptions
condition?: BsbTableCondition[] | BsbTableCondition
}
export type BsbTableColumn = {
primary?: boolean
column: string
title?: string
actions?: BsbTableAction[]
format?: BsbTableFormat[] | BsbTableFormat
shorten?: number
}
export type BsbTableOptions = {
title?: string
columns: BsbTableColumn[]
actions?: BsbTableAction[]
}
const props = defineProps({
title: {
type: String,
default: '',
},
searchable: {
type: Boolean,
default: false,
},
searchLabel: {
type: String,
default: 'Search',
},
searchPlaceholder: {
type: String,
default: '',
},
refreshable: {
type: Boolean,
default: true,
},
items: {
type: Array as PropType<BsbTableItem[]>,
required: false,
},
itemsPerPage: {
type: Number,
default: 10,
},
currentPage: {
type: Number,
default: 1,
},
options: {
type: Object as PropType<BsbTableOptions>,
default: () => ({}),
},
shorten: {
type: Number,
default: Number.MAX_SAFE_INTEGER,
},
navigationFormat: {
type: Object as PropType<BsbTableFormat>,
default: () => ({
color: 'primary',
variant: 'outlined',
density: 'default',
size: 'default',
class: 'mt-2 ml-2',
}),
},
actionFormat: {
type: Object as PropType<BsbTableFormat>,
default: () => ({
color: 'primary',
variant: 'outlined',
density: 'default',
size: 'default',
class: 'mt-2 mb-2 ml-2',
}),
},
api: {
type: String,
default: '',
},
})
const emits = defineEmits(['action', 'beforeFetch', 'afterFetch', 'submit'])
const http = props.api ? useHttp({ baseURL: props.api }) : undefined
const search = ref('')
const { mobile } = useDisplay()
const showTable = ref(true)
const loading = ref(false)
const currentItems = ref<BsbTableItem[]>([])
const itemsPerPage = ref(props.itemsPerPage)
const currentPage = ref(props.currentPage)
const hasPrevPage = computed(() => currentPage.value > 1)
const hasNextPage = ref(false)
const dummyItems = ref<BsbTableItem[]>([])
const columns = props.options.columns
const actions = props.options.actions || []
const showForm = ref(false)
const formData = ref<Record<string, unknown>>({})
const formOptions = ref<BsbFormOptions>({
fields: [],
errors: [],
})
async function onAction(action: BsbTableAction, item: BsbTableItem) {
emits('action', { action: action.action, item })
if (action.form) {
formData.value = item
formOptions.value = action.form
showForm.value = true
}
}
async function onCancel() {
showForm.value = false
}
async function onSubmit(data: Record<string, unknown>) {
emits('submit', data)
const primaryKey = columns.find((column) => column.primary)?.column
if (primaryKey) {
const index = currentItems.value.findIndex(
(item) => item[primaryKey] === formData.value[primaryKey],
)
if (index !== -1) currentItems.value[index] = { ...currentItems.value[index], ...data }
}
showForm.value = false
}
async function fetch(newPage?: number) {
loading.value = true
emits('beforeFetch', {
items: currentItems.value,
itemsPerPage: itemsPerPage.value,
currentPage: currentPage.value,
newPage: newPage,
search: search.value,
})
if (newPage) currentPage.value = newPage
let newItems = props.items || []
if (http) {
const { data } = await http.get('', {
search: search.value,
offset: itemsPerPage.value * (currentPage.value - 1),
limit: itemsPerPage.value + 1,
})
newItems = (data as { items: BsbTableItem[] })?.items
hasNextPage.value = newItems.length > itemsPerPage.value
currentItems.value = newItems.slice(0, itemsPerPage.value)
} else {
if (search.value)
newItems = newItems.filter((item: BsbTableItem) => {
return Object.values(item).some((value: unknown) => {
return String(value).toLowerCase().includes(search.value.toLowerCase())
})
})
hasNextPage.value = currentPage.value < newItems.length / itemsPerPage.value
currentItems.value = newItems.slice(
itemsPerPage.value * (currentPage.value - 1),
itemsPerPage.value * (currentPage.value - 1) + itemsPerPage.value,
)
}
dummyItems.value =
currentItems.value.length < itemsPerPage.value
? Array.from({ length: itemsPerPage.value - currentItems.value.length }, () => ({}))
: []
emits('afterFetch', {
items: currentItems.value,
itemsPerPage: itemsPerPage.value,
currentPage: currentPage.value,
newPage: newPage,
search: search.value,
})
loading.value = false
}
onMounted(async () => fetch(1))
defineExpose({
fetch,
})
</script>
<style scoped>
.v-theme--light.v-table tbody tr:nth-of-type(odd) {
background-color: rgba(0, 0, 0, 0.03);
}
.v-theme--dark.v-table tbody tr:nth-of-type(odd) {
background-color: rgba(0, 0, 0, 0.5);
}
thead th,
tbody th {
font-weight: bold !important;
}
.th-width-auto,
.td-width-auto {
width: 1px;
white-space: nowrap;
}
.transparent {
background-color: transparent;
}
</style>
<i18n>
{
"en": {
"No data": "No data",
"Search": "Search"
}
}
</i18n>
Cell Sub Component
<template>
<v-chip
v-if="formatted && !href && format.text"
:color="format.color"
:prepend-icon="format.icon"
:variant="format.variant"
:text="text"
/>
<span v-if="formatted && !href && !format.text && format.icon"
><v-icon :icon="format.icon" :color="format.color"
/></span>
<a v-if="href" :href="href" :target="format.target ?? '_self'">{{ text }}</a>
<span v-if="!formatted && !href">{{ text }}</span>
<v-dialog v-if="shortened">
<template v-slot:activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
text="..."
variant="tonal"
density="default"
size="x-small"
rounded="lg"
></v-btn>
</template>
<template v-slot:default="{ isActive }">
<v-card>
<v-card-title>{{ column.title }}</v-card-title>
<v-card-text>{{ item[column.column] }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn :text="t('Close')" @click="isActive.value = false"></v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
<span v-for="action in column.actions" :key="action.action">
<v-btn
v-if="showAction(action, item)"
data-cy="action"
@click="onAction(action, item)"
:key="action.action"
:text="t(action.format?.text ?? '')"
:icon="action.format?.icon"
:color="action.format?.color"
:variant="action.format?.variant"
:density="action.format?.density"
:size="action.format?.size"
:class="action.format?.class"
></v-btn>
</span>
</template>
<script setup lang="ts">
import type {
BsbTableItem,
BsbTableColumn,
BsbTableFormat,
BsbTableAction,
BsbTableCondition,
} from './VBsbTable.vue'
const { t } = useI18n()
const { item, column, shorten } = defineProps<{
item: BsbTableItem
column: BsbTableColumn
shorten?: number
}>()
const emits = defineEmits<{
action: [BsbTableAction, BsbTableItem]
}>()
function onAction(action: BsbTableAction, item: BsbTableItem) {
emits('action', action, item)
}
const text = ref('')
const shortened = ref(false)
const format = ref<BsbTableFormat>({})
const formatted = ref(false)
const href = ref('')
type ConditionChecker = (condition: BsbTableCondition) => boolean
type ConditionEvaluator = (condition: BsbTableAction['condition']) => boolean
function showAction(action: BsbTableAction, item: BsbTableItem): boolean {
if (!action.condition) return true
const checkCondition: ConditionChecker = ({ name, type, value }) => {
const itemValue = item[name]
const numValue = Number(itemValue)
switch (type) {
case 'equals':
return itemValue === value
case 'not-equals':
return itemValue !== value
case 'greater-than':
return numValue > Number(value)
case 'less-than':
return numValue < Number(value)
case 'in-range': {
const [min, max] = value as [number, number]
return numValue >= min && numValue <= max
}
default:
return false
}
}
const evaluateCondition: ConditionEvaluator = (condition) => {
return Array.isArray(condition)
? condition.some(evaluateCondition)
: checkCondition(condition as BsbTableCondition)
}
return evaluateCondition(action.condition)
}
function validate(
condition?: string,
params?: unknown,
value?: unknown,
message?: string,
): boolean | string {
const validationRules: { [key: string]: () => boolean | string } = {
required: () => !!value || message || false,
'min-length': () =>
(typeof value === 'string' && value.length >= Number(params)) || message || false,
'max-length': () =>
(typeof value === 'string' && value.length <= Number(params)) || message || false,
equals: () => value === params || message || false,
'equals-not': () => value !== params || message || false,
'starts-with': () =>
(typeof value === 'string' && value.startsWith(params as string)) || message || false,
'ends-with': () =>
(typeof value === 'string' && value.endsWith(params as string)) || message || false,
'greater-than': () => Number(value) > Number(params) || message || false,
'less-than': () => Number(value) < Number(params) || message || false,
'in-range': () => {
const [min, max] = params as [number, number]
return (Number(value) >= min && Number(value) <= max) || message || false
},
set: () => (Array.isArray(params) && params.includes(value)) || message || false,
password: () =>
/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/.test(value as string) || message || false,
email: () => /^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$/.test(value as string) || message || false,
url: () =>
/^(https?:\/\/)?([\w-]+\.)+[\w-]+(\/\S*)?$/.test(value as string) || message || false,
ip: () =>
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(
value as string,
) ||
message ||
false,
regexp: () => new RegExp(String(params)).test(value as string) || message || false,
'same-as': () => value === params || message || false,
'is-json': () =>
(typeof value === 'string' &&
(() => {
try {
JSON.parse(value)
return true
} catch {
return false
}
})()) ||
message ||
false,
custom: () => (typeof params === 'function' && params(value)) || message || false,
}
return condition && validationRules[condition] ? validationRules[condition]() : message || false
}
watchEffect(() => {
text.value = item[column.column] as string
if (text.value) {
const maxLength = column.shorten ?? shorten ?? Number.MAX_SAFE_INTEGER
shortened.value = text.value.length > maxLength || maxLength === 0
if (shortened.value) {
text.value = maxLength === 0 ? '' : text.value.slice(0, maxLength)
}
}
format.value = Array.isArray(column.format)
? column.format.find((f) => validate(f.condition, f.params, item[column.column])) || {}
: column.format || {}
formatted.value = Object.values(format.value).some((value) => value)
href.value =
!Array.isArray(column.format) && column.format?.to
? column.format.to.replace('$value', item[column.column] as string)
: ''
})
defineExpose({ validate })
</script>
Test
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 = {
title: 'Table Title',
columns: [
{ column: 'name', title: 'Name' },
{ column: 'email', title: 'Email' },
{ column: 'phone', title: 'Phone' },
{ column: 'website', title: 'Website' },
],
actions: [{ action: 'new', format: { icon: '$mdiPlus' } }],
}
// Helper function to mount the component
const factory = (props = {}) => {
return mount(VBsbTable, {
// global: {
// plugins: [vuetify]
// },
props: {
items,
options,
searchable: true,
refreshable: true,
...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) => {
expect(header.text()).toBe(options.columns[index].title)
})
})
it('emits `beforeFetch` and `afterFetch` events on fetch', async () => {
const beforeFetch = vi.fn()
const afterFetch = vi.fn()
wrapper = factory({
onBeforeFetch: beforeFetch,
onAfterFetch: afterFetch,
})
await wrapper.vm.fetch(1) // Manually trigger fetch
expect(beforeFetch).toHaveBeenCalled()
expect(afterFetch).toHaveBeenCalled()
})
it('filters items based on search input', async () => {
const searchInput = wrapper.find('input[type="text"]')
await searchInput.setValue('Leanne')
await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr')
expect(rows.length).toBeGreaterThan(1)
expect(rows[0].text()).toContain('Leanne Graham')
})
it('emits `submit` event when form is submitted', async () => {
const submitSpy = vi.fn()
wrapper = factory({
onSubmit: submitSpy,
})
// Assume `showForm` is triggered and we submit a form
if (wrapper.vm.onSubmit) {
await wrapper.vm.onSubmit({ name: 'Test', email: 'test@example.com' })
}
expect(submitSpy).toHaveBeenCalledWith({ name: 'Test', email: 'test@example.com' })
})
})