mmt/generic_table

Generic table for Livewire + Laravel


License
MIT

Documentation

Generic Table: Laravel+Livewire package to automatize HTML tables

Introduction

Generic Table is a package that makes working with tables in Laravel + Livewire easier. It was developed with performance in mind. The main reason behind the project is working with the interface design pattern to segregate responsibilities. From my perspective, table logic should be in its own place.

Requirements

  1. PHP 8.4
  2. Laravel >= 11.x
  3. Livewire >= 3.x
  4. Bootstrap 5.x

How to install

composer require mmt/generic_table

Features

  • Drag and drop rows to simplify reordering. (using Dragula Js)
  • Classic column reordering.
  • Bind the entire column to a laravel route.
  • Create action columns. Customize them using blade views
  • Filter by mutually exclusive or inclusives values.
  • Search by any column
  • Use relationships on columns to link the column with a relationship value.
  • and more...

Basic Usage

First, the Generic Table Definition

With the following code snippet your are creating the table definition that you will later will pass to the blade directive @generic_table().

<?php

namespace App\Tables;

use Illuminate\Database\Eloquent\Model;
use App\Models\Product;
use Mmt\GenericTable\Components\ColumnCollection;

class ProductTable implements IGenericTable
{
    public Model|string $model = Product::class;
    
    public ColumnCollection $columns;
}

Second, the livewire component

Now, create an accessible livewire component...

// ...
// Component render method
public function render()
{
    return view('my.livewire.component-view', ['table' => \App\Tables\ProductTable::class]);
}
// ...

Then, in the view use the @generic_table() directive as follows:

<div>
    @generic_table($table)
</div>

And that's it!

It's clear that the more features you need, the more configurations you will need. By default generic_table will detect that if no column definition was made, then:

  1. All attrbutes in the model are intended to be public.
  2. Attributes with the _id suffix will be omitted from the public view.
  3. All columns are searchables
  4. Nothing can be exported
  5. Drag and drop sorting is disabled
  6. All columns are sorteable

Interfaces

The interface design pattern is the core of the project. By using of several or all of the available interfaces you will be able to control every aspect of the generic table engine. From here I will try to give you a datailed break down of all interfaces that exists at the moment.

IGenericTable

This is the main interface. Every class table you want to define must implement the IGenericTable interface. This interface has two property declarations.

  1. Model|string $model. This property defines the model from which the engine will extract all the data from database, perform filters or searches.
  2. ColumnCollection $columns. This property defines the columns that will be used in the html output
// Example

$this->model = new Product();

$this->columns = ColumnCollection::make(
    new Column('Id'),
    new Column('Description'),
    new Column('Price'),
    new Column('Stock')
);

The first argument of Column constructor is a string in representing the the column label, the second parameter is the database column name but, if there is no second argument, the engine will use the snake case of the column label for the database column name. For example SubDepartment will be mapped to sub_department case using the Str::snake() method. You can also use defined relationship columns. For example:

$this->columns = ColumnCollection::make(
	new  Column('Id'),
	new  Column('Description'),
	new  Column('Price'),
	new  Column('Stock'),
	new  Column('Department', 'subDepartment.department.name'),
	new  Column('SubDepartment', 'subDepartment.name'),
);

Some times you need to dinamically, bind the entire column to a laravel route. In that case you can configure the column as follows:

$this->columns = ColumnCollection::make(
	new  Column('Id'),
	new  Column('Name')->bindToRoute(
		new MappedRoute('product_details', ['product_id' => ':id'], 'See Product Details')
	),
	new  Column('Description'),
	new  Column('Price'),
	new  Column('Stock'),
	new  Column('Department', 'subDepartment.department.name'),
	new  Column('SubDepartment', 'subDepartment.name'),
);

MappedRoute takes its as first argument a defined (existing) route name. The second argument would be the parameters expected by that route and here's a note. See how the route parameters are set... ['product_id' => ':id'] . In order for the engine to be able to determine what value to pass to the route, you need to use a :binder. This tells the system what database column value to use in the row for the route work as expected. The third argument is the link label. If you leave the label empty, the system will use the value of that cell to represent the link label. The last argument is HrefTarget $target an enum to help you set the target attribute of the <a> tag.

If you need to hide a column for some reason, here is how: Use ColumnSettingsFlag. In the example below you will find some other useful flags for column settings.

$this->columns = ColumnCollection::make(
	new  Column('Id')->withSettings(
		ColumnSettingFlags::HIDDEN
	),
	new  Column('Description')->withSettings(
		ColumnSettingFlags::SEARCHABLE,
		ColumnSettingFlags::EXPORTABLE,
		ColumnSettingFlags::SORTEABLE,
	)->bindToRoute(
		new MappedRoute('product_details', ['product_id' => ':id'], 'See Product Details')
	),
	new  Column('Price'),
	new  Column('Stock')->withSettings(
		ColumnSettingFlags::DEFAULT_SORT_ASC
	),
	new  Column('Department', 'subDepartment.department.name'),
	new  Column('SubDepartment', 'subDepartment.name'),
);

IBulkAction

The IBulkAction interface has one property declaration which is BulkActionCollection $bulkActionCollection. Its goal is to define the bulk action methods or group of methods. Group of methods can have nested groups. The html output will be a dropdown menu with nested dropdowns menus if needed in the up-left-corner of the table.

// Basic

$this->bulkActionCollection = BulkActionCollection::make(
    BulkAction::make('100:3 Boost Fund', fn($e) => $this->ProcessMassiveMarketing($e))
);

// Complex

$this->bulkActionCollection = BulkActionCollection::make(
    BulkActionGroup::make('Emails', 
        BulkActionGroup::make('FxLive', 
            BulkActionGroup::make('Marketing',
                BulkAction::make('100:1 Boost Fund', fn($e) => $this->ProcessMassiveMarketing($e)),
                BulkAction::make('100:2 Boost Fund', fn($e) => $this->ProcessMassiveMarketing($e)),
                BulkAction::make('100:3 Boost Fund', fn($e) => $this->ProcessMassiveMarketing($e)),
            ),
            BulkAction::make('100:3 Boost Fund', fn($e) => $this->ProcessMassiveMarketing($e)),
        )
    )
);

// ...

public  function  ProcessMassiveMarketing(BulkActionSettings  $bulkActionSettings)
{
		// ...
}

Normally, you would prefer to handle bulk actions in a Laravel Job but, if you need to make some processing on the main thread BulkActionSettings gives you some useful tools. Once the execution reaches the callback you will have access to the query builder, and of course, for performance reasons, in this point, the system will avoid querying the database, so is a developer's job but but you should fear not because obtaining the selected values is very easy.

  1. Use $bulkActionSettings->getQueryBuilder(); if you wish to obtain only the query builder with the records to be process in the where clause. That way, when you executes the query at any time, it will give you what you select.
  2. If you have a decent (lite) amount of data you can use $bulkActionSettings->getSelectedModels()... but keep in mind that this guy is a dragon of resources. This method will also provide all the selected data.
  3. When you use $bulkActionSettings->getSelectedIds(); all that you will be obtaining is the result of executing $this->getQueryBuilder()->pluck($this->modelPrimaryKey)->toArray();

IDateRangeFilter

This interface has the property declaration DateFilterSettings $dateFilterSettings. Note that first argument of the class DateFilterSettings is the column to be used as the filterable date range column. The purpose is to gives the users a set of common labeled dates ranges from which the user can choose one of them. As you can imagine, each common date range has its own predefined DateTime range. See the example below.
Note: Labels descriptions cannot be modified by any interface at this time

// Example

$this->dateFilterSettings = new DateFilterSettings('created_at', 
    CommonDateFilter::LAST_2_MONTHS,
    CommonDateFilter::LAST_3_MONTHS
);

Additionally, you can specify the CommonDateFilter::CUSTOM_RANGE case to instruct the engine to allow the user to enter a custom date range from the HTML date input. Or you can use CommonDateFilter::ALL_RANGES case to specify that the engine must render all options included CommonDateFilter::CUSTOM_RANGE case.

ISingleSelectionFilter

This interface has the property declaration SelectionFilterSettings $singleSelectionFilterSettings. The HTML code associated to this filter will force the user to choose only one possible filter value at a time. So, if you want to filter products by their status the way to do is:

// Example

$this->singleSelectionFilterSettings = new SelectionFilterSettings('status')
    ->add('Out of stock', 'out_of_stock')
    ->add('Discontinued', 'discontinued')
    ->add('Available', 'available');

The class SelectionFilterSettings receives as its first argument the column to filter, then, using the builder pattern the possible values to filter can concatenated using the add method. The first argument of the add method is the filter label while the second is the possible value for that particular case.

IMultiSelectionFilter

This interfaces behaves the same as ISingleSelectionFilter but multiple values can be selected at once. The selected values are not inclusive, so they are interpreted as AND in the query string. To use the interface you need to implement SelectionFilterSettings $multiSelectionFilterSettings property. See the following example:

// Example

$this->$multiSelectionFilterSettings = new SelectionFilterSettings('status')
    ->add('Out of stock', 'out_of_stock')
    ->add('Discontinued', 'discontinued')
    ->add('Available', 'available');

IRowsPerPage

If you need to change the default behavior of the rows displayed per page or explicitly set the initial value of the rows per page you should implement the IRowsPerPage interface. The property $rowsPerPage will set the initial set of rows displayed and $rowsPerPageOptions property will overwrite the Rows per page options in the html output.

// Example

public int $rowsPerPage = 10;
public array $rowsPerPageOptions = [10,20,40,60,80];

IPaginationRack

To overwrite the default behavior of the pagination section of the table, you must implement the IPaginationRack interface as follows:

// Example

public int $paginationRack = 0;

public function __construct()
{
    // Display the pagination on top and bottom
    PaginationRack::addFlags($this->paginationRack, PaginationRack::TOP, PaginationRack::BOTTOM);
}

IActionColumn

Usually, you need to use a column as a container of buttons that control some of the actions in the row. This is a common case of an action column. This interfaces allows you to use one column with this purpose.

// Example

public int $actionColumnIndex = -1;

public function actionView(Model $item): \Illuminate\View\View
{
    return view("tables_action_views.edit_delete_details", ['productId' => $item->id]);
}

The public int $actionColumnIndex property defines the position at which the column should be rendered. If the value is set to -1 the engine will always set the action column as the last column rendered. By implementing the public function actionView(Model $item): \Illuminate\View\View you are telling the engine which view to use to render each set of controls for each cell in the column. The argument to the actionView method is an instance of the model and method must return a \Illuminate\View\View object.

IDragDropReordering - (powered by Dragula JS)

The generic table engine helps you to sort rows manually. When you implement this interface you will be able to grab any rendered row and drop it wherever you need (inside the tbody). For this case, the engine needs a column sorted from 1 to N where N is the last row count. Let's say you have 5 records in the Product's table. The table needs to have a order column where the order value is from 1 to 5. The engine will not set the order for you and you need to keep this in mind when you inert new records. The interface declares only public string $orderingColumn property. If you implement this interface and leave the property unset, the engine will use the order column by default and if you don't have an existing column called order horrors will be seen. By default this column will force the system to use this column as a default sorted column. With the following code you will enable manual ordering using drag and drop

// Example

class TableWithDragDropOrdering implements IGenericTable, IDragDropReordering
{
    public Model|string $model = Product::class;
    
    public ColumnCollection $columns;

    public string $orderingColumn = 'order';
    
    // ...
}

OnReorder Attribute

Now, if you need to implement your own reordering method you could use the OnReorder attribute on a method. For example:

#[OnReorder]
public function onReorderCallback(int $newPosition, $oldPosition, Model $model)
{
    /**
     * If method exists, it should return TRUE to indicate to the subsystem that
     * this method will handle the reording.
     * If you do not explicitly return boolean, the subsystem will use FALSE as a default return value
     */
    
    return false;
}

If the method exists, it should return TRUE to indicate to the subsystem that this method will handle the reording. If you do not explicitly return boolean, the subsystem will use FALSE as a default return value and will handle the reording ignoring your custom handler. When you move a record from postion X to position Y the X position is called the old position and Y position is called the new position, and as you can understand, the $model is the moved element.

IEvent

Use it when you need to capture some of the internal events of the engine and perform custom calculations or modify some thing at your convenience. The interface declares the method public function dispatchCallback(EventArgs $arguments): void.

Times where dispatchCallback is fired:

  1. When database query is about to be generated. The dispatchCallback receives a child class of EvntArgs called DatabaseEvent which is fired only in the initial state of the query builder.
  2. When dispatcher is invoked from within generic table. Imagine you need to rise a callback event through the generic table for some reason, mainly to maintain organized logic, well, this is the way. You manually make a wrie:click = "dispatcher( {object} )" from an action column control for example:
// Example of action column
// edit_delete_details.blade.php

<div>
    <a href="" wire:click.prevent = "$dispatch('edit', {productId:{{ $productId }}})">Edit</a>
    
    <!-- Here you can rise the default EventArgs in your table definition side -->
    <a href="" wire:click.prevent = "dispatcher({productId: {{ $productId }}})">Details</a>
    
    <a href="">Delete</a>
</div>

That way, you can structure your tables logic around the table class definition (as it should be) and not the livewire component. Of course, some times you will need to fire an event directly to your livewire component and a $dispatch from livewire is fine.

Another use case for this interface is to be used as a complement of param injection. The param injection is nothing but generic tablethat listens to a particular event called injectParams where you can pass arbitrary data that will be exposed in any dispatchCallback

Let's say you have defined the options for a navigation tab in your Livewire component and each tab should filter the generic table with some arbitrary or defined value. By using this interface in combination with param injection you can set the initial state of the query and have the selected tab fire the injectParams event on every update to the Livewire model. Let's look at a quick example:

Table definition:

namespace App\Tables;

use App\Models\Product;
use Illuminate\Database\Eloquent\Model;
use Mmt\GenericTable\Interfaces\IEvent;
use Mmt\GenericTable\Components\ColumnCollection;
use Mmt\GenericTable\Components\Column;

class TableWithFilters implements IGenericTable, IEvent
{
    public Model|string $model = Product::class;

    public ColumnCollection $columns;
    
    public function __construct()
    {
        $this->columns = ColumnCollection::make(
            new Column('Id'),
            new Column('Description'),
            new Column('Price'),
            new Column('Stock')
        );
    }
    
    
    public function dispatchCallback(EventArgs $arguments): void
    {
        if($arguments instanceof DatabaseEvent)
        {
            if($arguments->injectedArguments['tabView'] == 1)
                $arguments->builder->where('status', 'discontinued');
            else
                $arguments->builder->where('status', 'available');
        }
    }
}

As you can see from previous example the dispatchCallback implementation is prepare to receive DatabaseEvent. When that happens, the livewire component´s selected tab view can be used to controo the query behavior. But, there is no initial state for $arguments->injectedArguments['tabView'] yet...

Livewire Component:

<?php

namespace App\Livewire\Examples;

use App\Tables\TableWithFilters;
use Livewire\Component;

class TableWithFiltersComponent extends Component
{
    public int $tab;

    public function mount()
    {
        $this->tab = 1;
    }

    public function render()
    {
        return view('livewire.examples.table-with-filters-component', [
            'table' => TableWithFilters::class
        ])
        ->extends('components.layouts.app')
        ->section('content');
    }

    public function updatedTab($val)
    {
        $this->dispatch('injectParams', ['tabView' => $val]);
    }
}

// ... and view ...
<div class="container-fluid">
    <div class="row">
        <div class="col-auto">
            <button wire:click = "$set('tab', 1)" class="w-100 btn btn-{{ $tab == 1 ? 'primary' : 'light' }}">Discontinued</button>
        </div>
        <div class="col-auto">
            <button wire:click = "$set('tab', 2)" class="w-100 btn btn-{{ $tab == 2 ? 'primary' : 'light' }}">Available</button>
        </div>
    </div>

    @generic_table($table, ['tabView' => $tab])
    
</div>

The @generic_table directive accepts two arguments. The first one is the FQCN of the table definition while the second is an array of possible arguments. All the arguments passed to the generic table component initialization will be exposed as a part of the event args in the dispatchCallback method implementation. In the above example you can see how by initializing the generic table with predefined values and thanks to generic table engine that exposes those values in the dispatchCallback we have the hability to set an initial state in the query builder and with the help of injectParams event we can update the tabView array key value to "keep track" of the the nav-tab filter

IExportable

If you need export all queried registers you can use IExportable interface which declares the public function onExport(ExportEventArgs $args) : BinaryFileResponse|Response method. When you implement this interface a bootstrap warning button with a SVG cloud appears in the top-right corner of the table. The system does not handle the export for you, but it makes it much easier. Once you have the public function onExport(ExportEventArgs $args) : BinaryFileResponse|Response implemented you only needs to return $args->export();

// Example

public function onExport(ExportEventArgs $args) : BinaryFileResponse|Response
{
    $args->settings->fileName = 'my_products';
    return $args->export();
}

The ExportEventArgs have some interesting points worth explaining. Once the execution reaches the onExport method you have some options to handle. By default, the engine will set a timestamp on the exported file name like date('YmdHis'). So, my_products may end up in my_products_20250310220005, but if you don't need that you can set appendTimeMarkToFilename to false like:

public function onExport(ExportEventArgs $args) : BinaryFileResponse|Response
{
    $args->settings->fileName = 'my_products';
    $args->settings->appendTimeMarkToFilename = false;
    return $args->export();
}

Also, if you don't use a filename, the system will assign the model name in the Snake Case version. So, for the MyProducts model, the filename will be my_products_<timestamp>.[extension].

Deadlocks

Once the execution reaches onExport no query has been performed to improve performance. If the dataset obtained from the query execution is relatively short, you may have no problem calling the $args->export(); method directly, but when 300K records come into play, things get heavy for the server and PHP kills the fun... rightly so. If you take a look at the ExportEventArgs $args the generic table offers you the Eloquent\Builder to manage the results as you need, but with all the filters applied if there are any. This way (custom handling) you are able to create a Laravel Job at this moment, save the file and send it by email or using another channel you have in mind... but! deadlocks can strike again and that is why I will always recommend doing exports using chunks or with limited amount of data.

The Maatwebsite\Excel package

If you have installed the Maatwebsite\Excel package the generic table will use a predefinded template to export the data using this library, otherwise the exported file will be a csv file.

ILoadingIndicator

This interface is useful only when you need to change the behavior of the loading indicator. When you perform some action inside the generic table component there is a Livewire hook waiting for the request. The loading indicator appears when the request is initiated and is hidden when the requests have been answered. To give you full control over the view you need to specify a blade view to serve as the loading indicator and the engine will toggle the CSS property display: block|none using the id attribute of the html tag which should be called generic_table_loader. See the example:

// Example in table definition

public function tableLoadingIndicatorView(): \Illuminate\View\View
{
    return view('custom-loader');
}
// and view ...
<div id = "generic_table_loader" class="position-absolute top-0 start-0 w-100 h-100" style="display:none; background: rgba(0, 0, 0, 0.5)">
    <div class="d-flex h-100">
        <div class="m-auto d-flex flex-column">
            <div class="spinner-border mx-auto text-danger" role="status"></div>
            <div class="text-white">Loading</div>
        </div>
    </div>
</div>

Attributes

The package have some useful attributes that worths mentioning:

  1. CellFormatter
  2. OnReorder

CellFormatter

When a cell is about to be rendered, the engine will call the method targeted by the attribute, to aply a format to the cell. The attribute expects the database column name. The only argument passed to the callback is an instance of the model in representing the entire row. The output will always be treated as HTML string, so be aware of the security concerns.

#[CellFormatter('id')]
public function idFormatter(Model $modelItem)
{
    return '<b class = "text-primary">#</b> '.$modelItem->id;
}

OnReorder

This attribute will help you to make a custom handler for the IDragDropReordering interface implementation. See How to Implement IDragDropReordering interface

Traits

  1. WithGenericTable

WithGenericTable

It was designed to be a sort of helper for the Generic Table Definition.

WithGenericTable::refreshGenericTable()

Dispatch an event called refreshGenericTable to force the the component reload

WithGenericTable::injectParams(array $params)

Dispatch an event called injectParams to "inject" arbitrary data into the generic table component. See How to implement IEvent