Skip to content

Custom Table Components

FancyCRUD provides a powerful slot-based system for customizing table columns, actions, and other table elements. This gives you complete control over how your data is displayed and how users interact with it.

Why Custom Table Components?

While FancyCRUD provides default table rendering, you may need:

  • Custom Column Renderers: Display images, badges, chips, progress bars
  • Custom Actions: Special buttons with unique behaviors
  • Interactive Cells: Inline editing with form components
  • Rich Data Display: Charts, formatted text, links, icons
  • Custom Layouts: Complex cell structures, multi-line content

Understanding Table Architecture

FancyCRUD tables use Vue slots extensively for customization:

Main Slots

  1. table-header: Customize the entire header section
  2. table-body: Replace the entire table body
  3. table-footer: Customize pagination and footer
  4. table-form: Customize the form dialog
  5. delete-confirmation-modal: Customize delete confirmation

Column Slots

  • #column-{columnName}: Customize individual column display
  • Receives props: { row, item, index }

Action Slots

  • Header Actions: create-button, export-button, prepend, append
  • Row Actions: column-actions, column-actions-prepend, column-actions-append, edit-button, delete-button

Custom Column Rendering

Using Column Slots

The primary way to customize column display is using the #column-{columnName} slot pattern:

vue
<template>
  <f-table v-bind="table">
    <!-- Custom column rendering -->
    <!-- Slot receives: { row, item, index } -->
    <template #column-status="{ row }">
      <v-chip
        :color="row.status === 'active' ? 'success' : 'error'"
        size="small"
      >
        {{ row.status }}
      </v-chip>
    </template>
    
    <template #column-avatar="{ row }">
      <v-avatar size="32">
        <v-img :src="row.avatar" />
      </v-avatar>
    </template>
    
    <template #column-progress="{ row, index }">
      <v-progress-linear
        :model-value="row.progress"
        color="primary"
        height="8"
      >
        <template #default="{ value }">
          <span class="text-xs">{{ Math.ceil(value) }}%</span>
        </template>
      </v-progress-linear>
    </template>
  </f-table>
</template>

<script setup lang="ts">
import { useForm, useTable } from '@fancy-crud/vue'

const form = useForm({
  fields: {
    name: { type: 'text', label: 'Name' },
    status: { type: 'text', label: 'Status' },
  },
  settings: { url: 'users/' },
})

const table = useTable({
  form,
  columns: {
    name: { label: 'Name' },
    status: { label: 'Status' },
    avatar: { label: 'Avatar' },
    progress: { label: 'Progress' },
  },
  settings: {
    url: 'users/',
  },
})
</script>

Using Column Field Functions

For more control, use the field function in column definitions:

ts
const table = useTable({
  form: myForm,
  columns: {
    fullName: {
      label: 'Full Name',
      field: (row) => `${row.firstName} ${row.lastName}`,
    },
    salary: {
      label: 'Salary',
      field: (row) => row.salary,
      format: (value) => new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD',
      }).format(value),
    },
    createdAt: {
      label: 'Created',
      field: (row) => row.created_at,
      format: (value) => new Date(value).toLocaleDateString(),
    },
  },
})

Customizing Actions

Customizing Row Action Buttons

Use the provided action slots to customize edit and delete buttons:

vue
<template>
  <f-table v-bind="table">
    <!-- Customize edit button -->
    <template #edit-button="{ }">
      <v-btn
        icon="mdi-pencil"
        size="small"
        color="primary"
        variant="tonal"
      />
    </template>
    
    <!-- Customize delete button -->
    <template #delete-button>
      <v-btn
        icon="mdi-trash-can"
        size="small"
        color="error"
        variant="tonal"
      />
    </template>
  </f-table>
</template>

Adding Custom Row Actions

Use the column-actions-prepend or column-actions-append slots to add custom actions:

vue
<template>
  <f-table v-bind="table">
    <!-- Add actions before edit/delete buttons -->
    <template #column-actions-prepend="{ row, item }">
      <v-btn
        icon="mdi-eye"
        size="small"
        variant="text"
        @click="viewDetails(row)"
      />
      <v-btn
        icon="mdi-content-copy"
        size="small"
        variant="text"
        @click="duplicate(row)"
      />
    </template>
    
    <!-- Add actions after edit/delete buttons -->
    <template #column-actions-append="{ row }">
      <v-btn
        icon="mdi-download"
        size="small"
        variant="text"
        @click="exportRow(row)"
      />
    </template>
  </f-table>
</template>

<script setup lang="ts">
const viewDetails = (row: any) => {
  console.log('View:', row)
}

const duplicate = (row: any) => {
  console.log('Duplicate:', row)
}

const exportRow = (row: any) => {
  console.log('Export:', row)
}
</script>

Replacing the Entire Actions Column

Use the column-actions slot to completely replace the actions column:

vue
<template>
  <f-table v-bind="table">
    <template #column-actions="{ row, item }">
      <v-menu>
        <template #activator="{ props }">
          <v-btn
            icon="mdi-dots-vertical"
            size="small"
            variant="text"
            v-bind="props"
          />
        </template>
        
        <v-list>
          <v-list-item @click="table.buttons.edit.onClick(row)">
            <template #prepend>
              <v-icon>mdi-pencil</v-icon>
            </template>
            <v-list-item-title>Edit</v-list-item-title>
          </v-list-item>
          
          <v-list-item @click="table.buttons.remove.onClick(row)">
            <template #prepend>
              <v-icon>mdi-delete</v-icon>
            </template>
            <v-list-item-title>Delete</v-list-item-title>
          </v-list-item>
        </v-list>
      </v-menu>
    </template>
  </f-table>
</template>

Customizing Header Actions

Customize the create and export buttons using header action slots:

vue
<template>
  <f-table v-bind="table">
    <!-- Add custom buttons before create/export -->
    <template #prepend>
      <v-btn
        color="secondary"
        prepend-icon="mdi-filter"
      >
        Filters
      </v-btn>
    </template>
    
    <!-- Customize create button -->
    <template #create-button>
      <v-btn
        color="success"
        prepend-icon="mdi-plus-circle"
      >
        Add New User
      </v-btn>
    </template>
    
    <!-- Add custom buttons after create/export -->
    <template #append>
      <v-btn
        color="info"
        prepend-icon="mdi-refresh"
        @click="table.list.fetchData()"
      >
        Refresh
      </v-btn>
    </template>
  </f-table>
</template>

Real-World Examples

Status Badge Component

vue
<!-- StatusBadge.vue -->
<template>
  <v-chip
    :color="statusColor"
    :variant="variant"
    size="small"
  >
    <v-icon start :icon="statusIcon" />
    {{ displayText }}
  </v-chip>
</template>

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
  value: string
}>()

const statusConfig: Record<string, { color: string; icon: string; text: string }> = {
  active: { color: 'success', icon: 'mdi-check-circle', text: 'Active' },
  pending: { color: 'warning', icon: 'mdi-clock', text: 'Pending' },
  inactive: { color: 'error', icon: 'mdi-close-circle', text: 'Inactive' },
  draft: { color: 'info', icon: 'mdi-pencil', text: 'Draft' },
}

const config = computed(() => statusConfig[props.value] || statusConfig.draft)
const statusColor = computed(() => config.value.color)
const statusIcon = computed(() => config.value.icon)
const displayText = computed(() => config.value.text)
const variant = computed(() => 'flat')
</script>

Usage:

vue
<template>
  <f-table v-bind="table">
    <template #column-status="{ row }">
      <status-badge :value="row.status" />
    </template>
  </f-table>
</template>
vue
<!-- ImageGallery.vue -->
<template>
  <div class="image-gallery">
    <v-avatar
      v-for="(image, index) in displayImages"
      :key="index"
      size="32"
      class="gallery-item"
    >
      <v-img :src="image" />
    </v-avatar>
    <v-chip v-if="remainingCount > 0" size="small" class="remaining-count">
      +{{ remainingCount }}
    </v-chip>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
  images: string[]
  maxDisplay?: number
}>()

const maxDisplay = props.maxDisplay || 3

const displayImages = computed(() => 
  props.images.slice(0, maxDisplay)
)

const remainingCount = computed(() => 
  Math.max(0, props.images.length - maxDisplay)
)
</script>

<style scoped>
.image-gallery {
  display: flex;
  align-items: center;
  gap: 4px;
}

.gallery-item {
  margin-right: -8px;
  border: 2px solid white;
}

.remaining-count {
  margin-left: 8px;
}
</style>

Inline Editing with Form Components

FancyCRUD automatically renders form components for inline editing when you enable the input option in column configuration:

ts
const table = useTable({
  form,
  columns: {
    name: {
      label: 'Name',
      input: {
        isEnable: true,  // Enable inline editing
        type: 'text',     // Form field type
      },
    },
    status: {
      label: 'Status',
      input: {
        isEnable: true,
        type: 'select',
        options: [
          { label: 'Active', value: 'active' },
          { label: 'Inactive', value: 'inactive' },
        ],
      },
    },
    verified: {
      label: 'Verified',
      input: {
        isEnable: true,
        type: 'checkbox',
      },
    },
  },
  settings: {
    url: 'users/',
  },
})

The table will automatically:

  • Render the appropriate form component in the cell
  • Handle value updates via UpdateRowValueCommand
  • Support all form field types (text, select, checkbox, datepicker, etc.)
  • Provide validation and error handling

Rating Display Component

vue
<!-- RatingDisplay.vue -->
<template>
  <div class="rating-display">
    <v-rating
      :model-value="rating"
      :length="5"
      size="small"
      readonly
      color="amber"
    />
    <span class="rating-text">{{ rating.toFixed(1) }}</span>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
  value: number
  maxRating?: number
}>()

const rating = computed(() => 
  Math.min(props.value, props.maxRating || 5)
)
</script>

<style scoped>
.rating-display {
  display: flex;
  align-items: center;
  gap: 8px;
}

.rating-text {
  font-size: 0.875rem;
  color: #666;
}
</style>

Creating Custom Table Components

Just like the form wrappers, you can create custom table body and footer components to integrate with different UI frameworks.

Custom Table Body Component

Create a custom table body component that receives the table data and renders it using your UI framework:

vue
<!-- CustomTableBody.vue -->
<template>
  <v-data-table
    v-bind="$attrs"
    :headers="parseHeaders"
    :items="props.items"
    :loading="props.loading"
    class="elevation-1"
    item-value="id"
  >
    <!-- Render each column with slot support -->
    <template 
      v-for="(column, index) in columnsWithoutActions" 
      :key="index" 
      #[`item.${column.value}`]="{ item, index }"
    >
      <slot 
        :name="`column-${column.value}`" 
        v-bind="{ row: item, item, index }"
      >
        {{ getValue(item, column, index) }}
      </slot>
    </template>
    
    <!-- Actions column -->
    <template v-if="hasActionColumn" #[`item.actions`]="{ item }">
      <slot name="column-actions" v-bind="{ row: item, item }">
        <slot name="column-actions-prepend" v-bind="{ row: item, item }" />
        <f-table-row-actions
          @edit="emit('edit', item)"
          @delete="emit('delete', item)"
          :edit="props.buttons.edit"
          :delete="props.buttons.remove"
        />
        <slot name="column-actions-append" v-bind="{ row: item, item }" />
      </slot>
    </template>
  </v-data-table>
</template>

<script lang="ts" setup>
import { VDataTable } from 'vuetify/labs/VDataTable'
import type { NormalizedColumn } from '@fancy-crud/core'
import { Bus, GetColumnValueCommand } from '@fancy-crud/core'
import type { TableBodyProps, TableBodyEmit } from '@fancy-crud/vue'
import { FTableRowActions } from '@fancy-crud/vue'

const props = defineProps<TableBodyProps>()
const emit = defineEmits<TableBodyEmit>()

const bus = new Bus()

// Transform headers for Vuetify
const parseHeaders = computed(() => 
  props.headers.map(header => ({
    ...header,
    title: header.label,
    key: header.value,
  }))
)

const columnsWithoutActions = computed(() => 
  parseHeaders.value.filter(h => h.key !== 'actions')
)

const hasActionColumn = computed(() => 
  props.headers.some(h => h.value === 'actions' && !h.exclude)
)

function getValue(row: any, column: NormalizedColumn, rowIndex: number) {
  return bus.execute(
    new GetColumnValueCommand(row, column, rowIndex)
  )
}
</script>

Required Props (TableBodyProps)

ts
interface TableBodyProps {
  items: any[]                    // Table data rows
  headers: NormalizedColumn[]     // Column definitions
  loading: boolean                // Loading state
  buttons: NormalizedTableButtons // Button configurations
}

Required Emits (TableBodyEmit)

ts
interface TableBodyEmit {
  (e: 'edit', row: any): void    // Emitted when edit is clicked
  (e: 'delete', row: any): void  // Emitted when delete is clicked
}

Create a custom footer component for pagination:

vue
<!-- CustomTableFooter.vue -->
<template>
  <div class="d-flex justify-space-between align-center">
    <!-- Rows per page selector -->
    <v-menu location="bottom">
      <template #activator="{ props: menuProps }">
        <v-btn
          v-bind="menuProps"
          append-icon="mdi-chevron-down"
          variant="outlined"
        >
          {{ state.pagination.perPage }} / page
        </v-btn>
      </template>

      <v-list>
        <v-list-item
          v-for="option in props.pagination.rowsPerPageOptions"
          :key="option"
          @click="state.pagination.perPage = option"
        >
          <v-list-item-title>{{ option }}</v-list-item-title>
        </v-list-item>
      </v-list>
    </v-menu>

    <!-- Pagination -->
    <v-pagination
      v-model="state.pagination.page"
      :length="totalPages"
      :total-visible="7"
      size="small"
    />
  </div>
</template>

<script lang="ts" setup>
import { VBtn, VList, VListItem, VListItemTitle, VMenu, VPagination } from 'vuetify/components'
import type { TableFooterProps, TableFooterEmit } from '@fancy-crud/vue'
import { useTableFooter } from '@fancy-crud/vue'

const props = defineProps<TableFooterProps>()
const emit = defineEmits<TableFooterEmit>()

const state = useTableFooter(props.pagination, emit)

const totalPages = computed(() => 
  Math.ceil(props.pagination.count / state.pagination.perPage)
)
</script>

Required Props (TableFooterProps)

ts
interface TableFooterProps {
  pagination: NormalizedTablePagination
}

interface NormalizedTablePagination {
  page: number
  rowsPerPage: number
  rowsPerPageOptions: number[]
  count: number
  hidden: boolean
}

Required Emits (TableFooterEmit)

ts
interface TableFooterEmit {
  (e: 'update:pagination', pagination: Pagination): void
}

interface Pagination {
  page?: number
  rowsPerPage?: number
}

Using useTableFooter Composable

The useTableFooter composable handles pagination state and emits updates:

ts
const state = useTableFooter(props.pagination, emit)

// state.pagination.perPage - current rows per page
// state.pagination.page - current page
// Automatically emits 'update:pagination' when these change

Registering Custom Table Components

Register your custom components globally:

ts
// fancy-crud.config.ts
import { defineConfig } from '@fancy-crud/vue'
import axios from 'axios'

// Import your custom components
import CustomTableBody from './components/CustomTableBody.vue'
import CustomTableFooter from './components/CustomTableFooter.vue'

export const fancyCrud = defineConfig({
  http: { request: axios },
  components: {
    tableBody: CustomTableBody,
    tableFooter: CustomTableFooter,
  },
})

Complete Example: Element Plus Table Components

Here's a complete example using Element Plus instead of Vuetify:

vue
<!-- ElTableBody.vue -->
<template>
  <el-table
    v-bind="$attrs"
    :data="props.items"
    v-loading="props.loading"
    stripe
  >
    <!-- Render columns -->
    <el-table-column
      v-for="(column, index) in columnsWithoutActions"
      :key="index"
      :prop="column.value"
      :label="column.label"
    >
      <template #default="{ row, $index }">
        <slot 
          :name="`column-${column.value}`" 
          v-bind="{ row, item: row, index: $index }"
        >
          {{ getValue(row, column, $index) }}
        </slot>
      </template>
    </el-table-column>
    
    <!-- Actions column -->
    <el-table-column
      v-if="hasActionColumn"
      label="Actions"
      width="150"
      fixed="right"
    >
      <template #default="{ row }">
        <slot name="column-actions" v-bind="{ row, item: row }">
          <slot name="column-actions-prepend" v-bind="{ row }" />
          
          <el-button
            v-if="!props.buttons.edit.hidden"
            type="primary"
            size="small"
            @click="emit('edit', row)"
          >
            Edit
          </el-button>
          
          <el-button
            v-if="!props.buttons.remove.hidden"
            type="danger"
            size="small"
            @click="emit('delete', row)"
          >
            Delete
          </el-button>
          
          <slot name="column-actions-append" v-bind="{ row }" />
        </slot>
      </template>
    </el-table-column>
  </el-table>
</template>

<script lang="ts" setup>
import type { TableBodyProps, TableBodyEmit } from '@fancy-crud/vue'
import { Bus, GetColumnValueCommand } from '@fancy-crud/core'

const props = defineProps<TableBodyProps>()
const emit = defineEmits<TableBodyEmit>()

const bus = new Bus()

const columnsWithoutActions = computed(() => 
  props.headers.filter(h => h.value !== 'actions' && !h.exclude)
)

const hasActionColumn = computed(() => 
  props.headers.some(h => h.value === 'actions' && !h.exclude)
)

function getValue(row: any, column: any, rowIndex: number) {
  return bus.execute(
    new GetColumnValueCommand(row, column, rowIndex)
  )
}
</script>
vue
<!-- ElTableFooter.vue -->
<template>
  <div class="el-table-footer">
    <el-pagination
      v-model:current-page="state.pagination.page"
      v-model:page-size="state.pagination.perPage"
      :page-sizes="props.pagination.rowsPerPageOptions"
      :total="props.pagination.count"
      layout="sizes, prev, pager, next, total"
      background
    />
  </div>
</template>

<script lang="ts" setup>
import type { TableFooterProps, TableFooterEmit } from '@fancy-crud/vue'
import { useTableFooter } from '@fancy-crud/vue'

const props = defineProps<TableFooterProps>()
const emit = defineEmits<TableFooterEmit>()

const state = useTableFooter(props.pagination, emit)
</script>

<style scoped>
.el-table-footer {
  margin-top: 16px;
  display: flex;
  justify-content: flex-end;
}
</style>

Register the Element Plus components:

ts
// fancy-crud.config.ts
import { defineConfig } from '@fancy-crud/vue'
import axios from 'axios'
import ElTableBody from './components/ElTableBody.vue'
import ElTableFooter from './components/ElTableFooter.vue'

export const fancyCrud = defineConfig({
  http: { request: axios },
  components: {
    tableBody: ElTableBody,
    tableFooter: ElTableFooter,
  },
})

Key Points

  1. Always implement required props and emits - TableBodyProps, TableBodyEmit, TableFooterProps, TableFooterEmit
  2. Support all slots - Forward column slots, action slots to allow customization
  3. Use GetColumnValueCommand - To properly extract column values with field/format support
  4. Use useTableFooter composable - For consistent pagination behavior
  5. Emit events correctly - edit and delete events with row data, update:pagination with pagination changes
  6. Integrate your UI framework - Wrap framework-specific table components (VDataTable, ElTable, etc.)

Column Data Transformation

Field Function

Use the field function to extract or compute values from row data:

ts
const table = useTable({
  form,
  columns: {
    fullName: {
      label: 'Full Name',
      field: (row) => `${row.firstName} ${row.lastName}`,
    },
    fullAddress: {
      label: 'Address',
      field: (row) => `${row.street}, ${row.city}, ${row.state} ${row.zip}`,
    },
    daysActive: {
      label: 'Days Active',
      field: (row) => {
        const start = new Date(row.created_at)
        const now = new Date()
        return Math.floor((now - start) / (1000 * 60 * 60 * 24))
      },
    },
  },
})

Format Function

Use the format function to format the displayed value:

ts
const table = useTable({
  form,
  columns: {
    salary: {
      label: 'Salary',
      field: (row) => row.salary,
      format: (value) => new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD',
      }).format(value),
    },
    createdAt: {
      label: 'Created',
      field: (row) => row.created_at,
      format: (value) => new Date(value).toLocaleDateString('en-US', {
        year: 'numeric',
        month: 'long',
        day: 'numeric',
      }),
    },
    discount: {
      label: 'Discount',
      field: (row) => row.discount,
      format: (value) => `${(value * 100).toFixed(0)}%`,
    },
  },
})

Best Practices

1. Use Slots for Column Customization

Slots are the primary way to customize table columns:

vue
<template>
  <f-table v-bind="table">
    <template #column-email="{ row }">
      <a :href="`mailto:${row.email}`">{{ row.email }}</a>
    </template>
  </f-table>
</template>

2. Use Field/Format Functions for Data Transformation

For simple data transformation, prefer field and format functions over slots:

ts
// ✅ Good - Simple transformation
columns: {
  name: {
    field: (row) => row.firstName + ' ' + row.lastName
  }
}

// ❌ Not needed - Use slots for complex UI
<template #column-name="{ row }">
  {{ row.firstName }} {{ row.lastName }}
</template>

3. Provide Data Fallbacks

Handle missing or invalid data gracefully:

vue
<template #column-avatar="{ row }">
  <v-avatar size="32">
    <v-img :src="row.avatar || '/default-avatar.png'" />
  </v-avatar>
</template>

4. Use Action Slots for Custom Buttons

Add custom actions using the provided action slots:

vue
<template>
  <f-table v-bind="table">
    <template #column-actions-prepend="{ row }">
      <v-btn icon="mdi-eye" @click="view(row)" />
    </template>
  </f-table>
</template>

5. Leverage Inline Editing

Use input.isEnable for editable columns instead of building custom edit UI:

ts
// ✅ Good - Use built-in inline editing
columns: {
  name: {
    label: 'Name',
    input: { isEnable: true, type: 'text' }
  }
}

6. Use TypeScript for Better DX

Type your slot props for better developer experience:

vue
<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
  status: 'active' | 'pending' | 'inactive'
}

const table = useTable<User>({
  // ...
})
</script>

<template>
  <f-table v-bind="table">
    <template #column-status="{ row }: { row: User }">
      <!-- row is now properly typed -->
    </template>
  </f-table>
</template>

Advanced Patterns

Conditional Rendering

Show different components based on data:

vue
<template>
  <f-table v-bind="table">
    <template #column-document="{ row }">
      <v-btn
        v-if="row.documentUrl"
        icon="mdi-file-document"
        size="small"
        @click="openDocument(row.documentUrl)"
      />
      <v-chip v-else size="small" color="grey">
        No document
      </v-chip>
    </template>
    
    <template #column-status="{ row }">
      <v-chip
        :color="getStatusColor(row.status)"
        size="small"
      >
        {{ row.status }}
      </v-chip>
    </template>
  </f-table>
</template>

<script setup lang="ts">
const getStatusColor = (status: string) => {
  const colors: Record<string, string> = {
    active: 'success',
    pending: 'warning',
    inactive: 'error',
  }
  return colors[status] || 'default'
}

const openDocument = (url: string) => {
  window.open(url, '_blank')
}
</script>

Replacing Table Sections

Replace entire sections using major slots:

vue
<template>
  <f-table v-bind="table">
    <!-- Custom header section -->
    <template #table-header="{ onCreate, onExport, add, dump }">
      <div class="custom-header">
        <h2>User Management</h2>
        <div class="actions">
          <v-btn @click="onCreate" v-bind="add">
            Add User
          </v-btn>
          <v-btn @click="onExport" v-bind="dump">
            Export
          </v-btn>
        </div>
      </div>
    </template>
    
    <!-- Custom footer section -->
    <template #table-footer="{ pagination }">
      <div class="custom-footer">
        <span>Total: {{ pagination.count }} users</span>
        <!-- Your custom pagination UI -->
      </div>
    </template>
  </f-table>
</template>

Customizing the Form Dialog

Customize the table's form dialog:

vue
<template>
  <f-table v-bind="table">
    <template #table-form="{ form, id, onSuccess }">
      <v-card>
        <v-card-title>Custom Form Title</v-card-title>
        <v-card-text>
          <f-form v-bind="form" :id="id" @success="onSuccess" />
        </v-card-text>
      </v-card>
    </template>
  </f-table>
</template>

Next Steps