tobento / app-crud
App CRUD.
Requires
- php: >=8.0
- psr/container: ^2.0
- tobento/app-html-sanitizer: ^1.0
- tobento/app-http: ^1.0.5
- tobento/app-language: ^1.0
- tobento/app-media: ^1.0
- tobento/app-message: ^1.0
- tobento/app-slugging: ^1.0
- tobento/app-translation: ^1.0
- tobento/app-validation: ^1.0
- tobento/app-view: ^1.0
- tobento/css-modal: ^1.0
- tobento/js-editor: ^1.0
- tobento/js-notifier: ^1.0
- tobento/service-autowire: ^1.0
- tobento/service-collection: ^1.0
- tobento/service-iterable: ^1.0
- tobento/service-menu: ^1.0
- tobento/service-pagination: ^1.0
- tobento/service-repository: ^1.0.1
- tobento/service-support: ^1.0.1
- tobento/service-tag: ^1.0.5
- tobento/service-uri: ^1.0
- tobento/service-validation: ^1.0.1
Requires (Dev)
- league/mime-type-detection: ^1.15
- phpunit/phpunit: ^9.5
- tobento/app-database: ^1.0
- tobento/app-event: ^1.0
- tobento/app-file-storage: ^1.0
- tobento/app-logging: ^1.0
- tobento/app-profiler: ^1.0
- tobento/app-testing: ^1.0.5
- tobento/app-user: ^1.0
- tobento/service-acl: ^1.0
- tobento/service-filesystem: ^1.0.5
- tobento/service-imager: ^1.0
- tobento/service-repository-storage: ^1.0
- tobento/service-seeder: ^1.0
- tobento/service-storage: ^1.0
- vimeo/psalm: ^4.0
README
A simple app CRUD.
Table of Contents
- Getting Started
- Documentation
- App
- Crud Boot
- Crud Crontroller
- Fields
- Actions
- Filters
- Security
- Testing
- Credits
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 tablefooter
display below tablemodal
display in modalaside
display in the aside areafield
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)); }