Building a Reusable Element Plus Data Table with Filtering, Pagination, Dynamic Columns, and Inline Editing

Table Configuration

Create a configuration file to define grid schemas and search parameters. Each column definition requires a field (data key) and header (display name). Optional properties control alignment, width, rendering types (e.g., timestamp, image, switch, tag), and inline editing.

// grid.config.ts
import type { ColumnDef, FilterDef } from './types';

export const roleColumns: ColumnDef[] = [
  { field: 'roleName', header: 'Role Name', editable: true, minWidth: 140 },
  { field: 'roleKey', header: 'Role Key', align: 'center', width: 180 },
  { field: 'deptName', header: 'Department', align: 'center', width: 180 },
  { field: 'createdAt', header: 'Created At', align: 'center', type: 'timestamp', width: 180 }
];

export const roleFilters: FilterDef[] = [
  { label: 'Role Name', field: 'keyword', kind: 'text' },
  { label: 'Department', field: 'deptId', kind: 'select', options: [], valueKey: 'id', labelKey: 'name' },
  { label: 'Date Range', field: 'dateRange', kind: 'daterange' }
];

Reusable Table Component

Build the core component to handle rendering, filtering, pagination, and dynamic visibility. The component accepts configuration and data via props, and emits events for data fetching and user interactions, avoiding callback props.

<template>
  <div class="data-grid-wrapper">
    <div class="toolbar">
      <el-form :model="filterState" inline>
        <el-form-item :label="f.label" v-for="f in filters" :key="f.field">
          <el-input v-if="f.kind === 'text'" v-model="filterState[f.field]" :placeholder="`Search ${f.label}`" clearable />
          <el-select v-else-if="f.kind === 'select'" v-model="filterState[f.field]" clearable :placeholder="`Select ${f.label}`">
            <el-option v-for="opt in f.options" :key="opt[f.valueKey]" :label="opt[f.labelKey]" :value="opt[f.valueKey]" />
          </el-select>
          <el-date-picker v-else-if="f.kind === 'daterange'" v-model="filterState[f.field]" type="daterange" range-separator="To" start-placeholder="Start" end-placeholder="End" />
        </el-form-item>
      </el-form>
      <div class="actions">
        <el-button type="primary" @click="triggerFetch">Search</el-button>
        <el-button @click="resetFilters">Reset</el-button>
        <slot name="toolbar-buttons"></slot>
        <el-popover trigger="click" width="200">
          <div v-for="col in columns" :key="col.field">
            <el-checkbox v-model="col.visible">{{ col.header }}</el-checkbox>
          </div>
          <template #reference>
            <el-button :icon="Setting" circle />
          </template>
        </el-popover>
      </div>
    </div>

    <el-table :data="records" v-loading="isLoading" border @cell-dblclick="activateEditor">
      <el-table-column type="selection" width="50" align="center" />
      <template v-for="col in visibleColumns" :key="col.field">
        <el-table-column :prop="col.field" :label="col.header" :align="col.align || 'left'" :width="col.width" :min-width="col.minWidth">
          <template #default="{ row, $index }">
            <el-switch v-if="col.type === 'switch'" v-model="row[col.field]" :active-value="col.activeVal" :inactive-value="col.inactiveVal" @change="$emit('status-change', row)" />
            <el-tag v-else-if="col.type === 'tag'" :type="resolveTagType(row[col.field], col.tagColors)">{{ resolveLabel(row[col.field], col.options) }}</el-tag>
            <el-image v-else-if="col.type === 'image'" :src="row[col.field]" style="width: 50px; height: 50px" fit="cover" />
            <span v-else-if="col.type === 'timestamp'">{{ formatTs(row[col.field]) }}</span>
            <template v-else-if="col.editable">
              <el-input v-if="activeCell?.rowIdx === $index && activeCell?.field === col.field" v-model="row[col.field]" @blur="confirmEdit(row, col.field)" autofocus />
              <span v-else class="editable-cell">{{ row[col.field] }}</span>
            </template>
            <span v-else>{{ row[col.field] }}</span>
          </template>
        </el-table-column>
      </template>
      <slot name="operation-column"></slot>
    </el-table>

    <el-pagination class="pager" v-model:current-page="pageState.current" v-model:page-size="pageState.size" :page-sizes="[10, 20, 50]" :total="pageState.total" layout="total, sizes, prev, pager, next" @size-change="triggerFetch" @current-change="triggerFetch" />
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
import { Setting } from '@element-plus/icons-vue';

const props = defineProps<{
  columns: any[];
  filters: any[];
  records: any[];
  isLoading: boolean;
  pagination: { current: number; size: number; total: number };
}>();

const emit = defineEmits(['fetch', 'status-change', 'cell-update']);

const filterState = reactive<Record<string, any>>({});
const pageState = reactive(props.pagination);
const activeCell = ref<{ rowIdx: number; field: string } | null>(null);

const visibleColumns = computed(() => props.columns.filter(c => c.visible !== false));

function triggerFetch() {
  emit('fetch', { ...filterState, current: pageState.current, size: pageState.size });
}

function resetFilters() {
  Object.keys(filterState).forEach(k => filterState[k] = undefined);
  triggerFetch();
}

function activateEditor(row: any, column: any) {
  const colDef = props.columns.find(c => c.field === column.property);
  if (colDef?.editable) {
    activeCell.value = { rowIdx: row.rowIndex, field: colDef.field };
  }
}

function confirmEdit(row: any, field: string) {
  activeCell.value = null;
  emit('cell-update', { ...row });
}

function resolveLabel(val: string | number, options: Record<string, string>) {
  return options[val] || val;
}

function resolveTagType(val: string | number, colors: Record<string, string>) {
  return colors[val] || 'info';
}

function formatTs(ts: number) {
  return new Date(ts).toLocaleString();
}
</script>

Implementation Usage

Integrate the table component into a view. Pass the configurations and handle the fetch event to load data based on current filters and pagination state.

<template>
  <DataTable
    :columns="roleColumns"
    :filters="roleFilters"
    :records="tableData"
    :is-loading="fetching"
    :pagination="pagination"
    @fetch="loadData"
  >
    <template #toolbar-buttons>
      <el-button type="success">Add Role</el-button>
    </template>
    <template #operation-column>
      <el-table-column label="Actions" width="150" align="center">
        <template #default="{ row }">
          <el-button size="small" @click="handleEdit(row)">Edit</el-button>
        </template>
      </el-table-column>
    </template>
  </DataTable>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import DataTable from './DataTable.vue';
import { roleColumns, roleFilters } from './grid.config';

const tableData = ref([]);
const fetching = ref(false);
const pagination = reactive({ current: 1, size: 10, total: 0 });

onMounted(() => loadData());

async function loadData(params: any = {}) {
  fetching.value = true;
  // Simulate API call
  // const res = await api.getRoles(params);
  // tableData.value = res.data.list;
  // pagination.total = res.data.total;
  fetching.value = false;
}

function handleEdit(record: any) {
  console.log('Editing:', record);
}
</script>

Tags: vue3 Element Plus Component Encapsulation Data Table frontend

Posted on Wed, 05 Aug 2026 16:29:02 +0000 by php_2004