Milk Admin

Row Actions Documentation

The setActions() method configures action buttons that appear for each row in your table. Actions can be simple navigation links or complex server-side operations with callbacks.

Quick Reference: Action Methods

Method Parameters Description Behavior
addAction() string $key, array $config Adds a single row action without removing existing actions Preserves existing
setActions() array $actions Sets all row actions at once Replaces all
setDefaultActions() array $customActions = [] Adds default Edit and Delete actions, preserving any previously added actions Preserves existing + Adds defaults
addBulkAction() array $config Adds a single bulk action for multiple row selection Preserves existing
setBulkActions() array $actions Sets all bulk actions at once Replaces all
Method Chaining Order

Recommended pattern: Use addAction() to add custom actions first, then call setDefaultActions() to append Edit and Delete buttons.

$table->addAction('view', [...])
      ->addAction('duplicate', [...])
      ->setDefaultActions(); // Adds Edit and Delete after custom actions

Action Types

Link Actions

Link actions create clickable buttons that navigate to another page. Perfect for Edit, View, or Detail operations.

$table = \Builders\TableBuilder::create($model, 'table_id')
    ->setActions([
        'edit' => [
            'label' => 'Edit',
            'link' => '?page=posts&action=edit&id=%id%'
        ],
        'view' => [
            'label' => 'View Details',
            'link' => '?page=posts&action=view&id=%id%',
            'target' => '_blank',
        ]
    ]);

Adding Actions Incrementally

Use addAction() to add individual actions without replacing existing ones. This is useful when you want to add custom actions and then append default actions:

// Add custom actions first
$table = \Builders\TableBuilder::create($model, 'table_id')
    ->addAction('comment', [
        'label' => 'Comments',
        'link' => '?page=posts&action=comments&id=%id%'
    ])
    ->addAction('preview', [
        'label' => 'Preview',
        'link' => '/post/%slug%',
        'target' => '_blank'
    ])
    ->setDefaultActions(); // Adds Edit and Delete after custom actions

// Result: Actions appear in order: Comments, Preview, Edit, Delete

Best Practice: Use addAction() when you want to preserve action order and combine custom actions with defaults. Use setActions() when you want complete control over all actions.

Callback Actions

Callback actions execute server-side functions when clicked. Ideal for operations like Delete, Publish, or Archive.

$table = \Builders\TableBuilder::create($model, 'table_id')
    ->setActions([
        'delete' => [
            'label' => 'Delete',
            'action' => [$this, 'actionDelete'],
            'confirm' => 'Are you sure you want to delete this item?',
            'class' => 'link-action-danger'
        ],
        'publish' => [
            'label' => 'Publish Now',
            'action' => [$this, 'actionPublish'],
        ]
    ])
    ->getResponse();

// Action handler method
public function actionDelete($record, $request) {
    if ($record->delete($record->id)) {
        return ['success' => true, 'message' => 'Item deleted successfully'];
    }
    return ['success' => false, 'message' => 'Delete failed'];
}

Configuration Parameters

Parameter Type Required Description
label string Yes Button text displayed to user
link string No* URL pattern with placeholders (e.g., %id%, %field_name%)
action callable No* Callback function: [$this, 'methodName']
class string No CSS classes for styling (Bootstrap classes supported) link-action-danger for danger link
target string No Link target: _blank
confirm string No Confirmation message shown before action execution
icon string No Bootstrap icon class (e.g., bi-x-circle, bi-pencil)
showIfFilter array No Table-level visibility based on active filters (see below)
condition callable No Row-level visibility condition that receives $row object (see below)

* Either link or action must be specified

URL Placeholders

Link actions support dynamic placeholders that are replaced with actual row data:

Placeholder Description Example
%id% Primary key value ?page=posts&id=%id%?page=posts&id=123
%field_name% Any column value /view/%slug%/view/my-post-slug
%primary% Explicit primary key reference ?action=edit&pk=%primary%
// Examples with multiple placeholders
->setActions([
    'edit' => [
        'label' => 'Edit',
        'link' => '?page=%category%&action=edit&id=%id%'
    ],
    'preview' => [
        'label' => 'Preview',
        'link' => '/blog/%created_at%/%slug%',
        'target' => '_blank'
    ]
])

Action Callbacks

Callback Signature

Action callback functions receive two parameters:

public function actionMethodName($record, $request) {
    // $record - Model instance of the selected row
    // $request - Full $_REQUEST array with all parameters

    // Perform your operation

    // Return array with status and optional data
    return ['success' => true, 'message' => 'Operation completed'];
}

Return Values

Callback functions must return an associative array. Common keys:

Key Type Description
success boolean Whether the operation succeeded
message string User feedback message
reload boolean Whether to reload the table (default: true)
redirect string URL to redirect after completion
Custom keys mixed Any additional data to pass to the view

Example Callbacks

// Simple delete action
public function actionDelete($record, $request) {
    if ($record->delete($record->id)) {
        return [
            'success' => true,
            'message' => 'Item deleted successfully'
        ];
    }
    return [
        'success' => false,
        'message' => 'Delete failed: ' . $record->getLastError()
    ];
}

// Status change action
public function actionPublish($record, $request) {
    $record->status = 'published';
    $record->published_at = date('Y-m-d H:i:s');

    if ($record->save()) {
        return [
            'success' => true,
            'message' => 'Post published successfully',
            'reload' => true
        ];
    }

    return [
        'success' => false,
        'message' => 'Publish failed',
        'errors' => $record->getErrors()
    ];
}

// Action with redirect
public function actionClone($record, $request) {
    $record->id = 0;

    if ($record->save()) {
        return [
            'success' => true,
            'message' => 'Item cloned successfully',
            'redirect' => '?page=items&action=edit&id=' . $record->id
        ];
    }

    return ['success' => false, 'message' => 'Clone failed'];
}

Conditional Visibility with showIfFilter

The showIfFilter parameter creates context-aware interfaces by showing or hiding actions based on active table filters.

Basic Concept

Actions configured with showIfFilter are only visible when specific filter conditions are met. This keeps the UI clean and prevents users from performing inappropriate actions.

Syntax

'showIfFilter' => ['filter_name' => 'expected_value']

Behavior:

  • Action is visible only when the specified filter is active
  • Filter value must exactly match the expected value
  • Currently supports single filter condition per action
  • Works seamlessly with table filter changes (real-time update)

Practical Example: Status-Based Actions

// Setup filter in SearchBuilder
$searchBuilder = \Builders\SearchBuilder::create('idTablePosts')
    ->actionList('status', 'Filter by Status:', [
        'active' => 'Active Posts',
        'deleted' => 'Deleted Posts'
    ], 'active');

// Configure table with conditional actions
$tableBuilder = \Builders\TableBuilder::create($postModel, 'idTablePosts')
    ->filter('status', function($query, $value) {
        if ($value === 'deleted') {
            $query->where('deleted_at IS NOT NULL');
        } else {
            $query->where('deleted_at IS NULL');
        }
    }, 'active')

    ->setActions([
        // Always visible
        'edit' => [
            'label' => 'Edit',
            'link' => '?page=posts&action=edit&id=%id%'
        ],

        // Only visible when viewing active posts
        'delete' => [
            'label' => 'Move to Trash',
            'action' => [$this, 'actionSoftDelete'],
            'confirm' => 'Move this post to trash?',
            'class' => 'link-action-warning',
            'showIfFilter' => ['status' => 'active']
        ],

        // Only visible when viewing deleted posts
        'restore' => [
            'label' => 'Restore',
            'action' => [$this, 'actionRestore'],
            'class' => 'link-action-success',
            'showIfFilter' => ['status' => 'deleted']
        ]
    ]);
How it works

When viewing Active Posts: Users see "Edit" and "Move to Trash"

When viewing Deleted Posts: Users see "Edit" and "Restore"

Filter Change: Actions update instantly when filter is changed

Implementation of Action Handlers

public function actionSoftDelete($record, $request) {
    $record->deleted_at = date('Y-m-d H:i:s');

    if ($record->save()) {
        return ['success' => true, 'message' => 'Post moved to trash'];
    }
    return ['success' => false, 'message' => 'Failed to move to trash'];
}

public function actionRestore($record, $request) {
    $record->deleted_at = null;

    if ($record->save()) {
        return ['success' => true, 'message' => 'Post restored successfully'];
    }
    return ['success' => false, 'message' => 'Restore failed'];
}

Row-Level Conditional Visibility with condition

The condition parameter controls action visibility for individual rows based on row data. Unlike showIfFilter which checks global table filters, condition evaluates each row independently.

How It Works

The condition parameter accepts a callable (function or closure) that:

  • Receives the current $row object as parameter
  • Has access to all row field values via $row->field_name
  • Returns true to show the action, false to hide it
  • Is evaluated separately for each row in the table

Basic Example

$table = \Builders\TableBuilder::create($model, 'table_id')
    ->setActions([
        'edit' => [
            'label' => 'Modifica',
            'link' => '?page=frequenze&action=edit&id=%id%'
        ],
        'ritira' => [
            'label' => 'Ritira',
            'link' => '?page=frequenze&action=ritira&id=%id%',
            'class' => 'btn-warning btn-sm',
            'icon' => 'bi-x-circle',
            'condition' => function($row) {
                // Show only if DATA_FINE_FREQ is empty (active enrollment)
                return empty($row->DATA_FINE_FREQ);
            }
        ],
        'restore' => [
            'label' => 'Ripristina',
            'link' => '?page=frequenze&action=restore&id=%id%',
            'class' => 'btn-success btn-sm',
            'condition' => function($row) {
                // Show only if DATA_FINE_FREQ is NOT empty (withdrawn enrollment)
                return !empty($row->DATA_FINE_FREQ);
            }
        ]
    ]);

In this example, each row will show different actions based on its data:

  • Active enrollments (empty DATA_FINE_FREQ): Show "Modifica" and "Ritira"
  • Withdrawn enrollments (filled DATA_FINE_FREQ): Show "Modifica" and "Ripristina"

Complex Conditions

->setActions([
    'approve' => [
        'label' => 'Approve',
        'action' => [$this, 'actionApprove'],
        'class' => 'btn-success btn-sm',
        'condition' => function($row) {
            // Show only if status is pending AND user has permission
            return $row->status === 'pending' &&
                   $row->created_by !== auth()->id();
        }
    ],
    'cancel' => [
        'label' => 'Cancel Order',
        'action' => [$this, 'actionCancel'],
        'confirm' => 'Cancel this order?',
        'condition' => function($row) {
            // Show only if order is recent and not shipped
            $order_date = strtotime($row->created_at);
            $is_recent = (time() - $order_date) < 86400; // 24 hours
            return $is_recent && $row->shipping_status !== 'shipped';
        }
    ]
]);

Difference Between showIfFilter and condition

Both showIfFilter and condition control action visibility, but they work at different levels and serve different purposes:

Feature showIfFilter condition
Scope Entire table (all rows) Individual row
Evaluates Global table filters Row field values
When Evaluated Once when table loads (in ActionManager) For each row during rendering
Parameter Type Array: ['filter' => 'value'] Callable: function($row)
Access To Active filter values All row data via $row->field
Use Case "Show Archive when viewing Active items" "Show Withdraw for this specific active enrollment"

Practical Comparison Example

// Setup filter
$searchBuilder = \Builders\SearchBuilder::create('enrollments_table')
    ->actionList('year', 'Filter by Year:', [
        '2024' => 'Year 2024',
        '2023' => 'Year 2023'
    ], '2024');

$table = \Builders\TableBuilder::create($model, 'enrollments_table')
    ->filter('year', function($query, $value) {
        $query->where('ANNO_SCOL_FRQ = ?', [$value]);
    }, '2024')

    ->setActions([
        // Always visible
        'edit' => [
            'label' => 'Edit',
            'link' => '?page=enrollments&action=edit&id=%id%'
        ],

        // showIfFilter: Shows for ALL rows when year filter is 2024
        'close_year' => [
            'label' => 'Close Academic Year',
            'action' => [$this, 'actionCloseYear'],
            'showIfFilter' => ['year' => '2024']
            // Visible for ALL 2024 enrollments (table-level)
        ],

        // condition: Shows only for specific active rows
        'withdraw' => [
            'label' => 'Withdraw',
            'link' => '?page=enrollments&action=withdraw&id=%id%',
            'class' => 'btn-warning btn-sm',
            'condition' => function($row) {
                return empty($row->DATA_FINE_FREQ);
            }
            // Visible only for THIS row if it's active (row-level)
        ],

        // Combined: Both conditions must be true
        'transfer_2024' => [
            'label' => 'Transfer to 2025',
            'action' => [$this, 'actionTransfer'],
            'showIfFilter' => ['year' => '2024'], // Only when viewing 2024
            'condition' => function($row) {        // AND only for active enrollments
                return empty($row->DATA_FINE_FREQ);
            }
        ]
    ]);
When to Use Which

Use showIfFilter when:

  • Action relevance depends on the current TABLE VIEW (filter context)
  • You want to show/hide actions for ALL rows at once
  • Example: "Archive All" button only visible when viewing active items

Use condition when:

  • Action relevance depends on INDIVIDUAL ROW DATA
  • Different rows should show different actions
  • Example: "Withdraw" button only for active enrollments, "Restore" only for withdrawn ones

Use both together when: Action requires both global context AND row-specific validation

Default Actions Helper

For common CRUD operations, use setDefaultActions() to automatically generate Edit and Delete actions. This method preserves any actions you've already added, making it easy to combine custom actions with standard CRUD operations.

// Simple default actions
$table = \Builders\TableBuilder::create($model, 'table_id')
    ->setDefaultActions();

// Generates:
// - Edit: links to ?page=current_page&action=edit&id=%id%
// - Delete: calls built-in delete handler with confirmation

// With custom page
$table = \Builders\TableBuilder::create($model, 'table_id')
    ->setPage('posts')
    ->setDefaultActions();

// Combine custom actions with defaults (RECOMMENDED)
$table = \Builders\TableBuilder::create($model, 'table_id')
    ->addAction('comment', [
        'label' => 'Comments',
        'link' => '?page=posts&action=comments&id=%id%'
    ])
    ->setDefaultActions();
// Result: Comment, Edit, Delete (in that order)

// Add additional actions via parameter
$table = \Builders\TableBuilder::create($model, 'table_id')
    ->setDefaultActions([
        'view' => [
            'label' => 'View',
            'link' => '?page=posts&action=view&id=%id%',
            'target' => '_blank'
        ]
    ]);
// Result: Edit, Delete, View (defaults first, then custom)
Important: Action Order

When you call setDefaultActions(), it adds Edit and Delete actions after any actions you've already added with addAction(). Actions passed as parameter to setDefaultActions() are added last.

Opening Offcanvas from Row Actions

You can open an offcanvas sidebar from a row action without writing any JavaScript. Simply return an offcanvas_end array in your action callback.

No JavaScript Required!

The framework automatically handles opening the offcanvas when your callback returns the special offcanvas_end key. This works for both row actions and bulk actions.

Basic Example: View Details in Offcanvas

// Controller
class TasksController extends AbstractController {

    #[RequestAction('home')]
    public function tasksList() {
        $response = TableBuilder::create($this->model, 'tasks_table')
            ->setActions([
                'view' => [
                    'label' => 'View Details',
                    'action' => [$this, 'actionViewDetails'],
                    'class' => 'btn btn-sm btn-info'
                ],
                'edit' => [
                    'label' => 'Edit',
                    'link' => '?page=tasks&action=edit&id=%id%'
                ]
            ])
            ->getResponse(); // IMPORTANT: Use getResponse() with action callbacks!

        Response::render(__DIR__ . '/Views/list_page.php', $response);
    }

    // Action callback - opens offcanvas automatically
    public function actionViewDetails($record, $request) {
        // Build HTML content for offcanvas
        $html = '
        
' . _esc_html($record->title) . '

Status: ' . $record->status . '

Priority: ' . $record->priority . '

' . $record->description . '

'; // Return with offcanvas_end key return [ 'success' => true, 'offcanvas_end' => [ 'title' => 'Task Details', 'body' => $html, 'size' => '' // Optional: 'xl' for extra large ] ]; } }

Offcanvas Configuration Options

Key Type Required Description
title string Yes Offcanvas header title
body string (HTML) Yes HTML content to display in offcanvas body
size string No Offcanvas width: '' (default) or 'xl' (extra large)

Advanced Example: Edit Form in Offcanvas

// Controller
public function actionQuickEdit($record, $request) {
    // Get record for editing
    $data = $this->model->getByIdForEdit($record->id);

    // Build form using FormBuilder
    $form = FormBuilder::create($this->model)
        ->addFieldsFromObject($data, 'edit')
        ->removeField('created_at')
        ->removeField('updated_at')
        ->setActions([
            'save' => [
                'label' => 'Save',
                'class' => 'btn btn-primary',
                'action' => FormBuilder::saveAction('?page='.$this->page)
            ],
            'cancel' => [
                'label' => 'Cancel',
                'type' => 'link',
                'class' => 'btn btn-secondary',
                'link' => 'javascript:window.offcanvasEnd.hide()'
            ]
        ])
        ->getForm();

    return [
        'success' => true,
        'offcanvas_end' => [
            'title' => 'Quick Edit: ' . $record->title,
            'body' => $form,
            'size' => 'xl' // Larger size for forms
        ]
    ];
}

Combining with Other Actions

You can mix offcanvas actions with regular actions and links:

->setActions([
    'view' => [
        'label' => 'Quick View',
        'action' => [$this, 'actionViewInOffcanvas']
        // Opens offcanvas when callback returns offcanvas_end
    ],
    'edit' => [
        'label' => 'Full Edit',
        'link' => '?page=tasks&action=edit&id=%id%'
        // Regular page navigation
    ],
    'delete' => [
        'label' => 'Delete',
        'action' => [$this, 'actionDelete'],
        'confirm' => 'Are you sure?'
        // Regular callback with reload
    ]
])
How It Works

When a row action callback returns an array containing offcanvas_end, the framework:

  1. Automatically calls window.offcanvasEnd.show()
  2. Sets the title using window.offcanvasEnd.title()
  3. Sets the body HTML using window.offcanvasEnd.body()
  4. Optionally sets the size using window.offcanvasEnd.size()

No custom JavaScript needed! The table component handles everything automatically.

Related Documentation

Loading...