DXTable

DXTable

A comprehensive data table component with built-in pagination, sorting, filtering, and CRUD operations for Laravel dashboards.

Data Fetching Modes

DXTable supports three modes for loading data. API mode is recommended for server-side operations. Client-side mode is great for smaller datasets that can be filtered/sorted locally.

ModePropBest For
API Mode (Recommended)api-url="/api/products"Server-side pagination. Clean separation, reusable endpoints.
Client-Side Modeclient-side + :itemsSmaller datasets (<1000 rows). Instant filtering without server requests.
Inertia Modeinertia-url="/" + :itemsApps already using Inertia.js with server-side page props.

Quick Comparison

FeatureAPI ModeClient-SideInertia Mode
Setup1 prop: api-url2 props: items, client-side3 props: items, pagination, inertia-url
Data SourceAuto-fetched via axiosPassed as items propPassed as page props
Sorting/FilteringServer-side (AJAX)Client-side (instant)Server-side (page reload)
PaginationServer-sideClient-sideServer-side
Best ForLarge datasetsSmall datasets (<1000)Inertia apps

Live Example

Props

NameTypeRequiredDefaultDescription
titlestringNo-Table title
itemNamestringNoitemSingular item name (auto-pluralized for display)
itemsTItem[]No-Table data items (for Inertia or client-side mode)
clientSidebooleanNofalseEnable client-side filtering, sorting, and pagination
providerBTableProvider<TItem>No-Provider function for API mode
apiUrlstringNo-API endpoint URL for auto-provider mode
fieldsTableField[]Yes-Table field definitions (see TableField interface)
sortByBTableSortBy[]No[]Sort configuration (v-model support)
filtersRecord<string, string>No{}Filter values (v-model support)
filterValuesRecord<string, string[]>No-Dynamic filter options from server
inertiaUrlstringNo-Inertia route URL (enables auto-navigation)
busybooleanNofalseLoading/busy state (v-model support, API mode)
loadingbooleanNofalseLoading state (Inertia mode, deprecated - use busy)
loadingTextstringNoLoading...Loading text
errorstring | nullNonullError message
paginationPaginationDataNofunctionPagination data (Inertia mode)
showPaginationbooleanNotrueShow pagination controls
showPerPageSelectorbooleanNotrueShow per-page selector
perPageOptionsnumber[]No[10, 25, 50, 100]Per-page options for selector
currentPagenumberNo1Current page (for provider mode)
perPagenumberNo10Items per page (v-model support)
stripedbooleanNotrueStriped rows
hoverbooleanNotrueHover effect on rows
responsivebooleanNotrueResponsive table
fluidbooleanNofalseFluid container
containerClassstringNopy-5Container CSS class
columnSizestring | numberNo12Column size (Bootstrap grid)
editFieldsFieldDefinition[]No-Form fields for edit modal (enables edit on row click)
editTabsEditTab[]No-Tab definitions for organizing edit modal content
editModalTitlestring | ((item: any) => string)No-Edit modal title (string or function)
editModalSizesm | md | lg | xlNolgEdit modal size
editUrlstringNo-API endpoint pattern for updates (e.g., "/api/products/:id")
deleteUrlstringNo-API endpoint pattern for deletions (e.g., "/api/products/:id")

Events

NameParametersDescription
pageChangepage: numberEmitted when the page changes
sortChangesort: { key: string, order: "asc" | "desc" }Emitted when sort changes
filterChangefilters: Record<string, string>Emitted when filters change
perPageChangeperPage: numberEmitted when per-page value changes
rowClickeditem: T, index: number, event: MouseEventEmitted when a row is clicked
rowUpdateditem: T, response: anyEmitted when a row is successfully updated
editErroritem: T, error: anyEmitted when row update fails
rowDeleteditem: T, response: anyEmitted when a row is successfully deleted
deleteErroritem: T, error: anyEmitted when row deletion fails
update:sortBysortBy: BTableSortBy[]v-model update for sortBy
update:filtersfilters: Record<string, string>v-model update for filters
update:perPageperPage: numberv-model update for perPage
update:busybusy: booleanv-model update for busy state

Slots

NameDescriptionScoped Props
headerCustom header content-

API mode is the recommended approach for most applications. Just provide an api-url and DXTable handles everything: fetching data, pagination, sorting, and loading states.

Why API Mode?

  • Simpler setup - One prop instead of three
  • Better separation - Your API is independent of your UI framework
  • Reusable endpoints - Same API works for mobile apps, other frontends, etc.
  • Better caching - JSON responses cache more efficiently than full page responses
  • No framework lock-in - Works whether you use Inertia, traditional Blade, or a pure SPA

Laravel Backend (API Endpoint)

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\Request;

class ProductController extends Controller
{
    public function apiIndex(Request $request)
    {
        $page = $request->input('page', 1);
        $perPage = $request->input('perPage', 10);
        $sortBy = $request->input('sortBy', 'created_at');
        $sortOrder = $request->input('sortOrder', 'desc');

        // Whitelist allowed sort columns for security
        $allowedSortColumns = ['sku', 'name', 'price', 'stock', 'created_at'];

        if (!in_array($sortBy, $allowedSortColumns)) {
            $sortBy = 'created_at';
        }

        if (!in_array(strtolower($sortOrder), ['asc', 'desc'])) {
            $sortOrder = 'desc';
        }

        $products = Product::orderBy($sortBy, $sortOrder)
            ->paginate($perPage, ['*'], 'page', $page);

        return response()->json([
            'data' => $products->items(),
            'pagination' => [
                'current_page' => $products->currentPage(),
                'per_page' => $products->perPage(),
                'total' => $products->total(),
                'from' => $products->firstItem(),
                'to' => $products->lastItem(),
                'last_page' => $products->lastPage(),
            ],
        ]);
    }
}

Route:

// routes/api.php
Route::get('/products', [ProductController::class, 'apiIndex']);

Vue Frontend (Simple API Mode)

<script setup lang="ts">
import { ref } from 'vue';
import { DXTable } from '@omnitend/dashboard-for-laravel';

const fields = [
  { key: 'sku', label: 'SKU', sortable: true },
  { key: 'name', label: 'Name', sortable: true },
  { key: 'price', label: 'Price', sortable: true },
  { key: 'stock', label: 'Stock', sortable: true },
];

const busy = ref(false);
</script>

<template>
  <DXTable
    title="Products"
    api-url="/api/products"
    :fields="fields"
    v-model:busy="busy"
    :per-page="10"
  />
</template>

That’s it! Just pass api-url and DXTable handles:

  • AJAX requests with axios
  • Sorting parameters
  • Pagination parameters
  • Data extraction from response.data.data
  • Error handling

Advanced: Custom Provider Function

For custom API logic (auth headers, data transformation, etc.), provide your own provider:

<script setup>
const customProvider = async (context) => {
  const response = await fetch('/api/products', {
    headers: { 'Authorization': `Bearer ${token}` },
    ...
  });
  const json = await response.json();
  return json.items; // Custom response structure
};
</script>

<template>
  <DXTable :provider="customProvider" :fields="fields" />
</template>

Provider Function Details

The provider function receives a context object:

interface BTableProviderContext {
  sortBy?: BTableSortBy[];      // Current sort state
  filter?: string;               // Filter string (if filtering enabled)
  currentPage: number;           // Current page number
  perPage: number;               // Items per page
}

Important:

  • Return an array of items (or Promise that resolves to array)
  • BTable handles pagination UI automatically
  • No need to show “Showing X to Y” text (provider mode)
  • Sorting and pagination trigger automatic provider calls

Adding Filters

Add inline filters by specifying filter in field definitions:

const fields = [
  { key: 'sku', label: 'SKU', sortable: true },
  { key: 'name', label: 'Name', sortable: true, filter: 'text' },
  { key: 'category', label: 'Category', sortable: true, filter: 'select', filterOptions: [
    { value: 'Electronics', text: 'Electronics' },
    { value: 'Clothing', text: 'Clothing' },
    { value: 'Books', text: 'Books' },
  ]},
  { key: 'price', label: 'Price', sortable: true, filter: 'number' },
  { key: 'stock', label: 'Stock', sortable: true, filter: 'number' },
];

Filter Types:

  • 'text' - Text input with 300ms debounce for LIKE searches
  • 'select' - Dropdown with options (automatically adds “All” option)
  • 'number' - Number input for exact matches
  • 'date' - Date input for date filtering
  • false or omit - No filter for this column

Filters appear inline beneath the table headers and trigger server requests automatically.

Field Hints

Add helpful hint text below column headers to guide users:

const fields = [
  { key: 'sku', label: 'SKU', sortable: true, hint: 'Product code' },
  { key: 'name', label: 'Name', sortable: true, filter: 'text', hint: 'Search by name' },
  { key: 'price', label: 'Price', sortable: true, hint: 'USD', formatter: (value) => `$${value}` },
  { key: 'stock', label: 'Stock', sortable: true, hint: 'Current inventory' },
];

Features:

  • Hint text appears below the column label in a smaller, muted font
  • Works with sortable columns (hint appears above sort indicators)
  • Works with filters (hint appears in column header, above filter input)
  • Useful for units (USD, kg), instructions (Search by name), or context (Current inventory)
  • Optional - only shows when hint property is provided

Backend Filter Handling

Update your controller to accept and apply filters:

public function apiIndex(Request $request)
{
    $page = $request->input('page', 1);
    $perPage = $request->input('perPage', 10);
    $sortBy = $request->input('sortBy', 'created_at');
    $sortOrder = $request->input('sortOrder', 'desc');

    // Build query with filters
    $query = Product::query();
    $filters = $request->input('filters', []);

    // Text filters (LIKE search)
    if (!empty($filters['name'])) {
        $query->where('name', 'LIKE', '%' . $filters['name'] . '%');
    }

    // Exact match filters
    if (!empty($filters['category'])) {
        $query->where('category', $filters['category']);
    }

    if (!empty($filters['price'])) {
        $query->where('price', '=', $filters['price']);
    }

    // Whitelist sort columns
    $allowedSortColumns = ['sku', 'name', 'price', 'stock', 'created_at'];
    if (!in_array($sortBy, $allowedSortColumns)) {
        $sortBy = 'created_at';
    }

    $products = $query->orderBy($sortBy, $sortOrder)
        ->paginate($perPage, ['*'], 'page', $page);

    return response()->json([
        'data' => $products->items(),
        'pagination' => [
            'current_page' => $products->currentPage(),
            'per_page' => $products->perPage(),
            'total' => $products->total(),
            'from' => $products->firstItem(),
            'to' => $products->lastItem(),
            'last_page' => $products->lastPage(),
        ],
    ]);
}

Filter Behavior:

  • Text filters automatically debounce (300ms) to reduce server load
  • Filtering resets to page 1
  • Empty filters are removed from request
  • Filters preserve sort state

Manual Refresh

Access the refresh() method for manual data reloading:

<script setup>
import { ref } from 'vue';

const tableRef = ref(null);

const refreshData = () => {
  tableRef.value?.refresh();
};
</script>

<template>
  <DButton @click="refreshData">Refresh</DButton>
  <DXTable
    ref="tableRef"
    :provider="fetchProducts"
    :fields="fields"
  />
</template>

Client-Side Mode

Client-side mode is perfect for smaller datasets where you want instant filtering and sorting without server requests. Data is loaded once (either statically or from an API), then all filtering, sorting, and pagination happens in the browser.

When to Use Client-Side Mode

  • Dataset is small (< 1000 rows)
  • You want instant filtering without network latency
  • Data doesn’t change frequently
  • You’re building a simple admin interface or demo

Basic Usage

<script setup lang="ts">
import { ref } from 'vue';
import { DXTable } from '@omnitend/dashboard-for-laravel';

const fields = [
  { key: 'id', label: 'ID', sortable: true },
  { key: 'name', label: 'Name', sortable: true, filter: 'text' },
  { key: 'email', label: 'Email', filter: 'text' },
  { key: 'status', label: 'Status', filter: 'select', filterOptions: [
    { value: 'Active', text: 'Active' },
    { value: 'Inactive', text: 'Inactive' },
  ]},
];

const items = ref([
  { id: 1, name: 'John Smith', email: 'john@example.com', status: 'Active' },
  { id: 2, name: 'Jane Doe', email: 'jane@example.com', status: 'Active' },
  { id: 3, name: 'Bob Johnson', email: 'bob@example.com', status: 'Inactive' },
  // ... more items
]);
</script>

<template>
  <DXTable
    title="Users"
    item-name="user"
    :items="items"
    :fields="fields"
    :client-side="true"
  />
</template>

Filter Types

The same filter types work in client-side mode:

  • 'text' - Case-insensitive contains search
  • 'select' - Exact match dropdown
  • 'number' - Exact numeric match
  • 'date' - Exact date match

Performance Considerations

  • Client-side mode loads all data into memory
  • For datasets > 1000 rows, consider API mode with server-side filtering
  • Filtering and sorting are instant (no network requests)
  • Pagination happens locally on the filtered/sorted data

Inertia Mode (Alternative)

If your application already uses Inertia.js and you prefer server-side page props, you can use Inertia mode instead of API mode.

When to Use Inertia Mode

  • Your app is built entirely with Inertia.js
  • You want the browser URL to update with sort/filter/page parameters
  • You’re already using PaginatedResource for other Inertia pages

Laravel Backend (Inertia)

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\Request;
use Inertia\Inertia;
use OmniTend\LaravelDashboard\Http\Resources\PaginatedResource;

class ProductController extends Controller
{
    public function index(Request $request)
    {
        $perPage = $request->input('perPage', 10);
        $sortBy = $request->input('sortBy', 'created_at');
        $sortOrder = $request->input('sortOrder', 'desc');

        // Whitelist allowed sort columns
        $allowedSortColumns = ['sku', 'name', 'price', 'stock', 'created_at'];
        if (!in_array($sortBy, $allowedSortColumns)) {
            $sortBy = 'created_at';
        }

        $products = Product::orderBy($sortBy, $sortOrder)->paginate($perPage);

        return Inertia::render('Products/Index', [
            'products' => new PaginatedResource($products),
        ]);
    }
}

Vue Frontend (Inertia)

<script setup lang="ts">
import { DXTable } from '@omnitend/dashboard-for-laravel';
import type { PaginationData } from '@omnitend/dashboard-for-laravel';

interface Product {
  id: number;
  sku: string;
  name: string;
  price: string;
  stock: number;
}

interface Props {
  products: PaginationData & { data: Product[] };
}

defineProps<Props>();

const fields = [
  { key: 'sku', label: 'SKU', sortable: true },
  { key: 'name', label: 'Name', sortable: true },
  { key: 'price', label: 'Price', sortable: true },
  { key: 'stock', label: 'Stock', sortable: true },
];
</script>

<template>
  <DXTable
    title="Products"
    :items="products.data"
    :fields="fields"
    :pagination="products"
    inertia-url="/"
  />
</template>

Key differences from API mode:

  • Pass data via :items and :pagination props (from Inertia page props)
  • Use inertia-url instead of api-url
  • URL updates when sorting/filtering/paginating
  • Uses :loading prop instead of v-model:busy

Custom Event Handlers (No Auto-Navigation)

If you need custom behavior, omit inertia-url and handle events manually:

<DXTable
  :items="products.data"
  :fields="fields"
  :pagination="products"
  @page-change="customPageHandler"
  @sort-change="customSortHandler"
/>

TableField Interface

Fields support the following properties:

interface TableField {
  key: string;                    // Required: Field key (matches data property)
  label?: string;                 // Column header label
  sortable?: boolean;             // Enable sorting for this column
  hint?: string;                  // Hint text below column header
  filter?: FilterType;            // Filter type: 'text' | 'select' | 'number' | 'date' | false
  filterOptions?: FilterOption[]; // Options for select filters
  filterPlaceholder?: string;     // Placeholder for filter input
  formatter?: (value: any, key: string, item: any) => string; // Custom formatter function
  [key: string]: any;             // Any other Bootstrap Vue Next BTable field props
}

Edit Modals

Enable inline editing by providing editFields and editUrl:

<script setup>
const fields = [
  { key: 'name', label: 'Product Name', sortable: true },
  { key: 'price', label: 'Price', sortable: true },
];

const editFields = [
  { key: 'name', label: 'Product Name', type: 'text', required: true },
  { key: 'description', label: 'Description', type: 'textarea' },
  { key: 'price', label: 'Price', type: 'number', required: true },
  { key: 'stock', label: 'Stock', type: 'number', required: true },
];
</script>

<template>
  <DXTable
    :items="products.data"
    :fields="fields"
    :edit-fields="editFields"
    edit-url="/api/products/:id"
    edit-modal-title="Edit Product"
  />
</template>

Features:

  • Click any row to open edit modal
  • Form fields auto-populated from row data
  • Save button submits PUT request to editUrl (:id replaced with item.id)
  • Success/error toasts shown automatically
  • Table refreshes after successful save
  • Validation errors displayed inline

Delete Functionality

Enable deletion with the deleteUrl prop:

<template>
  <DXTable
    :items="products.data"
    :fields="fields"
    :edit-fields="editFields"
    edit-url="/api/products/:id"
    delete-url="/api/products/:id"
    @row-deleted="handleDeleted"
    @delete-error="handleDeleteError"
  />
</template>

Features:

  • Delete button appears in modal footer (red/danger variant)
  • Confirmation dialog before deletion
  • Success/error toasts with server messages
  • Table auto-refreshes after successful deletion
  • Displays server validation errors (e.g., “Cannot delete. This category has 42 products.”)

Backend Example:

public function destroy(Product $product)
{
    // Optional: Add validation
    if ($product->orders()->count() > 0) {
        return response()->json([
            'message' => "Cannot delete {$product->name}. This product has orders.",
        ], 422);
    }

    $product->delete();

    return response()->json([
        'success' => true,
        'message' => 'Product deleted successfully',
    ]);
}

Extended Component

This is a custom component that extends beyond simple Bootstrap Vue Next wrappers, providing additional functionality specific to Laravel dashboards.