Milk Admin

Bulk Actions Documentation

Revision: 2025/12/02

Overview

Actions vs Bulk Actions
  • Row Actions: Actions column at the end of each table row for single record operations (edit, delete, view)
  • Bulk Actions: Actions that appear when selecting multiple rows via checkboxes (bulk delete, export, update status)
  • Checkboxes: Automatically appear when you define bulk actions using setBulkActions()

Basic Setup

<?php
namespace Modules\Posts;
use App\Abstracts\AbstractController;
use Builders\TableBuilder;

class PostsController extends AbstractController {

    #[RequestAction('home')]
    public function postsList() {
        $response = ['page' => $this->page, 'title' => $this->title];

        $tableBuilder = TableBuilder::create($this->model, 'idTablePosts')
            ->setBulkActions([
                'delete' => [
                    'label' => 'Delete Selected',
                    'action' => [$this, 'actionBulkDelete']
                ]
            ]);

        $response = array_merge($response, $tableBuilder->getResponse());
        Response::render(__DIR__ . '/Views/list_page.php', $response);
    }

    public function actionBulkDelete($record, $request) {
        if ($record->delete($record->id)) {
            return ['success' => true, 'msg' => 'Deleted successfully'];
        }
        return ['success' => false, 'msg' => 'Delete failed'];
    }
}
Important: Always use getResponse() instead of render() when using bulk actions or row actions with callbacks. This ensures action return values are properly merged with the table response.

Configuration Methods

addBulkAction() vs setBulkActions()
  • addBulkAction($key, $action_data): Adds a single bulk action without modifying existing ones
  • setBulkActions($actions): Replaces all bulk actions with the provided set (uses addBulkAction() internally)

setBulkActions() - Set All Actions

Replaces all bulk actions with a new set:

->setBulkActions([
    'delete' => [
        'label' => 'Delete',
        'action' => [$this, 'actionDelete']
    ],
    'export' => [
        'label' => 'Export',
        'action' => [$this, 'actionExport'],
        'mode' => 'batch',
        'updateTable' => false
    ]
])

addBulkAction() - Add Single Action

Add one action at a time without replacing existing ones:

->addBulkAction('archive', [
    'label' => 'Archive',
    'action' => [$this, 'actionArchive']
])

// Chain multiple actions
->addBulkAction('publish', ['label' => 'Publish', 'action' => [$this, 'actionPublish']])
->addBulkAction('unpublish', ['label' => 'Unpublish', 'action' => [$this, 'actionUnpublish']])

getBulkActions() - Inspect Configured Actions

Retrieve the list of all configured bulk actions:

$tableBuilder = TableBuilder::create($model, 'idTablePosts')
    ->addBulkAction('delete', ['label' => 'Delete', 'action' => [$this, 'actionDelete']])
    ->addBulkAction('export', ['label' => 'Export', 'action' => [$this, 'actionExport']]);

// Get all configured bulk actions
$bulkActions = $tableBuilder->getBulkActions();
// Returns: ['delete' => ['label' => 'Delete', 'action' => ...], 'export' => [...]]

Configuration Options

Parameter Type Default Description
label string required Action name shown in dropdown
action callable required Function to execute: [$this, 'methodName']
mode string 'single' 'single' = called once per record
'batch' = called once with all records
updateTable bool true Whether to reload table after execution
showIfFilter array null Conditional visibility based on active filters

Action Modes

Single Mode (Default)

The action function is called once for each selected record individually:

'delete' => [
    'label' => 'Delete',
    'action' => [$this, 'actionDelete']
    // mode defaults to 'single'
]

public function actionDelete($record, $request) {
    // Called once per selected record
    // $record is a single Model instance
    if ($record->delete($record->id)) {
        return ['success' => true, 'msg' => 'Deleted'];
    }
    return ['success' => false, 'msg' => 'Failed'];
}

Batch Mode

The action function is called once with all selected records together:

'export' => [
    'label' => 'Export',
    'action' => [$this, 'actionExport'],
    'mode' => 'batch',
    'updateTable' => false
]

public function actionExport($records, $request) {
    // Called once with all records
    // $records is a collection of Model instances
    $records->setOutputMode('formatted');

    $csv = '';
    foreach ($records as $record) {
        $csv .= "{$record->id},{$record->title},{$record->status}\n";
    }

    return [
        'success' => true,
        'offcanvas_end' => [
            'title' => 'Export Results',
            'body' => '
' . $csv . '
' ] ]; }

Action Method Signature

Single Mode

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

    // Perform operation on $record

    // Return array with success and msg
    return ['success' => true, 'msg' => 'Operation completed'];
}

Batch Mode

public function actionMethodName($records, $request) {
    // $records - Collection of Model instances
    // $request - Full $_REQUEST array

    // Perform operation on all $records

    // Return array with success and msg
    return ['success' => true, 'msg' => 'Batch operation completed'];
}

Return Values

Action methods must return an associative array. Available keys:

Key Type Description
success bool Whether the operation succeeded
msg string Feedback message to display (note: msg not message)
reload bool Force table reload (overrides updateTable setting)
offcanvas_end array Opens offcanvas panel with content: ['title' => '...', 'body' => '...', 'size' => 'xl']
modal array Opens modal dialog: ['title' => '...', 'body' => '...', 'footer' => '...']
redirect string URL to redirect to after operation

Practical Examples

Status Update

->setBulkActions([
    'publish' => [
        'label' => 'Publish',
        'action' => [$this, 'actionPublish']
    ]
])

public function actionPublish($record, $request) {
    $record->status = 'published';
    $record->published_at = date('Y-m-d H:i:s');
    if ($record->save()) {
        return ['success' => true, 'msg' => 'Published'];
    }
    return ['success' => false, 'msg' => 'Publish failed'];
}

Comparison (Batch Mode with Offcanvas)

->setBulkActions([
    'compare' => [
        'label' => 'Compare',
        'action' => [$this, 'actionCompare'],
        'mode' => 'batch',
        'updateTable' => false
    ]
])

public function actionCompare($records, $request) {
    $records->setOutputMode('formatted');
    $fields = ['title', 'status', 'created_at'];

    $html = '';
    foreach ($fields as $field) {
        $html .= '';
        foreach ($records as $record) {
            $html .= '';
        }
        $html .= '';
    }
    $html .= '
' . $field . '' . ($record->$field ?? '-') . '
'; return [ 'success' => true, 'offcanvas_end' => [ 'title' => 'Comparison', 'body' => $html, 'size' => 'xl' ] ]; }

Using Built-in Delete Action

// You can use the built-in actionDeleteRow method:
->setBulkActions([
    'delete' => [
        'label' => 'Delete',
        'action' => [$tableBuilder, 'actionDeleteRow']
    ]
])

// Or for default row actions with delete:
->setDefaultActions() // Automatically includes edit and delete actions

Conditional Visibility with showIfFilter

Show or hide bulk actions based on active table filters. Perfect for soft-delete patterns or status-based workflows.

Example: Soft Delete Pattern

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

// Configure table with conditional bulk actions
$tableBuilder = 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')

    ->setBulkActions([
        'soft_delete' => [
            'label' => 'Move to Trash',
            'action' => [$this, 'actionSoftDelete'],
            'showIfFilter' => ['status' => 'active']  // Only when viewing active
        ],
        'hard_delete' => [
            'label' => 'Delete Permanently',
            'action' => [$this, 'actionHardDelete'],
            'showIfFilter' => ['status' => 'deleted']  // Only when viewing deleted
        ],
        'restore' => [
            'label' => 'Restore',
            'action' => [$this, 'actionRestore'],
            'showIfFilter' => ['status' => 'deleted']  // Only when viewing deleted
        ]
    ]);

public function actionSoftDelete($record, $request) {
    $record->deleted_at = date('Y-m-d H:i:s');
    if ($record->save()) {
        return ['success' => true, 'msg' => 'Moved to trash'];
    }
    return ['success' => false, 'msg' => 'Failed'];
}

public function actionHardDelete($record, $request) {
    if ($record->deleted_at !== null) {
        if ($record->delete($record->id)) {
            return ['success' => true, 'msg' => 'Permanently deleted'];
        }
    }
    return ['success' => false, 'msg' => 'Cannot delete active records'];
}

public function actionRestore($record, $request) {
    $record->deleted_at = null;
    if ($record->save()) {
        return ['success' => true, 'msg' => 'Restored'];
    }
    return ['success' => false, 'msg' => 'Restore failed'];
}
How It Works

When filter is 'active': Users see only "Move to Trash"

When filter is 'deleted': Users see "Delete Permanently" and "Restore"

Dynamic UI: The dropdown updates automatically when filters change

JavaScript Hooks

Intercept bulk actions on the client side for confirmations, custom downloads, or preventing default behavior:

Hook Syntax

registerHook('table-action-{action_name}', function(ids, elclick, form, sendform) {
    // ids: array of selected record IDs
    // elclick: clicked button element
    // form: table form element
    // sendform: always true (legacy)

    return true;   // Proceed with server request
    return false;  // Cancel request
});

Example: Confirmation

registerHook('table-action-delete', function(ids, elclick, form, sendform) {
    return confirm('Delete ' + ids.length + ' items? This cannot be undone.');
});

Example: Custom Download

registerHook('table-action-download', function(ids, elclick, form, sendform) {
    // Create download form
    const downloadForm = document.createElement('form');
    downloadForm.method = 'POST';
    downloadForm.action = milk_url;
    downloadForm.style.display = 'none';

    const input = document.createElement('input');
    input.name = 'ids';
    input.value = ids.join(',');
    downloadForm.appendChild(input);

    document.body.appendChild(downloadForm);
    downloadForm.submit();
    document.body.removeChild(downloadForm);

    return false; // Don't send default request
});

Working with Links

While bulk actions typically use callbacks, you can also create link-based actions with fetch:

// Row action with link and fetch
->setActions([
    'restore' => [
        'label' => 'Restore',
        'link' => '?page=posts&action=restore&id=%id%',
        'fetch' => 'post'  // Send link as POST via fetch
    ]
])

// Or use activeFetch() to enable fetch mode for all links
->activeFetch()
->setActions([
    'restore' => [
        'label' => 'Restore',
        'link' => '?page=posts&action=restore&id=%id%'
        // fetch: 'post' is added automatically
    ]
])

Best Practices

  • Mode Selection: Use 'single' for operations on individual records, 'batch' for operations requiring all records at once
  • Table Updates: Set updateTable => false for actions that open offcanvas/modal or don't modify data
  • Validation: Always validate operations in action handlers to prevent misuse
  • Error Handling: Return detailed error messages in the msg field for better user feedback
  • Built-in Actions: Use [$tableBuilder, 'actionDeleteRow'] for standard delete operations
  • Conditional Actions: Use showIfFilter to show/hide actions based on active filters (e.g., soft-delete patterns)

Common Pitfalls

Common Mistakes
  • Using 'message' instead of 'msg': The return key is 'msg' not 'message'
  • Using render() instead of getResponse(): Action returns won't be merged with the table response
  • Confusing modes: 'single' mode receives $record (singular), 'batch' receives $records (collection)
  • Wrong addBulkAction() signature: Use addBulkAction($key, $action_data), not addBulkAction($action_data)

Related Documentation

Loading...