FFTable Flagship

FFTable is the best feature of Fuwafuwa Framework. This feature allows you to quickly build nice, responsive table view of selected fields in a table. It provides multiple fields sorting, searching, formatting, pagination, add/modify record. Let's review Customer view in Chinook sample aplication.

<?php
// MODEL: app/controllers/user/model/chinook/customer.php
namespace Model\Chinook;

class Customer extends \Fuwafuwa\BaseModel {

    function __construct(\Data\Chinook $db) {
        parent::__construct($db, 'Customer', ['ai_field' => 'CustomerId',]);
        $this->validation = [
            'rules' => [
                'FirstName' => 'required',
                'LastName' => 'required',
                'Address' => 'required',
                'Email' => 'required|email',
            ]
        ];                    
    }
}
    
<?php
// CONTROLLER: app/controllers/user/ajax/chinook/employee.php

namespace Ajax\Chinook;

class Employee extends \Fuwafuwa\Controller\FFTable {

    function list($f3) {
        $this->recordList('\Model\Chinook\Employee');
    }

    function edit($f3) {
        $this->ajaxEdit('\Model\Chinook\Employee');
    }

    function elist($f3) {
        $sql = "SELECT e.*, h.FirstName as HeadFirstName, h.LastName as HeadLastName
        FROM Employee e
        LEFT JOIN Employee h ON e.ReportsTo = h.EmployeeId ";
        $csql = "SELECT COUNT(1) FROM Employee e";
        $this->recordElist($sql, $csql);
    }
}

For simple table without lookup from other table, the controller code become very simple, only edit and list method is required. The complexity of pagination, searching, sorting are handled by FFTable.

<f3:inject id="content">
    <h2 class="text-lg font-bold">Customer</h2>
    <div x-data="data">
    <include href="blocks/table.html" />
    <include href="blocks/modal-form.html" />
    </div>
</f3:inject>
<f3:inject id="script">
    <script src="{{@BASE}}/js-src/fftable.js"></script>
    <script type="text/javascript">
    let settings = {
        fields: [
        {
            title: 'Customer Id', name: 'CustomerId', visible: false, readonly: true
        },
        {
            title: 'Support Rep', name: 'SupportRepId', visible: false, type: 'select', triggerChange(d) {
            fetchData('{{@BASE}}/ajax/chinook/employee/select-option',).then(data => {
                this.options = data;
            });
            },
        },
        {
            title: 'Last Name', name: 'LastName', formatter(v, c, d) {
            return `<svg viewBox="0 0 24 24" class="inline mr-2 stroke-1 size-4 stroke-gray-500 dark:stroke-gray-400 fill-transparent">
        <use href="#people" />
    </svg> <a class="underline" href="{{@BASE}}/chinook/customer/${d.CustomerId}">${v}</a>`;
            }, raw: true, class: 'whitespace-nowrap', searchable: true
        },
        { title: 'First Name', name: 'FirstName', searchable: true },
        { title: 'Company', name: 'Company', class: 'whitespace-nowrap' },
        { title: 'Address', name: 'Address', visible: false, type: 'textarea', fclass: 'md:col-span-2' },
        { title: 'City', name: 'City', class: 'whitespace-nowrap' },
        { title: 'State', name: 'State', visible: false, },
        { title: 'Country', name: 'Country', visible: false, },
        { title: 'PostalCode', name: 'PostalCode', visible: false, },
        { title: 'Phone', name: 'Phone', visible: false, type: 'tel' },
        { title: 'Fax', name: 'Fax', visible: false, type: 'tel' },
        { title: 'Email', name: 'Email', visible: false, type: 'email' },
        ],

        // table options
        table: {
            url: '{{@BASE}}/ajax/chinook/customer/list',
            editable: true,
            selection: 'single',
            pageSize: 20,
            sorting: 'multiple'
            size: 'large',
            // display: 'compact',
        },

        // form options
        form: {
            url: '{{@BASE}}/ajax/chinook/customer/edit',
            object: 'Customer',
            size: 'normal', // small, normal, large, huge
            columns: 2,
            size: 'large',
            pk: ['CustomerId'], // primary key
        }
    }
    let data = FFTable(settings);
    </script>
</f3:inject>
<include href="blocks/popup.html" />

in the content section, we simply include table and modal-form blocks. The detail is in the script section, where the table configuration is defined. Table configuration consists of fields, table and form property. Available properties for each are below:

Fields

Fields define columns that we want to show in table and form.

title
Field title in table column and form
name
Field name in table
visible
If set false, will not show in table
virtual
If set true, will not show in form
readonly
If set true, uneditable in form
hidden
Hidden in form and table, but still submit value
sortable
Boolean, show sorting icons at column header
inline
Show title and input side by side
type
html input type in form (text, date, month, email, etc). Additional types: uploader, checkboxes. Uploader type needs to initiate uploader method. Checkboxes type needs to define encoder, decoder, and default to [], as array will be used as basic operation.
default
Default value on create new object
attr
array of attribute to apply to input element (including Alpine JS attributes). Example:
{ 'x-on:keyup.debounce': 'check_input()', 'maxlength': 100 }
validator
function(value, column, data) to validate current value. Return true or error message.
formatter
function(value, column, data) to format current value. Example:
function(v) { return '$' + v }
will add dollar prefix to the value.
encoder
function(value, column, data) to format data before saving into database:
function(v) { return JSON.stringify(v) }
will convert data into JSON string.
decoder
function(value, column, data) to parse data from database
function(v) { return JSON.parse(v) }
will parse JSON string into array.
raw
display as html code or let browser display like ordinary html code
initOptions
function(data) to be executed in form show event. Usually for initialization of select options.
triggerChange
function(data) to be executed when there is a change on watched properties. Example:
{ title: 'Support Rep', name: 'SupportRepId', visible: false, type: 'select', triggerChange(d) {
        fetchData('{{@BASE}}/ajax/chinook/employee/select-option',).then(data => {
                this.options = data;
            });
        },
    },
parameter d contains current data. In code above, after executing fetchData, it will set options property of this (current field).
watch
array of column change to watch
searchable
field is searchable from search bar
class
css class for table column
fclass
css class for form field
queryPrefix
If you join two or more tables as controller, this prefix + field name will be put in search and order by clause. For example in the controller you query SELECT a.timestamp, u.name FROM activity a JOIN user u on a.userid = u.userid , then you specify a or u for the queryPrefix, depends on which table you intend to query.
lookupUrl
Lookup url for Tom Select if using remote source
lookupLabel
Field name of display value. For example we use IdUser and Username pair to represent user in join query, then lookupLabel is assigned with Username.
staticLookup
if set to true, lookup is only performed once, and query is ignored. Usefull if there are considerable amount of select options, and we don't want to put in the code. Use dynamic lookup if there are so many options and it'd better to search with query.
filter
{ op: 'LIKE',  }
Add filter above table. Op is query operator. If op is BETWEEN, second input filter will be shown.
Valid op: LIKE, CONTAINS, START(S), END(S), IS (NOT) NULL, IN, < <= > >= = <> !=
For IN filter, value will be split on pipe character. Example: { op: 'IN', value: 'Black|Green|Red' } will result query field IN ('Black', 'Green', 'Red') .
For < <= > >= filter, if value2 is specified, opposite sign complement will be added. Example: { op: '>', value: 10, value2: 20 } will result query (field > 10 AND field < 20)

Table

table section define table display parameter.

url
url to table query controller
addable
Show add button in toolbar
editable
Show edit button in toolbar
deletable
Show delete button in toolbar
printable
Show print button in toolbar
exportable
Show export button in toolbar
searchable
If searchable, show search bar
selection
none, single, multiple
sorting
none, single, multiple
pageSize
Number of record to display per page
display
normal, compact
displayClass
additional class for table
rowClass
additional class for row. A string or function rowClass(idx, row)
cellClass
additional class for cell. A string or function cellClass(ridx, cidx, row, col)
size
small, normal, large. On mobile, all sizes are the same.
deferLoading
Don't fetch data after initialization.
customHeader
Define complex table header. Example:
[
    [
        { title: 'City', attr: { rowspan: 2 }, class: 'border-r border-t' },
        { title: 'Clothes', attr: { colspan: 3 }, class: 'border-r border-t' },
        { title: 'Accessories', attr: { colspan: 2 }, class: 'border-t' }
    ],
    [
        { title: 'Trousers', class: 'border-r' },
        { title: 'Skirts', class: 'border-r' },
        { title: 'Dresses', class: 'border-r' },
        { title: 'Bracelets', class: 'border-r' },
        { title: 'Rings' },
    ]
],
                    
footerData
Create sticky footer at the bottom. Example:
[
    [
        { value: 'Total' },
        { name: 'sum-trousers', value: 10, class: 'text-right' },
        { name: 'sum-skirts', value: 10, class(v) { return { 'text-right': true, 'italic': v < 1000 } }, formatter(v) { return v + 5 } },
        { name: 'sum-dresses', value: 10, class: 'text-right' },
        { name: 'sum-bracelets', value: 10, class: 'text-right' },
        { name: 'sum-rings', value: 10, class: 'text-right' },
    ],
    [
        { value: '%' },
        { name: 'total-trousers', value: 10, class: 'text-right' },
        { name: 'total-skirts', value: 10, class: 'text-right', formatter(v) { return v + 5 } },
        { name: 'total-dresses', value: 10, class: 'text-right' },
        { name: 'total-bracelets', value: 10, class: 'text-right' },
        { name: 'total-rings', value: 10, class: 'text-right' },
    ],
],                        
                    
functions
Array of functions that will override default button function. Example:
functions: {
  add() { location.href = '{{@BASE}}/page/edit'; return true },
  edit(data) { location.href = '{{@BASE}}/page/edit?id=' + data.id; return true; },
}
Each function needs to return true.
eventHandler
function(event, data)
filter
Filter configuration
filter: {
    cols: 1,
    width: 'xl:w-2/3',
}
Toolbar
Additional toolbar button
toolbar: [
  {
    title: 'Filter',
    show(selected, data) { return selected },
    action(data) { submit(data) },
    icon: '<svg></svg>'
  }
]
tableInit()
tableInit event, run once to set up table

Form

form section define form display parameter.

url
url to form controller
object
Title to display in form
size
small, normal, large, huge. On mobile, all sizes are the same.
columns
Number of columns in form. On mobile, only 1 column.
pk
array of primary key
fullHeight
if true, the content container will be full height and having vertical scrollbar. If not, the content container will fit the content.
formInit()
formInit event, run once to set up form
preSubmit
function(data, oper) before submit, modify and return the data back
onSubmitted
function(data, oper) after submission, modify data before display
onShow
function(data) modify data before showing form and return the data back
onSubmitted
function(data) modify data after received response from server after submission, and return it

Progressive Loading

FFTable supports progressive loading for handling large datasets efficiently:

let settings = {
    table: {
        progressiveLoad: true,  // Enable progressive loading
        size: 50,              // Records per page
        loadMoreText: 'Load More',  // Custom button text
        loadMoreClass: 'btn-primary' // Custom button class
    }
}

Progressive Loading Options

progressiveLoad
Enable progressive loading instead of pagination
size
Number of records to load per request
loadMoreText
Text to display on the load more button
loadMoreClass
CSS class for the load more button

Loading States

FFTable provides several loading states that you can use to enhance the user experience:

loading
True when initial data is being loaded
loadingMore
True when additional data is being loaded
meta.progressiveLoading
True when progressive loading is active
meta.showScrollTop
True when scroll-to-top button should be shown

Scroll Detection

When progressive loading is enabled, FFTable automatically detects when the user scrolls to the bottom of the table and loads more records. You can customize this behavior:

let settings = {
    table: {
        progressiveLoad: true,
        containerId: 'my-table-container',  // Container for scroll detection
        scrollOffset: 100  // Distance from bottom to trigger load
    }
}

Field Validation

FFTable provides comprehensive client-side validation for form fields. You can specify validation rules in the field configuration:

let settings = {
    fields: [{
        name: 'email',
        type: 'text',
        validation: {
            required: true,
            email: true,
            minLength: 5,
            maxLength: 100,
            pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
            custom: (value) => value.endsWith('@company.com') || 'Must use company email'
        }
    }]
}

Available Validation Rules

required
Field must not be empty
email
Field must be a valid email address
minLength
Minimum length of the field value
maxLength
Maximum length of the field value
min
Minimum numeric value
max
Maximum numeric value
pattern
Regular expression pattern to match
matches
Field must match another field's value
custom
Custom validation function that returns true or error message

Validation Methods

FFTable provides several methods to handle validation:

validation.getFieldError(fieldName)
Get the first error message for a field
validation.hasErrors()
Check if any fields have validation errors
validation.validateField(field, value)
Validate a single field
validation.validateForm()
Validate all fields in the form

Row Selection

FFTable supports both single and multiple row selection with keyboard navigation:

table.selection
Set to 'single' or 'multiple' to enable row selection
table.selectionCheckbox
Show checkboxes for row selection
isSelected(index)
Check if a row is selected
toggleSelect()
Toggle selection of all rows

Keyboard Navigation

The following keyboard shortcuts are supported:

Arrow keys
Move cursor up/down
Space
Select/deselect current row
Shift + Click
Select range of rows
Ctrl/Cmd + Click
Toggle individual row selection
Ctrl/Cmd + A
Select all rows

Backend

Ajax backend for table is very simple. You only need to implement list and edit. Example:

<?php

namespace Ajax;

class User extends \Fuwafuwa\Controller\FFTable {

    function list(\Base $f3): void {
        $this->recordList('\Model\User');
    }

    function edit(\Base $f3): void {
        $this->ajaxEdit('\Model\User');
    }

    function elist($f3) {
        $sql = "SELECT al.*, ar.Name as ArtistName FROM Album al
            JOIN Artist ar ON al.ArtistId = ar.ArtistId";
        $csql = "SELECT COUNT(1) FROM Album al";
        $this->recordElist($sql, $csql);
    }
}
For list that involves complex query join, we can use elist method. In this method, we pass two query, one for listing, and the other for counting.

Generators

Generators also available for FFTable to get basic form of the MVC files and modify later. Run this command in CLI:

php index.php data/generator/model --table=Customer > app/controllers/user/model/customer.php
php index.php data/generator/fftable --table=Customer > app/views/user/customers.html
php index.php data/generator/ajax-fftable --table=Customer > app/controllers/user/ajax/table/customer.php

See demo here

Example Usage & Best Practices

Here's a complete example of FFTable configuration implementing recommended practices:

let settings = {
    // Field definitions with validation
    fields: [{
        name: 'id',
        type: 'hidden'
    }, {
        name: 'email',
        type: 'text',
        label: 'Email Address',
        validation: {
            required: true,
            email: true,
            custom: (value) => {
                // Custom validation with clear error message
                if (!value.includes('@company.com')) {
                    return 'Please use your company email address';
                }
                return true;
            }
        }
    }, {
        name: 'status',
        type: 'select',
        label: 'Status',
        options: ['Active', 'Inactive'],
        validation: { required: true }
    }, {
        name: 'role',
        type: 'tom-select',
        label: 'Role',
        lookupUrl: 'roles/lookup',  // Server-side lookup for better performance
        validation: { required: true }
    }],

    // Table configuration with performance optimizations
    table: {
        selection: 'multiple',
        selectionCheckbox: true,
        progressiveLoad: true,  // Better performance for large datasets
        size: 50,
        columns: [
            { field: 'email', sortable: true },
            { field: 'status', sortable: true },
            { field: 'role', sortable: true }
        ],
        // Clear loading states for better UX
        loadMoreText: 'Loading more records...',
        loadMoreClass: 'btn-primary animate-pulse'
    },

    // Form configuration with validation handling
    form: {
        title: 'User Details',
        submitText: 'Save Changes',
        cancelText: 'Cancel',
        // Validation before submission
        onBeforeSubmit: function(data) {
            if (!this.validation.validateForm()) {
                return false;
            }
            // Additional data processing if needed
            return data;
        },
        // Handle response after submission
        onSubmitted: function(response) {
            if (response.success) {
                // Show success message
                this.showMessage('Changes saved successfully');
                this.refreshData();
            } else {
                // Handle server-side validation errors
                this.handleErrors(response.errors);
            }
            return response;
        }
    }
}

Implementation

In your view file:

<!-- Include required components -->
<include href="blocks/table.html" />
<include href="blocks/form.html" />

<script>
    // Initialize with settings
    let settings = { /* Configuration from above */ };
    let table = new FFTable(settings);

    // Optional: Add custom keyboard shortcuts
    document.addEventListener('keydown', (e) => {
        if (e.ctrlKey && e.key === 'f') {
            e.preventDefault();
            table.focusSearch();
        }
    });
</script>

In your controller:

<?php
namespace Ajax;

class Users extends \Fuwafuwa\Controller\FFTable {
    // List records with server-side processing
    function list($f3) {
        // Apply filters and sorting server-side
        $filter = $this->getFilter();
        $sort = $this->getSort();
        
        $this->recordList('\Model\User', [
            'filter' => $filter,
            'sort' => $sort
        ]);
    }

    // Handle record editing with validation
    function edit($f3) {
        try {
            // Server-side validation
            if (!$this->validate()) {
                return $this->error('Validation failed');
            }
            
            $this->recordEdit('\Model\User');
        } catch (\Exception $e) {
            return $this->error($e->getMessage());
        }
    }
}

Best Practices Summary

Practice
Implementation
Performance Optimization
  • Enable progressive loading
  • Use server-side processing
  • Implement efficient validation
Validation Strategy
  • Client + server validation
  • Clear error messages
  • Proper error handling
User Experience
  • Loading state indicators
  • Keyboard navigation
  • Clear feedback messages
Code Organization
  • Structured configuration
  • Separated concerns
  • Consistent error handling