tobento/app-crud


README

A simple app CRUD.

Table of Contents

Getting Started

Add the latest version of the app crud project running this command.

composer require tobento/app-crud

Requirements

  • PHP 8.0 or greater

Documentation

App

Check out the App Skeleton if you are using the skeleton.

You may also check out the App to learn more about the app in general.

Crud Boot

The crud boot does the following:

  • Migrates views and assets for default layout
  • Implements needed interfaces
use Tobento\App\AppFactory;

$app = (new AppFactory())->createApp();

// Add directories:
$app->dirs()
    ->dir(realpath(__DIR__.'/../../'), 'root')
    ->dir(realpath(__DIR__.'/../app/'), 'app')
    ->dir($app->dir('app').'config', 'config', group: 'config')
    ->dir($app->dir('root').'vendor', 'vendor')
    ->dir($app->dir('app').'views', 'views', group: 'views')
    ->dir($app->dir('app').'trans', 'trans', group: 'trans')
    ->dir($app->dir('root').'build/public', 'public');

// Adding boots:

// you might boot error handlers:
$app->boot(\Tobento\App\Boot\ErrorHandling::class);

$app->boot(\Tobento\App\Crud\Boot\Crud::class);

// you might boot:
$app->boot(\Tobento\App\View\Boot\Breadcrumb::class);
$app->boot(\Tobento\App\View\Boot\Messages::class);

// Run the app:
$app->run();

Crud Controller

With the CRUD controller you can create pages such as index, create, update, delete or custom pages for resources implementing the Repository Interface.

Create Controller

To create a crud controller simply extend the AbstractCrudController::class and specify a resource name with the RESOURCE_NAME constant.

Next, declare your repository class on the constructor method. You may use the Storage Repository to easily create a repository.

Finally, configure the fields, actions and filters.

use Tobento\App\Crud\AbstractCrudController;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field\FieldInterface;
use Tobento\App\Crud\Field;
use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter\FilterInterface;
use Tobento\App\Crud\Filter;
use Tobento\Service\Repository\RepositoryInterface;

class ProductsController extends AbstractCrudController
{
    /**
     * Must be unique, lowercase and only of [a-z-] characters.
     */
    public const RESOURCE_NAME = 'products';
    
    /**
     * Create a new ProductController.
     *
     * @param RepositoryInterface $repository
     */
    public function __construct(
        ProductRepository $repository
    ) {
        $this->repository = $repository;
    }
    
    /**
     * Returns the configured fields.
     *
     * @param ActionInterface $action
     * @return iterable<FieldInterface>|FieldsInterface
     */
    protected function configureFields(ActionInterface $action): iterable|FieldsInterface
    {
        return [
            Field\PrimaryId::new('id'),
            Field\Text::new('sku'),
            //...
        ];
    }
    
    /**
     * Returns the configured actions.
     *
     * @return iterable<ActionInterface>|ActionsInterface
     */
    protected function configureActions(): iterable|ActionsInterface
    {
        return [
            Action\Index::new(title: 'Products'),
            //...
        ];
    }
    
    /**
     * Returns the configured filters.
     *
     * @param ActionInterface $action
     * @return iterable<FilterInterface>|FiltersInterface
     */
    protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
    {
        return [
            Filter\Columns::new()->open(false),
            //...
        ];
    }
}

Entity Id Name

You may change the entity id name used as the id name of the entity.

use Tobento\App\Crud\AbstractCrudController;

class ProductsController extends AbstractCrudController
{
    /**
     * Returns the entity id name.
     *
     * @return string
     */
    protected function entityIdName(): string
    {
        return 'id';
    }
}

Configure Fields

Use the configureFields method to configure any fields using the Build in Fields or creating your custom fields.

use Tobento\App\Crud\AbstractCrudController;
use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field\FieldInterface;
use Tobento\App\Crud\Field;

class ProductsController extends AbstractCrudController
{
    /**
     * Returns the configured fields.
     *
     * @param ActionInterface $action
     * @return iterable<FieldInterface>|FieldsInterface
     */
    protected function configureFields(ActionInterface $action): iterable|FieldsInterface
    {
        return [
            Field\PrimaryId::new('id'),
            Field\Text::new(name: 'sku'),
            //...
        ];
    }
}

Configure Actions

Use the configureActions method to configure any actions using the Build in Actions or creating your Custom Actions.

use Tobento\App\Crud\AbstractCrudController;
use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Action;

class ProductsController extends AbstractCrudController
{
    /**
     * Returns the configured actions.
     *
     * @return iterable<ActionInterface>|ActionsInterface
     */
    protected function configureActions(): iterable|ActionsInterface
    {
        return [
            Action\Index::new(title: 'Products'),
            Action\Create::new(title: 'New product'),
            Action\Store::new(),
            Action\Edit::new(title: 'Edit product'),
            Action\Update::new(),
            Action\Copy::new(title: 'Copy product'),
            Action\Show::new(),
            Action\Delete::new(),
            Action\BulkDelete::new(),
            Action\BulkEdit::new(),
        ];
    }
}

Configure Filters

Use the configureFilters method to configure any filters using the Build in Filters or creating your custom filters.

use Tobento\App\Crud\AbstractCrudController;
use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter\FilterInterface;
use Tobento\App\Crud\Filter;

class ProductsController extends AbstractCrudController
{
    /**
     * Returns the configured filters.
     *
     * @param ActionInterface $action
     * @return iterable<FilterInterface>|FiltersInterface
     */
    protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
    {
        return [
            Filter\Columns::new()->open(false),
            Filter\FieldsSortOrder::new(),
            ...Filter\Fields::new()
               ->group('field')
               ->fields($action->fields())
               ->toFilters(),
            Filter\PaginationItemsPerPage::new()->open(false),
            
            // must be added last!
            Filter\Pagination::new(),
        ];
    }
}

Route Controller

After creating the crud controller you need to add the routes for the configured actions:

Routing via Crud Boot

use Tobento\App\Boot;
use Tobento\App\Crud\Boot\Crud;

class RoutesBoot extends Boot
{
    public const BOOT = [
        // you may ensure the crud boot:
        Crud::class,
    ];
    
    public function boot(Crud $crud)
    {
        $crud->routeController(App\ProductsController::class);
        
        // or:
        $crud->routeController(
            controller: App\ProductsController::class,
            // you may set specific actions only:
            only: ['index', 'show'],
            // or you may exclude specific actions:
            except: ['bulk'],
            // you may set middlewares for all routes:
            middleware: [
                SomeMiddleware::class,
            ],
            // you may localize the routes:
            localized: true,
        );
    }
}

Manually Routing

You may define the routes manually, if you need even more control:

use Tobento\Service\Routing\RouterInterface;

// After adding boots
$app->booting();

$router = $this->app->get(RouterInterface::class);

$name = App\ProductsController::RESOURCE_NAME;

$router->resource($name, App\ProductsController::class);

// needed if you have configured bulk actions:
$router->post($name.'/bulk/{name}', [App\ProductsController::class, 'bulk'])
    ->name($name.'.bulk');
    
// Run the app:
$app->run();

You may check out the App Http - Routing for more information on routing.

You may check out the Routing Service for more information on routing resources.

Route Permissions

You may install the App User and use the Verify Route Permission Middleware to protect your crud routes from users without the defined permissions.

use Tobento\App\Boot;
use Tobento\App\Crud\Boot\Crud;

class RoutesBoot extends Boot
{
    public function boot(Crud $crud)
    {
        $crud->routeController(
            controller: App\ProductsController::class,
            middleware: [
                [
                    \Tobento\App\User\Middleware\VerifyRoutePermission::class,
                    'permissions' => [
                        'products.index' => 'products',
                        'products.show' => 'products',
                        'products.create' => 'products.create',
                        'products.store' => 'products.create',
                        'products.copy' => 'products.create',
                        'products.edit' => 'products.edit',
                        'products.update' => 'products.edit',
                        'products.delete' => 'products.delete',
                        'products.bulk' => 'products.edit|products.delete',
                    ],
                ]
            ],
        );
    }
}

Fields

Build in Fields

Checkboxes Field

The checkboxes field displays a list of checkboxes using the specified options.

use Tobento\App\Crud\Field;

Field\Checkboxes::new(
    name: 'colors',
    // you may set a label, otherwise name is used:
    label: 'Colors',
);

Options

Use the options method to define the options to choose:

Field\Checkboxes::new('colors')
    // specify the options using an array:
    ->options(['blue' => 'Blue', 'red' => 'Red'])
    
    // or using a closure (parameters are resolved by autowiring):
    ->options(fn(ColorsRepository $repo): array => $repo->findColors());

Empty Option

Use the emptyOption method to change the empty option value needed when no option is selected:

Field\Checkboxes::new('colors')
    ->emptyOption(value: '_none');

Selected Options

You may use the selected method to define the selected value(s):

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field;
use Tobento\App\Crud\Field\FieldInterface;

Field\Checkboxes::new('colors')
    ->selected(value: ['blue', 'red'], action: 'create')
    
    // or using a closure (additional parameters are resolved by autowiring):
    ->selected(
        value: function (ActionInterface $action, FieldInterface $field): null|array {
            return ['blue', 'red'];
            //return null; // if none is selected
        },
        action: 'edit',
    )

Attributes

You may set HTML attributes assigned to each input element using the attributes method:

use Tobento\App\Crud\Field;

Field\Checkboxes::new('colors')->attributes(['class' => 'name']);

Validation

Data are being validated using the defined options. You may define additional rules though:

Field\Checkboxes::new('colors')
    ->validate('required|minItems:2|maxItems:10');

You may check out the Validate Field section for more detail.

File Field

The file field enables you to upload a single file using the FileSource Field. In addition, you can define any fields relating to the file source such an alternative text for images. The data will be stored in JSON format.

use Tobento\App\Crud\Field;

Field\File::new(
    name: 'file',
    // you may set a label, otherwise name is used:
    label: 'File',
);

Fields

use Tobento\App\Crud\Field;

Field\File::new(name: 'file')
    ->fields(
        Field\Text::new('alt', 'Alternative Text')->translatable(),
        Field\Radios::new('buyable', 'Buyable')->displayInline()->options(['no', 'yes']),
    );

Translatable

Use the translatable method if you want to have translatable files.

use Tobento\App\Crud\Field;

Field\File::new(name: 'file')
    ->translatable();

FileSource Field

Use the fileSource method to customize the FileSource Field.

use Tobento\App\Crud\Field;

Field\File::new(name: 'file')
    ->fileSource(function(Field\FileSource $fs): void {
        $fs->storage('uploads')
           ->allowedExtensions('jpg', 'png')
           ->imageEditor(template: 'default');
    });

Storing filenames

You may use the storeFilenameTo method to store the filenames to a certain file field.

use Tobento\App\Crud\Field;

Field\File::new(name: 'file')
    ->fields(
        Field\Text::new('name', 'Filename')->translatable(),
    )
    ->storeFilenameTo(field: 'name');
    
    // with using a filename modifier (callable):
    ->storeFilenameTo(field: 'name', modify: static function(string $filename): string {
        return $filename;
    });

Files Field

The files field enables you to upload multiple files using the FileSource Field. In addition, you can define any fields relating to the file source such an alternative text for images. The data will be stored in JSON format.

use Tobento\App\Crud\Field;

Field\Files::new(
    name: 'files',
    // you may set a label, otherwise name is used:
    label: 'Files',
);

Fields

use Tobento\App\Crud\Field;

Field\Files::new(name: 'files')
    ->fields(
        Field\Text::new('alt', 'Alternative Text')->translatable(),
        Field\Radios::new('buyable', 'Buyable')->displayInline()->options(['no', 'yes']),
    );

File Field

Use the file method to customize the File Field.

use Tobento\App\Crud\Field;

Field\Files::new(name: 'files')
    ->file(function(Field\File $file): void {
        $file->translatable();
        $file->fileSource(function(Field\FileSource $fs): void {
            $fs->allowedExtensions('png');
        });
    });

Number Of Files

Use the numberOfFiles method to set the min and/or max allowed files.

use Tobento\App\Crud\Field;

Field\Files::new(name: 'files')
    // min only;
    ->numberOfFiles(min: 1)
    // or max only:
    ->numberOfFiles(max: 10)
    // or min and max:
    ->numberOfFiles(min: 1, max: 10);

FileSource Field

The file source field enables you to upload a single file, storing the file path such as path/to/file.txt. If you want to store other data such as the storage name or an alternative text for images, consider using the File Field.

use Tobento\App\Crud\Field;

Field\FileSource::new(
    name: 'file',
    // you may set a label, otherwise name is used:
    label: 'File',
);

File Storage

Use the storage method to change the storage name where to store the file. By default the uploads storage is used. Make sure your defined storage is outside the webroot such as the default configured uploads storage.

Field\FileSource::new('image')
    ->storage(name: 'custom-uploads');

Make sure the storage is configured in the app/config/file_storage.php file.

Check out the App File Storage to learn more about file storages in general.

Folder

Use the folder method to define a folder path.

Field\FileSource::new('image')
    ->folder(path: 'shop/products')
    
    // or using a callable:
    ->folder(static function(): string {
        return sprintf('product/%s/%s/', date('Y'), date('m'));
    });

Validation

Use the allowedExtensions method to define the allowed file extensions. By default, only jpg, png, gif and webp are allowed.

Field\FileSource::new('image')
    ->allowedExtensions('jpg', 'png')
    
    // you may set max file size in KB:
    ->maxFileSizeInKb(1000); // or null unlimited (default)

If you need more control validating files, use the validator method:

use Tobento\App\Media\Upload\ValidatorInterface;
use Tobento\App\Media\Upload\Validator;

Field\FileSource::new('image')
    ->validator(static function(): ValidatorInterface {
        return new Validator(
            allowedExtensions: ['jpg'],
            strictFilenameCharacters: true,
            maxFilenameLength: 200,
            maxFileSizeInKb: 2000,
        );
        
        // or create your custom validator implementing the ValidatorInterface!
    });

Check out the Media Upload Validator section to learn more about the validator.

File Writer

If you need more control about writing files to the storage, use the fileWriter method:

use Tobento\App\Media\FileStorage\FileWriter;
use Tobento\App\Media\FileStorage\FileWriterInterface;
use Tobento\App\Media\FileStorage\Writer;
use Tobento\App\Media\Image\ImageProcessor;
use Tobento\Service\FileStorage\StorageInterface;

Field\FileSource::new('image')
    ->fileWriter(static function(StorageInterface $storage): FileWriterInterface {
        return new FileWriter(
            storage: $storage,
            filenames: FileWriter::ALNUM, // RENAME, ALNUM, KEEP
            duplicates: FileWriter::RENAME, // RENAME, OVERWRITE, DENY
            folders: FileWriter::ALNUM, // or KEEP
            folderDepthLimit: 5,
            writers: [
                new Writer\ImageWriter(
                    imageProcessor: new ImageProcessor(
                        actions: [
                            'orientate' => [],
                            'resize' => ['width' => 2000],
                        ],
                    ),
                ),
                new Writer\SvgSanitizerWriter(),
            ],
        );
    });

Check out the Media File Writer section to learn more about the file writer.

Images

By default, images get displayed on the index and edit page using the Media Picture Feature. You may change its default definition or setting it to null to disable it.

use Tobento\Service\Picture\DefinitionInterface;
use Tobento\Service\Picture\Definition\ArrayDefinition;

Field\FileSource::new('image')
    ->picture(definition: [
        'img' => [
            'src' => [120],
            'loading' => 'lazy',
        ],
        'sources' => [
            [
                'srcset' => [
                    '' => [120],
                ],
                'type' => 'image/webp',
            ],
        ],
    ])
    
    // or you may use a definition object implementing the DefinitionInterface:
    ->picture(definition: new ArrayDefinition('product-image', [
        'img' => [
            'src' => [120],
            'loading' => 'lazy',
        ],
        'sources' => [
            [
                'srcset' => [
                    '' => [120],
                ],
                'type' => 'image/webp',
            ],
        ],
    ]))
    
    // or you may disable it, showing just the path:
    ->picture(definition: null)
    
    // You may queue the picture generation:
    ->pictureQueue(true);

Image Editor

You may define an image editor template using the imageEditor method. Once defined, images can be edited using the Image Editor Feature by clicking the edit button on the file. Make sure the feature is installed and the template is defined.

Field\FileSource::new('image')
    ->imageEditor(template: 'crud');

You will need to define an event listener in the app/config/event.php file to deleted generated images once an image is edited:

'listeners' => [
    \Tobento\App\Media\Event\ImageEdited::class => [
        [\Tobento\App\Crud\Listener\ClearGeneratedPicture::class, ['definition' => 'crud-file-src']],
        
        // you add more when using different definitions:
        [\Tobento\App\Crud\Listener\ClearGeneratedPicture::class, ['definition' => 'product-image']],
    ],
],

Picture Editor

You may define a picture using the pictureEditor method. Once defined, images can be edited using the Picture Editor Feature by clicking the edit picture button on the file. Make sure the feature is installed and the template is defined.

Field\FileSource::new('image')
    ->pictureEditor(template: 'default', definitions: ['product-main', 'product-list']);

Display Messages

Use the displayMessages method to define the messages to be displayed.

Field\FileSource::new('image')
    ->displayMessages('error', 'success', 'info', 'notice');

Items Field

The items field displays a collection of items allowing you to add, edit and delete items.

use Tobento\App\Crud\Field;

Field\Items::new('prices')
    // you may group the fields
    ->group('Prices') // set before defining fields!
    
    // define the fields per item:
    ->fields(
        Field\Text::new('price_net', 'Price Net')
            ->type('number')
            ->attributes(['step' => 'any'])
            ->validate('decimal'),
        Field\Text::new('price_gross', 'Price Gross')
            ->type('number')
            ->attributes(['step' => 'any'])
            ->validate('decimal'),
    )
    
    // you may display items as card layout:
    ->displayAsCard()
    
    // you may restrict items:
    ->validate('required|minItems:1|maxItems:30')
    
    // you may define the items number to be displayed on default:
    ->defaultItems(num: 1)
    
    // you may define a custom add text:
    ->addText('Add new price')
    
    // you may not display the label text on create and edit action:
    ->withoutLabel();

Options Field

The options field displays searchable options to choose from using the defined repository. If you have only a few options, you may consider using the Checkboxes Field instead.

use Tobento\App\Crud\Field;

Field\Options::new(
    name: 'categories',
    // you may set a label, otherwise name is used:
    label: 'Categories',
);

Repository (required)

Use the repository method to define the repository implementing the Repository Interface:

use Tobento\Service\Repository\RepositoryInterface;

Field\Options::new('categories')
    ->repository(CategoriesRepository::class) // class-string|RepositoryInterface
    
    // you may change the limit of the searchable options to be displayed:
    ->limit(15); // default is 25

    // you may change the column value to be stored:
    ->storeColumn('sku') // 'id' is default
    
    // you may change the search columns:
    ->searchColumns('title', 'sku'); // 'title' is default

toOption (required)

Use the toOption method to create options from the repository items:

use Tobento\App\Crud\Field;
use Tobento\Service\View\ViewInterface;

Field\Options::new('categories')
    ->toOption(function(object $item, ViewInterface $view, Field\Options $options): Field\Option {        
        return new Field\Option(
            value: (string)$item->get('id'),
            text: (string)$item->get('title'),
        );
    });

Or using option methods:

use Tobento\App\Crud\Field;
use Tobento\Service\View\ViewInterface;

Field\Options::new('categories')
    ->toOption(function(object $item, ViewInterface $view, Field\Options $options): Field\Option {
        return (new Field\Option(value: (string)$item->get('id')))
            ->text((string)$item->get('title'))
            ->text((string)$item->get('sku'))
            ->html('html'); // must be escaped!
    });

Selected Options

You may use the selected method to define the selected value(s):

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field;
use Tobento\App\Crud\Field\FieldInterface;

Field\Options::new('categories')
    ->selected(value: ['2', '5'], action: 'create')
    
    // or using a closure (additional parameters are resolved by autowiring):
    ->selected(
        value: function (ActionInterface $action, FieldInterface $field): null|array {
            return ['2', '5'];
            //return null; // if none is selected
        },
        action: 'edit',
    )

Placeholder

Use the placeholder method to define a placeholder text for the serach input element:

Field\Options::new('categories')
    ->placeholder(text: 'Search categories');

Empty Option

Use the emptyOption method to change the empty option value needed when no option is selected:

Field\Options::new('categories')
    ->emptyOption(value: '_none');

Validation

Data are being validated using the repository to query the options. You may define additional rules though:

Field\Options::new('categories')
    ->validate('required|minItems:2|maxItems:10');

You may check out the Validate Field section for more detail.

PrimaryId Field

The primary id field will not be displayed on the create, edit and show view. In additon, it cannot be edited at all.

use Tobento\App\Crud\Field;

Field\PrimaryId::new(name: 'id', label: 'ID');

Radios Field

The radios field displays a list of radios using the specified options.

use Tobento\App\Crud\Field;

Field\Radios::new(
    name: 'colors',
    // you may set a label, otherwise name is used:
    label: 'Colors',
);

Options

Use the options method to define the options to choose:

Field\Radios::new('colors')
    // specify the options using an array:
    ->options(['blue' => 'Blue', 'red' => 'Red'])
    
    // or using a closure (parameters are resolved by autowiring):
    ->options(fn(ColorsRepository $repo): array => $repo->findColors());

Selected Option

You may use the selected method to define the selected value:

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field;
use Tobento\App\Crud\Field\FieldInterface;

Field\Radios::new('colors')
    ->selected(value: 'blue', action: 'create')
    
    // or using a closure (additional parameters are resolved by autowiring):
    ->selected(
        value: function (ActionInterface $action, FieldInterface $field): null|string {
            return 'blue';
            //return null; // if none is selected
        },
        action: 'edit',
    )

Attributes

You may set HTML attributes assigned to each radio element using the attributes method:

use Tobento\App\Crud\Field;

Field\Radios::new('colors')->attributes(['class' => 'name']);

Display Inline

Use the displayInline method for the radio options to be displayed inline:

Field\Radios::new('colors')
    ->displayInline();

Validation

Data are being validated using the defined options. You may define additional rules though:

Field\Radios::new('colors')
    ->validate('required');

You may check out the Validate Field section for more detail.

Select Field

The select field will be rendered as a HTML select element.

use Tobento\App\Crud\Field;

Field\Select::new(
    name: 'colors',
    // you may set a label, otherwise name is used:
    label: 'Colors',
);

Options

Use the options method to define the options to select:

Field\Select::new('colors')
    // specify the options using an array:
    ->options(['blue' => 'Blue', 'red' => 'Red'])
    
    // or using a closure (parameters are resolved by autowiring):
    ->options(fn(ColorsRepository $repo): array => $repo->findColors());

You may define options as groups:

Field\Select::new('roles')
    ->options([
        'Frontend' => [
            'guest' => 'Guest',
            'registered' => 'Registered',
        ],
        'Backend' => [
            'editor' => 'Editor',
            'administrator' => 'Aministrator',
        ],
    ]);

Empty Option

Use the emptyOption method to define an empty option which will not be saved when selected:

Field\Select::new('colors')
    // specify the options using an array:
    ->options(['blue' => 'Blue', 'red' => 'Red'])
    ->emptyOption(value: 'none', label: '---');

Selected Options

You may use the selected method to define the selected value(s):

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field;
use Tobento\App\Crud\Field\FieldInterface;

Field\Select::new('colors')
    ->selected(value: 'value', action: 'create')
    
    // or using a closure (additional parameters are resolved by autowiring):
    ->selected(
        value: function (ActionInterface $action, FieldInterface $field): null|string|array {
            return ['blue', 'red']; // if multiple selection
            //return 'blue'; // if single selection
            //return null; // if none is selected
        },
        action: 'edit',
    )

Attributes

You may set additional HTML select attributes using the attributes method:

use Tobento\App\Crud\Field;

Field\Select::new('colors')->attributes(['multiple', 'size' => '10']);

Validation

Data are being validated using the defined options. You may define additional rules though:

Field\Select::new('colors')
    ->validate('required')

// rules example if allowing multiple selection:
Field\Select::new('colors')
    ->attributes(['multiple', 'size' => '10'])
    ->validate('required|minItems:2|maxItems:10')

You may check out the Validate Field section for more detail.

Slug Field

The slug field generates slugs based on the provided input. For example, the input of Lorem Ipsum Dolor is usually something like lorem-ipsum-dolor.

use Tobento\App\Crud\Field;

Field\Slug::new(
    name: 'slug',
    // you may set a label, otherwise name is used:
    label: 'SLUG',
);

fromField

You may define a field to generate the slug from when no input from the slug field is provided.

use Tobento\App\Crud\Field;

Field\Slug::new('slug')->fromField('title');

Slugifier

You may use the slugifier method to define a custom slugifier. By default, the slugifier named crud is used but as the slugifier not exists, the default from the app/config/slugging.php will be used.

use Tobento\App\Crud\Field;
use Tobento\Service\Slugifier\SlugifierInterface;
use Tobento\Service\Slugifier\SlugifiersInterface;

// using slugifier name:
Field\Slug::new('slug')->slugifier('crud'); 
// 'crud' is set as default but fallsback to default as not defined in slugging config

// using object:
Field\Slug::new('slug')->slugifier(new Slugifier());

// using closure:
Field\Slug::new('slug')
    ->slugifier(function (SlugifiersInterface $slugifiers): SlugifierInterface {
        return $slugifiers->get('custom');
    });

You can define a custom slugifier in the app/config/slugging.php file.

You may check out the App Slugging bundle to learn more about it in general.

Unique Slugs

By default, generated slugs will be saved in the Slug Repository or deleted from when changed. The slug repository is added to the Slugs by default in the app/config/slugging.php file which enables you to use Slug Matches on routes.

In addition, the default slugifier used has the Prevent Dublicate Modifier applied so that slugs will be generated uniquely.

You may disable unique slugs by using the uniqueSlugs method if you want to implement a custom strategy:

use Tobento\App\Crud\Field;

Field\Slug::new('slug')->uniqueSlugs(false);

Attributes

You may set additional HTML input attributes using the attributes method:

use Tobento\App\Crud\Field;

Field\Slug::new(name: 'title')->attributes(['data-foo' => 'value']);

Example With Readonly

use Tobento\App\Crud\Field;

Field\Slug::new('slug')
    ->fromField('title')
    ->translatable()
    ->readonly(true, action: 'edit|update');

Text Field

The text field will be rendered as an input element of the type text as default. Use this field for any other input type as well.

use Tobento\App\Crud\Field;

Field\Text::new(
    name: 'title',
    // you may set a label, otherwise name is used:
    label: 'TITLE',
);

Type

You may set another HTML input type as the default text type using the type method:

use Tobento\App\Crud\Field;

Field\Text::new(name: 'email')->type('email');

Default Value

You may set a default value using the defaultValue method:

use Tobento\App\Crud\Field;

Field\Text::new(name: 'title')->defaultValue('Lorem');

// you may pass an array of values if your field is translatable:
Field\Text::new(name: 'title')
    ->translatable()
    ->defaultValue(['en' => 'Lorem', 'de' => 'Lorem ipsum']);

Attributes

You may set additional HTML input attributes using the attributes method:

use Tobento\App\Crud\Field;

Field\Text::new(name: 'title')->attributes(['data-foo' => 'value']);

Textarea Field

The textarea field will be rendered as an textarea element.

use Tobento\App\Crud\Field;

Field\Textarea::new(
    name: 'title',
    // you may set a label, otherwise name is used:
    label: 'TITLE',
);

Attributes

You may set additional HTML textarea attributes using the attributes method:

use Tobento\App\Crud\Field;

Field\Textarea::new(name: 'title')->attributes(['rows' => '5']);

TextEditor Field

This field creates a JavaScript-based WYSIWYG editor using the JS Editor.

use Tobento\App\Crud\Field;

Field\TextEditor::new(
    name: 'desc',
    // you may set a label, otherwise name is used:
    label: 'Description',
);

editorConfig

This method allows you to pass a PHP array of the configuration options to set passed to the js editor data-editor attribute:

use Tobento\App\Crud\Field;

Field\TextEditor::new(name: 'desc')
    ->editorConfig([
        'toolbar' => [
            'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
            'bold', 'italic', 'underline', 'strike',
            'ol', 'ul', 'quote', 'pre', 'code',
            'undo', 'redo', 'sourcecode', 'clear',
            'link', 'tables', 'style.fonts', 'style.text.colors'
        ]
    ]);

Security

This field does NOT sanitize the input in any way. You should sanitize the input or output using the App HTML Sanitizer, so you can safely render the value without escaping.

If you use a Repository Storage With Columns, you may use the read or write method on the column to clean the value:

use Tobento\Service\Repository\Storage\Column\Text;
use Tobento\Service\Repository\Storage\Column\ColumnsInterface;
use Tobento\Service\Repository\Storage\StorageRepository;
use function Tobento\App\HtmlSanitizer\sanitizeHtml;

class ExampleRepository extends StorageRepository
{
    protected function configureColumns(): iterable|ColumnsInterface
    {
        return [
            // ...
            Column\Text::new('desc', type: 'text')
                // as there might be data stored before, we clean the html on reading:
                ->read(fn (string $value): string => sanitizeHtml($value))
                
                // clean the html on writing:
                ->write(fn (string $value): string => sanitizeHtml($value))
            // ...
        ];
    }
}

Sure, you may sanitize the html depending on the context such as in your view file:

<?= $view->sanitizeHtml(html: $html) ?>

Different Fields Per Action

Option 1

Using the fields indexable, creatable and editable methods:

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    return [
        //...

        Field\Text::new(name: 'sku')

            // disabled on index action:
            ->indexable(false)

            // disabled on create and store action:
            ->creatable(false)
            
            // disabled on edit, update, delete and any bulk actions such as bulk-edit and bulk-delete action:
            ->editable(false)
            
            // disabled on show action:
            ->showable(false),

        //...
    ];
}

Option 2

Using the action name:

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    yield Field\PrimaryId::new('id');

    if (in_array($action->name(), ['create', 'store'])) {
        yield Field\Text::new(name: 'sku');
    }

    yield Field\Text::new(name: 'title');
}

Option 3

Using the action setFields method:

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Field;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->setFields(new Field\Fields(
                Field\PrimaryId::new('id'),
                Field\Text::new(name: 'sku'),
            )),
    ];
}

Validate Field

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    return [
        Field\Text::new(name: 'sku')
        
            // used for all actions:
            ->validate('required|alnum')
        
            // or using different validation per action:
            ->validate(
                store: 'required|alnum',
                update: 'required|alnum',
            ),
    ];
}

The field validation is using the Validation Service to validate the field.

applyValidationAttributes

By default, HTML validation attributes gets created based on your validate rules and applied for the fields text, textarea, texteditor, select. You may disable it using the applyValidationAttributes method if you want to add validation attributes by yourself.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    return [
        Field\Text::new(name: 'sku')
            ->validate('required|alnum')
            ->applyValidationAttributes(false)
            ->attributes(['required']),
    ];
}

Translatable Field

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    return [
        Field\Text::new(name: 'title')
            ->translatable(),
    ];
}

If you use a Repository Storage With Columns, make sure you use a translatable column:

use Tobento\Service\Repository\Storage\Column;
use Tobento\Service\Repository\Storage\Column\ColumnsInterface;

protected function configureColumns(): iterable|ColumnsInterface
{
    return [
        //...
        Column\Translatable::new(name: 'title'),
    ];
}

Supported Locales

All locales are supported as defined in the Language Config.

Unstorable Field

When you set a field as unstorable using the storable method, the field gets not saved.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    return [
        Field\Text::new(name: 'foo')
            ->storable(false),
    ];
}

Readonly And Disabled Field

You may set a field as readonly or disabled which will be automatically an unstorable field.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\FieldInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    return [
        Field\Text::new(name: 'foo')
            ->readonly()
            
            // or you may set only for specific actions:
            ->readonly(action: 'edit|update')
            
            // or you may use a closure (parameters are resolved by autowiring):
            ->readonly(function (ActionInterface $action, FieldInterface $field): bool {
                return true;
            }, action: 'edit|update')
            
            ->disabled()
            
            // or you may set only for specific actions:
            ->disabled(action: 'edit|update')
            
            // or you may use a closure (parameters are resolved by autowiring):
            ->disabled(function (ActionInterface $action, FieldInterface $field): bool {
                return true;
            }, action: 'edit|update')
    ];
}

Field Grouping

Use the group method to group fields:

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    return [
        Field\Text::new(name: 'foo')->group('Name'),
        Field\Text::new(name: 'bar')->group('Name'),
        
        Field\Text::new(name: 'baz')->group('Another Name'),
    ];
}

Field Texts

requiredText

By default, the required text will be set automatically if you have any required validation rules set. But you may specify a custom text:

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    return [
        Field\Text::new(name: 'foo')
            ->requiredText('Required because of ...')
            // same as:
            ->requiredText('Required because of ...', action: 'create|edit')
            
            // or using different text per action:
            ->requiredText('Required because of ...', action: 'edit'),
    ];
}

optionalText

By default, the optional text will be set automatically if you do not have any required validation rules set. But you may specify a custom text:

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    return [
        Field\Text::new(name: 'foo')
            ->optionalText('optional ...')
            // same as:
            ->optionalText('optional ...', action: 'create|edit')
            
            // or using different text per action:
            ->optionalText('optional ...', action: 'edit'),
    ];
}

infoText

You may specify an info text:

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    return [
        Field\Text::new(name: 'foo')
            ->infoText('Some info ...')
            // same as:
            ->infoText('Some info ...', action: 'create|edit')
            
            // or using different text per action:
            ->infoText('Some info ...', action: 'edit'),
    ];
}

Field Resolving

You may use the resolve method to set any field parameters from a resolved value or just for specific actions.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\EntityInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field\FieldInterface;
use Tobento\App\Crud\Field;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    return [
        Field\Text::new(name: 'foo')
            ->resolve(
                // you may define additional parameters being resolved by autowiring!
                resolve: function (ActionInterface $action, FieldInterface $field): void {
                    $entity = $field->entity(); // EntityInterface
                    $locale = $field->locale();
                    $locales = $field->locales();
                    
                    $field->attributes(['data-foo' => 'value']);
                },
                
                // you may define for which actions to resolve, otherwise it will be resolved for all actions:
                action: 'create|edit',
            ),
    ];
}

Custom Field Action

You may customize exisiting field actions or add custom actions using the process method:

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Field\ActionInterface;
use Tobento\App\Crud\Field\EntityInterface;
use Tobento\App\Crud\Field\FieldInterface;
use Tobento\App\Crud\Field\FieldsInterface;
use Tobento\App\Crud\Field;
use Tobento\App\Crud\Input\InputInterface;
use Tobento\Service\Support\Str;

protected function configureFields(ActionInterface $action): iterable|FieldsInterface
{
    return [
        Field\Text::new(name: 'foo')
            ->process(
                action: 'index',
                // you may define additional parameters being resolved by autowiring!
                processor: function (ActionInterface $action, FieldInterface $field): void {
                    $entity = $field->entity(); // EntityInterface
                    $locale = $field->locale();
                    $locales = $field->locales();
                    $html = (string)$entity->get($field->name(), '', $locale);
                    $field->html(Str::esc($html));
                }
            )
            ->process(
                action: 'create|edit',
                // you may define additional parameters being resolved by autowiring!
                processor: function (ActionInterface $action, FieldInterface $field): void {
                    $field->html('html');
                }
            )
            ->process(
                action: 'store|update',
                // you may define additional parameters being resolved by autowiring!
                processor: function (ActionInterface $action, FieldInterface $field, InputInterface $input): void {
                    // you may modify the input data:
                    $input->set($field->name(), 'data');
                }
            )
            ->process(
                action: 'stored|updated',
                // you may define additional parameters being resolved by autowiring!
                processor: function (ActionInterface $action, FieldInterface $field): void {
                    // you may do something on the custom action.
                    $actionName = $action->name(); // store or update
                    $entity = $field->entity(); // stored or updated entity
                    $oldEntity = $field->oldEntity(); // old entity before stored or updated
                }
            )
            ->process(
                action: 'delete',
                // you may define additional parameters being resolved by autowiring!
                processor: function (ActionInterface $action, FieldInterface $field): void {
                    // you may do something on delete.
                }
            )
            ->process(
                action: 'deleted',
                // you may define additional parameters being resolved by autowiring!
                processor: function (ActionInterface $action, FieldInterface $field): void {
                    // you may do something on the custom action.
                    $actionName = $action->name(); // delete
                    $entity = $field->entity(); // deleted entity
                }
            )            
            ->process(
                action: 'custom',
                // you may define additional parameters being resolved by autowiring!
                processor: function (ActionInterface $action, FieldInterface $field, InputInterface $input): void {                   
                    // you may do something on the custom action.
                }
            ),
    ];
}

Actions

Build in Actions

Index Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            
            // you may set a custom view:
            ->view('custom/crud/index')
    ];
}

Configure Buttons

The default buttons are named create, edit, show, show.json, copy and delete.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->removeButton('delete', 'show'),
    ];
}

Check out the Buttons section for more information.

Create Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Create::new(title: 'New product')
        
            // you may set a custom view:
            ->view('custom/crud/create')
    ];
}

Configure Buttons

The default buttons are named cancel, save, close, copy and new.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Create::new(title: 'New product')
            ->removeButton('create', 'edit'),
    ];
}

Check out the Buttons section for more information.

Store Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Store::new(),
    ];
}

Edit Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Entity\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Edit::new(title: 'Edit product'),
        
        // or using the entity:
        Action\Edit::new(fn (EntityInterface $entity): string => 'Edit Product: '.$entity->get('sku'))
        
            // you may set a custom view:
            ->view('custom/crud/edit')
    ];
}

Configure Buttons

The default buttons are named cancel, save, close, copy and new.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Edit::new(title: 'Edit product')
            ->removeButton('copy', 'new'),
    ];
}

Update Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Update::new(),
    ];
}

Unupdatable Entities

Sometimes, it may be useful to prevent certain entities from being updatabed by using the unupdatable method:

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Entity\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Update::new()
            // by entity ids using an array:
            ->unupdatable([12, 13])
            
            // or using a closure:
            ->unupdatable(fn (EntityInterface $entity): bool => in_array($entity->get('sku'), ['foo', 'bar'])),
            
        // In addition, you may not display the edit button for those entities:
        Action\Index::new('Products')
            ->displayButtonIf('edit', fn (EntityInterface $entity): bool => !in_array($entity->get('sku'), ['foo', 'bar']))
    ];
}

Copy Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Entity\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Copy::new(title: 'Copy product'),
        
        // or using the entity:
        Action\Copy::new(fn (EntityInterface $entity): string => 'Copy Product: '.$entity->get('sku'))
        
            // you may set a custom view:
            ->view('custom/crud/copy')
            //->view('crud/create') // is default view
    ];
}

Configure Buttons

The default buttons are named cancel, save, close, copy and new.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Copy::new(title: 'Copy product')
            ->removeButton('copy', 'new'),
    ];
}

Show Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Entity\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Show::new(title: 'Show product'),
        
        // or using the entity:
        Action\Show::new(fn (EntityInterface $entity): string => 'Product: '.$entity->get('sku')),
        
        Action\Show::new(title: 'Show product')
            // you may set a custom view:
            ->view('custom/crud/show')
    ];
}

Configure Buttons

The default buttons are named back.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Show::new(title: 'Show product')
            ->removeButton('back'),
    ];
}

Show JSON Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Entity\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\ShowJson::new(),
    ];
}

Delete Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Delete::new(),
    ];
}

Undeletable Entities

Sometimes, it may be useful to prevent certain entities from being deleted by using the undeletable method:

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Entity\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Delete::new()
            // by entity ids using an array:
            ->undeletable([12, 13])
            
            // or using a closure:
            ->undeletable(fn (EntityInterface $entity): bool => in_array($entity->get('sku'), ['foo', 'bar'])),
            
        // In addition, you may not display the delete button for those entities:
        Action\Index::new('Products')
            ->displayButtonIf('delete', fn (EntityInterface $entity): bool => !in_array($entity->get('sku'), ['foo', 'bar']))
    ];
}

Bulk Delete Action

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\BulkDelete::new(),
    ];
}

Bulk Edit Action

You can make as many bulk edit actions as you want. Make sure the name parameter is unique and only contains a-z letters and hyphens.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\BulkEdit::new(name: 'edit-status', title: 'Edit Status')
            ->field('status'),
        
        Action\BulkEdit::new(name: 'edit-multiple', title: 'Edit Multiple Fields')
            ->field('fieldname', 'another-fieldname'),
    ];
}

Supported Fields

The following fields support bulk editing:

Buttons

All build-in actions have already specified the buttons for linking to other actions. You may configure the buttons by the following methods.

You may view the action buttons method to see its configuration such as the button names.

Creating Buttons

Available Buttons

$link = Button\Link::new(label: 'Label', group: 'entity');
// renders an <a> element

$button = Button\Button::new(label: 'Label', group: 'entity');
// renders a <button> element

$delete = Button\Delete::new(label: 'Label', group: 'entity');
// renders a <form> element to delete an entity: 

$dropdown = Button\Dropdown::new(label: 'Label', group: 'entity');

Linking Methods

$link = Button\Link::new(label: 'View invoice', group: 'entity')
    // link to an existing action:
    ->linkToAction('viewInvoice')

    // link to a route:
    ->linkToRoute('viewInvoice')

    // link to a route with parameters:
    ->linkToRoute('viewInvoice', [
        'param' => 'value',
    ])

    // link to a route using a closure:
    ->linkToRoute('viewInvoice', function(EntityInterface $entity): array {
        return ['id' => $entity->id()];
    })

    // link to an url:
    ->linkToUrl('https://example.com/invoice')

    // link to an url using a closure:
    ->linkToUrl(function(EntityInterface $entity): string {
        return 'https://example.com/invoice/'.$entity->id();
    });

General Methods

$link = Button\Link::new(label: 'View invoice', group: 'entity')
    // You may define a name:
    ->name('viewInvoice')
    // You modify the label:
    ->label('View invoice')
    // You may define an icon:
    ->icon('invoice')
    
    // You may set it as primary button:
    ->primary()
    // You may set it as raw button:
    ->raw()
    
    // You may add an attribute:
    ->attr('data-foo', 'value')
    // You may remove an attribute:
    ->removeAttr('data-foo')
    
    // You may ask to confirm the action using inline buttons:
    ->askConfirmation()
    // If a text is given, a modal is used:
    ->askConfirmation('Are you sure you want to perform this action.')
    // Or disable it if previous set:
    ->askConfirmation(false)
    
    // You may use AJAX to perform the action:
    ->ajaxAction()
    // With a success message:
    ->ajaxAction('Action performed successfully.')
    // Or disable it if previous set:
    ->ajaxAction(false);

Adding Buttons

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Button;
use Tobento\App\Crud\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    $button = Button\Link::new(label: 'View invoice', group: 'entity')
        ->name('viewInvoice')
        ->linkToAction('viewInvoice');
        
    return [
        Action\Index::new(title: 'Products')
            ->addButton($button),
    ];
}

Removing Buttons

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->removeButton('create', 'edit'),
    ];
}

Reorder Buttons

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->reorderButtons('edit', 'delete'),
    ];
}

Modify Buttons

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Button\ButtonInterface;
use Tobento\App\Crud\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->modifyButton('edit', function(ButtonInterface $button, EntityInterface $entity): void {
                $button
                    ->label('')
                    ->icon('pencil')
                    ->primary(false)
                    ->raw(true);
            }),
    ];
}

Grouping Buttons

You may group buttons using the groupButtons method. If no group button is defined, buttons will be grouped using a dropdown button.

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Button;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->groupButtons(
                except: ['edit'],
                //only: ['show', 'delete'],
                
                // You may set a label:
                label: 'More',
                // You may set an icon:
                icon: 'dots',
                // You may set a name:
                name: 'more',
            )
            
            // or you may define a custom button:
            ->groupButtons(
                only: ['show', 'delete'],
                button: Button\Dropdown::new(label: '', icon: 'dots', group: 'entity')
                    ->name('anotherGroup')
                    ->raw(),
            ),
    ];
}

Display Buttons Conditionally

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->displayButtonIf('viewInvoice', fn (EntityInterface $entity): bool => $entity->get('isPaid'))
            
            // or with bool:
            ->displayButtonIf('viewInvoice', true),
    ];
}

Confirming Button Action

You may ask to confirm the button action using the confirmButtonAction method:

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\EntityInterface;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            // asking to confirm the action using inline buttons:
            ->confirmButtonAction('delete')
            
            // if a text is given, a modal is opened asking to confirm the action:
            ->confirmButtonAction('delete', 'Are you sure you want to delete the product')
            
            // or using a callable:
            ->confirmButtonAction('delete', function(EntityInterface $entity): string {
                return sprintf('Are you sure you want to delete the product %s', $entity->id());
            })
            
            // you may disable it if already set by default:
            ->confirmButtonAction('delete', false),
    ];
}

AJAX Button Action

You may use the ajaxButtonAction method to set whether to use AJAX to perform the button action:

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            // using ajax:
            ->ajaxButtonAction('delete')
            
            // using ajax with a success message:
            ->ajaxButtonAction('delete', 'Deleted the product successfully.')
            
            // or using a callable:
            ->ajaxButtonAction('delete', function(EntityInterface $entity): string {
                return sprintf('Deleted the product %s successfully.', $entity->id());
            })
            
            // you may disable it if already set by default:
            ->ajaxButtonAction('delete', false),
    ];
}

Set Buttons

Using the setButtons method will overwrite the default buttons. Any configurable buttons methods such as reorderButtons e.g. will be ignored though!

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Button;

protected function configureActions(): iterable|ActionsInterface
{
    return [
        Action\Index::new(title: 'Products')
            ->setButtons(
                Button\Link::new(label: 'Create New', group: 'global')
                    ->name('create')
                    ->linkToAction('create'),
            ),
    ];
}

Custom Action

You may create a custom action by the following way:

1. Create And Add Button

use Tobento\App\Crud\Action\ActionsInterface;
use Tobento\App\Crud\Action;
use Tobento\App\Crud\Button;

protected function configureActions(): iterable|ActionsInterface
{
    $viewInvoiceBtn = Button\Link::new(label: 'View Invoice', group: 'entity')
        ->name('viewInvoice')
        // link to an action:
        ->linkToAction('viewInvoice')
        // or link to a route:
        ->linkToRoute('products.invoice.view', function(EntityInterface $entity): array {
            return ['id' => $entity->id()];
        });
        
    return [
        Action\Index::new(title: 'Products')
            ->addButton($viewInvoiceBtn),
        //...
    ];
}

2. Create Action

If your added button links to a custom action using the linkToAction method, you will need to create the corresponding action, otherwise skip this step:

use Tobento\App\Crud\Button\ButtonsInterface;
use Tobento\App\Crud\Button\Buttons;
use Tobento\App\Crud\Button;
use Tobento\App\Crud\Entity\EntityInterface;
use Closure;

final class ViewInvoice extends AbstractAction
{
    public function __construct(
        null|string|Closure $title = null,
    ) {
        $this->title = $title;
        $this->route('{name}.invoice.view', function(EntityInterface $entity): array {
            return ['id' => $entity->id()];
        });
    }

    public static function new(null|string|Closure $title = null): static
    {
        return new static($title);
    }
    
    public function name(): string
    {
        return 'viewInvoice';
    }
    
    public function buttons(): ButtonsInterface
    {
        if ($this->buttons instanceof ButtonsInterface) {
            return $this->buttons;
        }
        
        $this->buttons = new Buttons(
            Button\Link::new(label: $this->trans('Back to index'), group: 'entity')
                ->name('back')
                ->linkToAction('index'),
        );
        
        return $this->applyButtonsConfig($this->buttons);
    }
}

3. Route your action to the CRUD controller

use Tobento\Service\Routing\RouterInterface;

// After adding boots
$app->booting();

$router = $this->app->get(RouterInterface::class);

$name = App\ProductsController::RESOURCE_NAME;

// needed if you have configured bulk actions:
$router->get($name.'/invoice/{id}', [App\ProductsController::class, 'viewInvoice'])
    ->name($name.'.invoice.view');

Sure, you may route it to a different controller too!

4. Create your method in the routed controller

use Psr\Http\Message\ResponseInterface;
use Tobento\App\Crud\AbstractCrudController;

class ProductsController extends AbstractCrudController
{
    public function viewInvoice(int|string $id): ResponseInterface
    {
        // ...
        return $response;
    }
}

Filters

Build in Filters

Checkboxes Filter

Adds multiple HTML input elements of the type checkbox filtering the checked values.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Checkboxes::new(name: 'colors', field: 'color')
            // specify the options using an array:
            ->options(['blue' => 'Blue', 'red' => 'Red'])
            
            // or using a closure (parameters are resolved by autowiring):
            ->options(fn(ProductRepository $repo): array => $repo->findAllColors()),
            
            // you may set the default selected values:
            ->selected(['blue', 'red'])
            
            // you may add attributes applied to all input elements:
            ->attributes(['data-foo' => 'foo'])
            
            // you may change the comparison:
            ->comparison('in') // = (default)
            // 'in', 'not like', 'contains'
            
            // hide on default:
            ->open(false)
            
            // display above table (default):
            ->group('header')
            
            // display below table:
            ->group('footer')
            
            // display in modal:
            ->group('modal')
            
            // display in the aside area:
            ->group('aside')
            
            // display in table at the field:
            ->group('field')
            
            // you may set a label:
            ->label('Colors')
            
            // you may set a description:
            ->description('Lorem ipsum')
            
            // you may set a custom view:
            ->view('custom/crud/filter'),
        
        // you may use dot notation for the name and
        // use -> for the field (JSON) if your repository supports it:
        Filter\Checkboxes::new(name: 'options.color', field: 'options->color')
            ->options(['blue' => 'Blue', 'red' => 'Red'])
            ->comparison('contains'),
    ];
}

Example using the after method

You may use the after method to define the filters where parameters if you are not define a field or for custom filtering.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Checkboxes::new(name: 'categories') // no field defined!
            // specify the options using an array:
            ->options(['1' => 'Foo Category', '3' => 'Bar Category'])
            
            // you may use the after method to set the filters where parameters:
            ->after(function(Filter\Checkboxes $filter, FiltersInterface $filters, ProductToCategoryRepository $repo): void {
                if (empty($filter->getSelected())) {
                    return;
                }
                
                $ids = $repo->findProductIdsForCategoryIds(
                    categoryIds: $filter->getSelected(),
                    
                    // you may get the limit from the filters:
                    limit: $filters->getLimitParameter()[0] ?? 100,
                );

                $filter->setWhereParameters(['id' => ['in' => $ids]]);
            }),
    ];
}

Clear Button Filter

Filter to display a button for clearing filter(s).

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\ClearButton::new()
        
            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')
            
            // display below table:
            ->group('footer')
            
            // display in modal:
            ->group('modal')
            
            // display in the aside area:
            ->group('aside')
            
            // you may set a custom label:
            ->label('Clear all filters')
            
            // you may set button attributes:
            ->attributes(['data-foo' => 'value']),
        
        // or clear specific filter by its names:
        Filter\ClearButton::new(
            filters: ['foo', 'bar'],
            name: 'unique-filter-name', // only if multiple clear button filters
        )->label('Clear foo and bar filters'),
    ];
}

Columns Filter

Filter to display only the selected columns.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Columns::new()
        
            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')
            
            // display below table:
            ->group('footer')
            
            // display in modal:
            ->group('modal')
            
            // display in the aside area:
            ->group('aside')
            
            // you may set a label:
            ->label('Columns')
            
            // you may set a description:
            ->description('The columns to display') // is default text
            
            // you may set a custom view:
            ->view('custom/crud/filter')
    ];
}

Datalist Filter

Filter to display a HTML datalist element.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Datalist::new(name: 'list-titles')
            // specify the options using an array:
            ->options(['foo', 'bar'])
            
            // or using a closure (parameters are resolved by autowiring):
            ->options(fn(ProductRepository $repo): array => $repo->findAllTitles()),
        
        // set the list attributes on the filter you want the datalist to be displayed:
        Filter\Input::new(name: 'data', field: 'title')
            ->attributes(['list' => 'list-titles']),
    ];
}

Using optionsFromField method

You may use the optionsFromField method to easily retrive options from the specified field. Your repository must be of Storage Repository, otherwise it gets ignored.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;
use Tobento\Service\Repository\Storage\StorageRepository;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Datalist::new(name: 'list-titles')
            ->optionsFromField(field: 'title', limit: 50),
            
            // or specify fromInput parameters, applying a where like query:
            ->optionsFromField(field: 'title', fromInput: 'data', limit: 50),
        
        // set the list attributes on the filter you want the datalist to be displayed:
        Filter\Input::new(name: 'data', field: 'title')
            ->attributes(['list' => 'list-titles']),
    ];
}

Examples using options with a closure

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;
use Tobento\App\Crud\InputInterface;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Datalist::new(name: 'list-titles')
            
            // $input, $action and $filter parameters will always be available,
            // any other parameters are resolved by autowiring:
            ->options(function(ProductRepository $repo, InputInterface $input, ActionInterface $action, Filter\Datalist $filter): array {
                // You may get the locales from the action:
                $locale = $action->getLocale();
                $locales = $action->getLocales();
                
                // You may get any fields:
                $field = $action->fields()->get(name: 'name');
                
                // Be careful with $input values as they may come from user input!
                $value = $input->get('data');

                if (!empty($value) && is_string($value)) {
                    // Example if storage repository by using the underlying storage query builder:
                    return $repo->query()->where('title->en', 'like', $value.'%')->column('title->en')->all();
                    
                    // Example using a custom repository method:
                    return $repo->findAllTitlesFromValue(value: $value, locale: $locale);
                }

                return [];
            }),
    ];
}

Editable Columns Filter

Filter to enable inline editing fields in table columns.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\EditableColumns::new('sku', 'title') // define the editable columns
        
            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')
            
            // display below table:
            ->group('footer')
            
            // display in modal:
            ->group('modal')
            
            // display in the aside area:
            ->group('aside')
            
            // you may set a label:
            ->label('Editable Columns')
            
            // you may set a description:
            ->description('The columns to edit in table.'), // is default text
    ];
}

Supported Fields

The following fields support inline table editing:

Fields Filter

The fields filter displays an input filter on each field in the table column.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        ...Filter\Fields::new()
            
            // set the fields from the action:
            ->fields($action->fields())
            
            // display only specific:
            ->only('sku', 'title')
            
            // or
            ->except('sku', 'title')
            
            ->toFilters(),
    ];
}

Fields Sort Order Filter

The fields sort order filter provides the option to order fields up and down.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\FieldsSortOrder::new()
        
            // display only specific:
            ->only('sku', 'title')
            
            // or
            ->except('sku', 'title'),
    ];
}

Group Filter

Filter to group other filters.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Group::new(name: 'group-key')
            // display in modal:
            ->group('modal')
            
            // you may hide the grouped filters:
            ->open(false)
            
            // you may set a custom label, otherwise name is used:
            ->label('Group Name')
            
            // you may set a description:
            ->description('Lorem ipsum')
            
            // you may set a custom view:
            ->view('custom/crud/filter/group'),
            
        // Next, assign filters to the group:
        Filter\Columns::new()->group('group-key'),
        
        // The modal button to open filters:
        Filter\ModalButton::new(),
    ];
}

Input Filter

Adds an HTML input element to filter the field if specified.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Input::new(name: 'foo', field: 'email')
            // you may change the type:
            ->type('email') // text (default)
            
            // you may add attributes for the input element:
            ->attributes(['placeholder' => 'value'])
            
            // you may change the comparison:
            ->comparison('like') // = (default)
            // '=', '!=', '>', '<', '>=', '<=', '<>', '<=>', 'like', 'not like', 'contains'
            
            // hide on default:
            ->open(false)
            
            // display above table (default):
            ->group('header')
            
            // display below table:
            ->group('footer')
            
            // display in modal:
            ->group('modal')
            
            // display in the aside area:
            ->group('aside')
            
            // display in table at the field:
            ->group('field')
            
            // you may set a label:
            ->label('Foo')
            
            // you may set a description:
            ->description('Lorem ipsum')
            
            // you may set a custom view:
            ->view('custom/crud/filter'),
        
        // you may use dot notation for the name and
        // use -> for the field (JSON) if your repository supports it:
        Filter\Input::new(name: 'options.color', field: 'options->color')
            ->comparison('contains'),
    ];
}

Example using the after method

You may use the after method to define the filters where parameters if you are not define a field or for custom filtering.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Input::new(name: 'colors') // no field defined!
            // you may use the after method to set the filters where parameters:
            ->after(function(Filter\Input $filter, FiltersInterface $filters): void {
                if (!is_string($filter->getSearchValue())) {
                    return;
                }

                $filter->setWhereParameters(['fieldname' => ['=' => $filter->getSearchValue()]]);
            }),
    ];
}

Locale Filter

Adds a filter to switch the resource locale. Make sure this filter is added as the first filter because other filters may depend on the locales.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        // should be added as the first filter!
        Filter\Locale::new()
            // hide on default:
            ->open(false)
            
            // display above table (default):
            ->group('header')
            
            // display below table:
            ->group('footer')
            
            // display in modal:
            ->group('modal')
            
            // display in the aside area:
            ->group('aside')
            
            // you may set a label:
            ->label('Resource Locale')
            
            // you may set a description:
            ->description('Lorem ipsum')
            
            // you may set a custom view:
            ->view('custom/crud/filter'),
    ];
}

Menu Filter

Adds a menu based on the specified items. Records will be filtered by the active menu item. The filter uses the Menu Service to render the menu.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Menu::new(name: 'categories', field: 'category_id')
            // specify the menu items using an array:
            ->items([
                ['id' => 'foo', 'name' => 'Foo', 'parent' => null],
                ['id' => 'bar', 'name' => 'Bar', 'parent' => 'foo'],
            ])
            
            // or using a closure (parameters are resolved by autowiring):
            ->items(fn(CategoryRepository $repo): array => $repo->findAllMenuItems()),
            
            // you may change the comparison:
            ->comparison('like') // = (default)
            // '=', '!=', '>', '<', '>=', '<=', '<>', '<=>', 'like', 'not like', 'contains'
            
            // hide on default:
            ->open(false)
            
            // display above table (default):
            ->group('header')
            
            // display below table:
            ->group('footer')
            
            // display in modal:
            ->group('modal')
            
            // display in the aside area:
            ->group('aside')
            
            // you may set a label:
            ->label('Label')
            
            // you may set a description:
            ->description('Lorem ipsum')
            
            // you may set a custom view:
            ->view('custom/crud/filter'),
    ];
}

Example using the after method

You may use the after method to define the filters where parameters if you are not define a field.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Menu::new(name: 'categories') // no field defined!
            // specify the menu items using an array:
            ->items([
                ['id' => 'foo', 'name' => 'Foo', 'parent' => null],
                ['id' => 'bar', 'name' => 'Bar', 'parent' => 'foo'],
            ])
            
            // you may use the after method to set the filters where parameters:
            ->after(function(Filter\Menu $filter, FiltersInterface $filters, ProductToCategoryRepository $repo): void {
                if (!is_string($filter->getActive())) {
                    return;
                }
                
                $ids = $repo->findProductIdsForCategoryId(
                    categoryId: $filter->getActive(),
                    
                    // you may get the limit from the filters:
                    limit: $filters->getLimitParameter()[0] ?? 100,
                );

                $filter->setWhereParameters(['id' => ['in' => $ids]]);
            }),
    ];
}

Modal Button Filter

Filter to display a button for opening the filters in the modal. Will only be displayed if there are filters with the group modal though.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\ModalButton::new()
        
            // hide on default:
            ->open(false)

            // display above table (default):
            ->group('header')
            
            // display below table:
            ->group('footer')
            
            // display in the aside area:
            ->group('aside')
            
            // you may set a custom label:
            ->label('Filters')
            
            // you may set button attributes:
            ->attributes(['data-foo' => 'value']),
        
        // or
        Filter\ModalButton::new(
            name: 'unique-filter-name', // only if multiple clear button filters
        ),
    ];
}

Pagination Filter

Adds pagination for the items.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        // ...
        // must be the last filter added!
        Filter\Pagination::new()
        
            // you may set a label:
            ->label('Current Page')
            
            // you may unset the default description:
            ->description('')
            
            // you may hide on default:
            ->open(false)
            
            // display above table (default):
            ->group('header')
            
            // display in modal:
            ->group('modal')
            
            // display in the aside area:
            ->group('aside')
            
            // display below table:
            ->group('footer')
            
            // you may set a custom view:
            ->view('custom/crud/filter'),
        
        // Or:
        Filter\Pagination::new(
            // Specify the default items to show per page:
            show: 100,
            
            // Specify the max. items (limit) to show per page:
            maxItemsPerPage: 1000, // default 1000
        ),
    ];
}

Pagination Items Per Page Filter

Adds the option to specify how many items to display.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\PaginationItemsPerPage::new()
        
            // you may unset the default label:
            ->label('')
            
            // you may set the default description:
            ->description('Per page')
            
            // you may hide on default:
            ->open(false)
            
            // display above table (default):
            ->group('header')
            
            // display below table:
            ->group('footer')
            
            // display in modal:
            ->group('modal')
            
            // display in the aside area:
            ->group('aside')
            
            // you may set a custom view:
            ->view('custom/crud/filter'),
        
        // Or:
        Filter\PaginationItemsPerPage::new(
            // Specify the default items to show per page:
            show: 50, // default 100
        ),
        
        // required filter, otherwise the above filter is not displayed at all.
        Filter\Pagination::new(),
    ];
}

Radios Filter

Adds multiple HTML input elements of the type radio filtering the selected value.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Radios::new(name: 'status', field: 'status')
            // specify the options using an array:
            ->options(['_none' => 'None', 'pending' => 'Pending', 'paid' => 'Paid'])
            // you may set a value to '_none' which skips filtering if selected!
            
            // or using a closure (parameters are resolved by autowiring):
            ->options(fn(StatusRepository $repo): array => $repo->findAll()),
            
            // you may set the default selected value:
            ->selected('_none')
            
            // you may add attributes applied to all input elements:
            ->attributes(['data-foo' => 'foo'])
            
            // you may change the comparison:
            ->comparison('=') // = (default)
            // '=', '!=', '>', '<', '>=', '<=', '<>', '<=>', 'like', 'not like', 'contains'
            
            // hide on default:
            ->open(false)
            
            // display above table (default):
            ->group('header')
            
            // display below table:
            ->group('footer')
            
            // display in modal:
            ->group('modal')
            
            // display in the aside area:
            ->group('aside')
            
            // display in table at the field:
            ->group('field')
            
            // you may set a label:
            ->label('Status')
            
            // you may set a description:
            ->description('Lorem ipsum')
            
            // you may set a custom view:
            ->view('custom/crud/filter'),
        
        // you may use dot notation for the name and
        // use -> for the field (JSON) if your repository supports it:
        Filter\Radios::new(name: 'options.color', field: 'options->color')
            ->options(['blue' => 'Blue', 'red' => 'Red'])
            ->comparison('contains'),
    ];
}

Example using the after method

You may use the after method to define the filters where parameters if you are not define a field, allowing multiple selection or for custom filtering.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Radios::new(name: 'colors') // no field defined!
            // specify the options using an array:
            ->options(['1' => 'Foo Category', '3' => 'Bar Category'])
            
            // you may use the after method to set the filters where parameters:
            ->after(function(Filter\Radios $filter, FiltersInterface $filters, ProductToCategoryRepository $repo): void {
                if (empty($filter->getSelected())) {
                    return;
                }
                
                $ids = $repo->findProductIdsForCategoryId(
                    categoryId: $filter->getSelected(),
                    
                    // you may get the limit from the filters:
                    limit: $filters->getLimitParameter()[0] ?? 100,
                );

                $filter->setWhereParameters(['id' => ['in' => $ids]]);
            }),
    ];
}

Select Filter

Adds an HTML select element with options to filter the field if specified.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Select::new(name: 'colors', field: 'color')
            // specify the options using an array:
            ->options(['blue' => 'Blue', 'red' => 'Red'])
            
            // or using a closure (parameters are resolved by autowiring):
            ->options(fn(ProductRepository $repo): array => $repo->findAllColors()),
            
            // you may set the default selected value(s):
            ->selected('blue')
            ->selected(['blue', 'red']) // if multiple
            
            // you may add attributes for the select element:
            ->attributes(['size' => '3', 'multiple'])
            
            // you may change the comparison:
            ->comparison('like') // = (default)
            // '=', '!=', '>', '<', '>=', '<=', '<>', '<=>', 'like', 'not like', 'contains'
            
            // hide on default:
            ->open(false)
            
            // display above table (default):
            ->group('header')
            
            // display below table:
            ->group('footer')
            
            // display in modal:
            ->group('modal')
            
            // display in the aside area:
            ->group('aside')
            
            // display in table at the field:
            ->group('field')
            
            // you may set a label:
            ->label('Colors')
            
            // you may set a description:
            ->description('Lorem ipsum')
            
            // you may set a custom view:
            ->view('custom/crud/filter'),
        
        // you may use dot notation for the name and
        // use -> for the field (JSON) if your repository supports it:
        Filter\Select::new(name: 'options.color', field: 'options->color')
            ->options(['blue' => 'Blue', 'red' => 'Red'])
            ->comparison('contains'),
    ];
}

Example using the after method

You may use the after method to define the filters where parameters if you are not define a field, allowing multiple selection or for custom filtering.

use Tobento\App\Crud\Action\ActionInterface;
use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Select::new(name: 'colors') // no field defined!
            // specify the options using an array:
            ->options(['1' => 'Foo Category', '3' => 'Bar Category'])
            
            // you may add attributes for the select element:
            //->attributes(['size' => '3', 'multiple'])
            
            // you may use the after method to set the filters where parameters:
            ->after(function(Filter\Select $filter, FiltersInterface $filters, ProductToCategoryRepository $repo): void {
                if (!is_string($filter->getSelected())) {
                    return;
                }
                
                // $filter->getSelected()
                // will return an array if multiple or null if none selected
                
                $ids = $repo->findProductIdsForCategoryId(
                    categoryId: $filter->getSelected(),
                    
                    // you may get the limit from the filters:
                    limit: $filters->getLimitParameter()[0] ?? 100,
                );

                $filter->setWhereParameters(['id' => ['in' => $ids]]);
            }),
    ];
}

Filter Groups

Available filter groups (if the filter supports it)

  • header display above table
  • footer display below table
  • modal display in modal
  • aside display in the aside area
  • field display in table at the field if exists

Example

use Tobento\App\Crud\Filter\FiltersInterface;
use Tobento\App\Crud\Filter;
use Tobento\App\Crud\Action\ActionInterface;

protected function configureFilters(ActionInterface $action): iterable|FiltersInterface
{
    return [
        Filter\Input::new(name: 'foo', field: 'title')
        
            // display above table (default):
            ->group('header')
            
            // display below table:
            ->group('footer')
            
            // display in table at the field:
            ->group('field'),
    ];
}

Filter Limitations

As the CRUD controller uses the Repository Interface and filtering is done using the findAll method you are not able to make complex queries by filters.

$entities = $repository->findAll(
    where: $filters->getWhereParameters(),
    orderBy: $filters->getOrderByParameters(),
    limit: $filters->getLimitParameter(),
);

Security

Keep in mind that the repository is responsibilty to protect against any SQL injections for instance! If your are using the Repository Storage you will be save.

Testing

You may test your crud controllers by using the provided test classes. Just make sure you have installed the App Testing bundle.

Crud Controller Testing

To test your crud controller extend the AbstractCrudTestCase class. Next, use createApp method to create the test app as usual and define your crud controller using the getCrudController method. Finally, write your tests:

use Tobento\App\Crud\Testing\AbstractCrudTestCase;
use Tobento\App\AppInterface;

final class ProductCrudControllerTest extends AbstractCrudTestCase
{
    public function createApp(): AppInterface
    {
        return require __DIR__.'/../app/app.php';
        
        // or creating a tmp app:
        $app = $this->createTmpApp(rootDir: __DIR__.'/..');
        $app->boot(\Tobento\App\Crud\Boot\Crud::class);
        // boot your product crud boot which routes your crud controller:
        $app->boot(ProductCrudBoot::class);
        return $app;
    }
    
    protected function getCrudController(): string
    {
        return ProductCrudController::class;
    }
    
    public function testIndexAction()
    {
        $http = $this->fakeHttp();
        $http->request(method: 'GET', uri: 'products');
        
        $http->response()
            ->assertStatus(200)
            ->assertCrudIndexEntityCount(5);
    }
}

Seeding

If you are using Storage Repositories with defined columns, you may use getSeedFactory method to seed entities automatically. Make sure you use a Reset Database Strategy.

use Tobento\App\Crud\Testing\AbstractCrudTestCase;

final class ProductCrudControllerTest extends AbstractCrudTestCase
{
    use \Tobento\App\Testing\Database\RefreshDatabases;
    
    // ...
    
    public function testIndexPageMultipleEntitiesAreDisplayed()
    {
        $http = $this->fakeHttp();
        $http->request(method: 'GET', uri: 'products');
        
        // seed 5 products:
        $this->getSeedFactory()->times(5)->create();
        
        // seed 3 inactive products:
        $this->getSeedFactory(['active' => false])->times(3)->create();
        
        $http->response()
            ->assertStatus(200)
            ->assertCrudIndexEntityCount(8);
    }
}

For any other repositories checkout the App Seeding - Repository documentation as to create a seed factory or use the getSeedDefinition method to define a definition for your repository seed factory:

use Tobento\App\Crud\Testing\AbstractCrudTestCase;
use Tobento\Service\Seeder\SeedInterface;
use Tobento\Service\Seeder\Lorem;

final class ProductCrudControllerTest extends AbstractCrudTestCase
{
    use \Tobento\App\Testing\Database\RefreshDatabases;
    
    // ...

    protected function getSeedDefinition(): null|\Closure
    {
        return function (SeedInterface $seed): array {
            return [
                'sku' => Lorem::word(number: 1),
                'desc' => Lorem::sentence(number: 2),
            ];
        };
    }
    
    public function testIndexPageMultipleEntitiesAreDisplayed()
    {
        $http = $this->fakeHttp();
        $http->request(method: 'GET', uri: 'products');
        
        // seed 5 products:
        $this->getSeedFactory()->times(5)->create();
        
        $http->response()
            ->assertStatus(200)
            ->assertCrudIndexEntityCount(5);
    }
}

Uri Generation

You may use the following methods to generate request uris for the actions:

public function testAnyPage()
{
    $http = $this->fakeHttp();
    
    // index action uri:
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    // equal to uri: 'products'
    
    // index action uri with locale:
    $http->request(method: 'GET', uri: $this->generateIndexUri(locale: 'de'));
    // equal to uri: 'de/products'
    
    // bulk action uri:
    $http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-edit'));
    // equal to uri: 'products/bulk/bulk-edit'
    
    // bulk action uri with locale:
    $http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-edit', locale: 'de'));
    // equal to uri: 'de/products/bulk/bulk-edit'    
    
    // create action uri:
    $http->request(method: 'GET', uri: $this->generateCreateUri());
    // equal to uri: 'products/create'
    
    // create action uri with locale:
    $http->request(method: 'GET', uri: $this->generateCreateUri(locale: 'de'));
    // equal to uri: 'de/products/create'
    
    // store action uri:
    $http->request(method: 'POST', uri: $this->generateStoreUri());
    // equal to uri: 'products'
    
    // store action uri with locale:
    $http->request(method: 'POST', uri: $this->generateStoreUri(locale: 'de'));
    // equal to uri: 'de/products'
    
    // edit action uri:
    $http->request(method: 'GET', uri: $this->generateEditUri(id: 2));
    // equal to uri: 'products/2/edit'
    
    // edit action uri with locale:
    $http->request(method: 'GET', uri: $this->generateEditUri(id: 2, locale: 'de'));
    // equal to uri: 'de/products/2/edit'
    
    // update action uri:
    $http->request(method: 'PUT|PATCH', uri: $this->generateUpdateUri(id: 2));
    // equal to uri: 'products/2'
    
    // update action uri with locale:
    $http->request(method: 'PUT|PATCH', uri: $this->generateUpdateUri(id: 2, locale: 'de'));
    // equal to uri: 'de/products/2'
    
    // copy action uri:
    $http->request(method: 'GET', uri: $this->generateCopyUri(id: 2));
    // equal to uri: 'products/2/copy'
    
    // copy action uri with locale:
    $http->request(method: 'GET', uri: $this->generateCopyUri(id: 2, locale: 'de'));
    // equal to uri: 'de/products/2/copy'
    
    // delete action uri:
    $http->request(method: 'DELETE', uri: $this->generateDeleteUri(id: 2));
    // equal to uri: 'products/2'
    
    // delete action uri with locale:
    $http->request(method: 'DELETE', uri: $this->generateDeleteUri(id: 2, locale: 'de'));
    // equal to uri: 'de/products/2'
    
    // show action uri:
    $http->request(method: 'GET', uri: $this->generateShowUri(id: 2));
    // equal to uri: 'products/2'
    
    // show action uri with locale:
    $http->request(method: 'GET', uri: $this->generateShowUri(id: 2, locale: 'de'));
    // equal to uri: 'de/products/2'
}

Asserts

The following asserts are avaliable using the default views.

Index Action Asserts

assertCrudIndexEntityCount

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    
    $this->getSeedFactory()->times(3)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(3)
        
        // you may specify a custom error message:
        ->assertCrudIndexEntityCount(3, 'Custom message');
}

assertCrudIndexEntityExists

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    
    $this->getSeedFactory()->times(3)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexEntityExists(entityId: 2)
        
        // you may specify buttons the entity should have or not:
        ->assertCrudIndexEntityExists(entityId: 2, withButtons: ['edit', 'delete'], withoutButtons: ['show'])
        
        // you may specify a custom error message when entity does not exists:
        ->assertCrudIndexEntityExists(entityId: 2, message: 'Custom message');
}

assertCrudIndexEntityMissing

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    
    $this->getSeedFactory()->times(1)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexEntityMissing(entityId: 2)
        
        // you may specify a custom error message:
        ->assertCrudIndexEntityMissing(entityId: 2, message: 'Custom message');
}

assertCrudIndexHeaderColumnsExists

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    
    $this->getSeedFactory()->times(1)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexHeaderColumnsExists(columns: ['username', 'actions'])
        
        // you may specify a custom error message:
        ->assertCrudIndexHeaderColumnsExists(columns: ['username'], message: 'Custom message');
}

assertCrudIndexHeaderColumnsMissing

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    
    $this->getSeedFactory()->times(1)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexHeaderColumnsMissing(columns: ['username', 'actions'])
        
        // you may specify a custom error message:
        ->assertCrudIndexHeaderColumnsMissing(columns: ['username'], message: 'Custom message');
}

assertCrudIndexFiltersExists

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    
    $this->getSeedFactory()->times(1)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexFiltersExists(filters: ['columns'], group: 'header')
        
        // you may specify a custom error message:
        ->assertCrudIndexFiltersExists(filters: ['pagination_items'], group: 'footer', message: 'Custom message')
        
        // use the "field" group and field name to check entity field filters:
        ->assertCrudIndexFiltersExists(filters: ['username'], group: 'field');
}

assertCrudIndexFiltersMissing

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    
    $this->getSeedFactory()->times(1)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexFiltersMissing(filters: ['columns'], group: 'header')
        
        // you may specify a custom error message:
        ->assertCrudIndexFiltersMissing(filters: ['pagination_items'], group: 'footer', message: 'Custom message')
        
        // use the "field" group and field name to check entity field filters:
        ->assertCrudIndexFiltersMissing(filters: ['username'], group: 'field');
}

assertCrudIndexButtonsExists

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    
    $this->getSeedFactory()->times(1)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexButtonsExists(buttons: ['create'], group: 'global')
        
        // you may specify a custom error message:
        ->assertCrudIndexButtonsExists(buttons: ['create'], group: 'global', message: 'Custom message');
}

assertCrudIndexButtonsMissing

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    
    $this->getSeedFactory()->times(1)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexButtonsMissing(buttons: ['create'], group: 'global')
        
        // you may specify a custom error message:
        ->assertCrudIndexButtonsMissing(buttons: ['create'], group: 'global', message: 'Custom message');
}

assertCrudIndexBulkActionsExists

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    
    $this->getSeedFactory()->times(1)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexBulkActionsExists(actions: ['edit-status'])
        
        // you may specify a custom error message:
        ->assertCrudIndexBulkActionsExists(actions: ['edit-status'], message: 'Custom message');
}

assertCrudIndexBulkActionsMissing

public function testIndexAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateIndexUri());
    
    $this->getSeedFactory()->times(1)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexBulkActionsMissing(actions: ['edit-status'])
        
        // you may specify a custom error message:
        ->assertCrudIndexBulkActionsMissing(actions: ['edit-status'], message: 'Custom message');
}
Form Asserts

You may use the form asserts to test your crud controller create, edit and copy actions.

assertCrudFormFieldExists

public function testEditAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateEditUri(id: 1));
    // or create uri:
    //$http->request(method: 'GET', uri: $this->generateCreateUri());
    
    $this->getSeedFactory()->times(2)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudFormFieldExists(field: 'username')
        
        // Or with more options:
        ->assertCrudFormFieldExists(
            field: 'username',
            
            // you may specify the label:
            label: 'Username',
            
            // you may specify the required text:
            requiredText: 'Required because ...',
            
            // you may specify the optional text:
            optionalText: 'optional',
            
            // you may specify the info text:
            infoText: 'Some info text',
            
            // you may specify the error text (validation):
            errorText: 'The title.en is required.',
            // first locale if translatable!
            
            // you may specify if it is translatable field or not:
            translatable: false,
            
            // you may specify a custom error message:
            message: 'Custom message',
        );
}

assertCrudFormFieldMissing

public function testEditAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'GET', uri: $this->generateEditUri(id: 1));
    // or create uri:
    //$http->request(method: 'GET', uri: $this->generateCreateUri());
    
    $this->getSeedFactory()->times(2)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudFormFieldMissing(field: 'username')
        
        // you may specify a custom error message:
        ->assertCrudFormFieldMissing(field: 'username', message: 'Custom message');
}
Asserts Selectors

If you customize the view files, make sure you have the following HTML attributes defined, otherwise you may write custom asserts to fit your views!

Index Action Asserts

  • [data-entity-id="ID"] on each entity table rows.
  • [data-button="name"] will be rendered by button automatically.
  • [data-header-col="name"] on each table header columns.
  • [data-filters="group_name"] on each filters group.
  • [data-filter="name"] on each filters within a group.
  • [data-bulk-action="name"] on each bulk actions.

Form Asserts

  • [data-field="name"] on each fields.
  • [data-field="name"] label on each fields for the label, required and optional text.
  • [data-field="name"] p on each fields for the info text.
  • [data-field="name"] .error on each fields for the error text (validation).
  • [data-translatable] on each translatable fields.
Example Tests

Index Filter Action

public function testFilterAction()
{
    $http = $this->fakeHttp();
    $http->request(
        method: 'GET',
        uri: $this->generateIndexUri(),
        query: ['filter' => ['field' => ['type' => 'business']]],
    );

    $this->getSeedFactory(['type' => 'private'])->times(1)->create();
    $this->getSeedFactory(['type' => 'business'])->times(2)->create();
    
    $http->response()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(2);
}

Store Action

public function testStoreAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'POST', uri: $this->generateStoreUri())->body([
        'email' => 'tom@example.com',
        'smartphone' => '555', // is required
    ]);

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateIndexUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(1);

    $this->assertSame(1, $this->getCrudRepository()->count());
}

Store Action With Validation Errors

public function testStoreActionWithValidationErrors()
{
    $http = $this->fakeHttp();
    $http->previousUri($this->generateCreateUri());
    $http->request(method: 'POST', uri: $this->generateStoreUri())->body([
        'email' => 'tom@example.com',
        //'smartphone' => '555', // is required
    ]);

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateCreateUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudFormFieldExists(field: 'smartphone', errorText: 'The smartphone is required.');

    $this->assertSame(0, $this->getCrudRepository()->count());
}

Update Action

public function testUpdateAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'PATCH', uri: $this->generateUpdateUri(id: 1))->body([
        'smartphone' => '555',
    ]);

    $this->getSeedFactory()->times(2)->create();

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateIndexUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(2);

    $this->assertSame('555', $this->getCrudRepository()->findById(1)->smartphone());
}

Update Action With Validation Errors

public function testUpdateActionWithValidationErrors()
{
    $http = $this->fakeHttp();
    $http->previousUri($this->generateEditUri(id: 1));
    $http->request(method: 'PATCH', uri: $this->generateUpdateUri(id: 1))->body([
        'smartphone' => ['invalid'],
    ]);

    $this->getSeedFactory()->times(2)->create();

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateEditUri(id: 1));

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudFormFieldExists(field: 'smartphone', errorText: 'The smartphone must be a string.');
}

Delete Action

public function testDeleteAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'DELETE', uri: $this->generateDeleteUri(id: 1));

    $this->getSeedFactory()->times(2)->create();

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateIndexUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(1);

    $this->assertSame(1, $this->getCrudRepository()->count());
}

Bulk Edit Action

public function testBulkEditAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-edit'))->body([
        'ids' => [2, 3],
        'smartphone' => '555',
    ]);

    $this->getSeedFactory()->times(5)->create();

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateIndexUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(5);

    $this->assertNotSame('555', $this->getCrudRepository()->findById(1)->smartphone());
    $this->assertSame('555', $this->getCrudRepository()->findById(2)->smartphone());
    $this->assertSame('555', $this->getCrudRepository()->findById(3)->smartphone());
}

Bulk Edit Action With Validation Errors

public function testBulkEditAction()
{
    $http = $this->fakeHttp();
    $http->previousUri($this->generateIndexUri());
    $http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-edit'))->body([
        'ids' => [2, 3],
        'smartphone' => ['555'],
    ]);

    $this->getSeedFactory()->times(5)->create();

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateIndexUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(5)
        ->assertCrudFormFieldExists(field: 'smartphone', errorText: 'The smartphone must be a string.');
}

Bulk Delete Action

public function testBulkDeleteAction()
{
    $http = $this->fakeHttp();
    $http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-delete'))->body([
        'ids' => [2, 3],
    ]);

    $this->getSeedFactory()->times(5)->create();

    $http->response()
        ->assertStatus(302)
        ->assertLocation($this->generateIndexUri());

    $http->followRedirects()
        ->assertStatus(200)
        ->assertCrudIndexEntityCount(3);

    $this->assertNotNull($this->getCrudRepository()->findById(1));
    $this->assertNull($this->getCrudRepository()->findById(2));
    $this->assertNull($this->getCrudRepository()->findById(3));
}

Credits