Download the PHP package tobento/app-crud without Composer
On this page you can find all versions of the php package tobento/app-crud. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Informations about the package app-crud
App Crud
A simple app CRUD.
Table of Contents
- Getting Started
- Requirements
- Documentation
- App
- Crud Boot
- Crud Controller
- Create Controller
- Entity Mapping
- Entity Actions
- Configure Fields
- Configure Actions
- Configure Filters
- Route Controller
- Route Permissions
- Create Controller
- Fields
- Build in Fields
- Checkboxes Field
- File Field
- Files Field
- FileSource Field
- Html Field
- Items Field
- Options Field
- PrimaryId Field
- Radios Field
- Select Field
- Slug Field
- Text Field
- Textarea Field
- TextEditor Field
- Value Field
- Different Fields Per Action
- Validate Field
- Translatable Field
- Unstorable Field
- Readonly And Disabled Field
- Field Grouping
- Field Texts
- Field Resolving
- Custom Field Action
- Build in Fields
- Actions
- Build in Actions
- Index Action
- Create Action
- Store Action
- Edit Action
- Update Action
- Copy Action
- Show Action
- Show JSON Action
- Delete Action
- Bulk Delete Action
- Bulk Edit Action
- Buttons
- Creating Buttons
- Adding Buttons
- Removing Buttons
- Reorder Buttons
- Modify Buttons
- Grouping Buttons
- Display Buttons Conditionally
- Confirming Button Action
- AJAX Button Action
- Set Buttons
- Custom Action
- Build in Actions
- Filters
- Build in Filters
- Checkboxes Filter
- Clear Button Filter
- Columns Filter
- Datalist Filter
- Editable Columns Filter
- Fields Filter
- Fields Sort Order Filter
- Group Filter
- Input Filter
- Locale Filter
- Menu Filter
- Modal Button Filter
- Pagination Filter
- Pagination Items Per Page Filter
- Radios Filter
- Select Filter
- Filter Groups
- Filter Limitations
- Build in Filters
- Security
- Testing
- Crud Controller Testing
- Seeding
- Uri Generation
- Asserts
- Index Action Asserts
- Form Asserts
- Example Tests
- Crud Controller Testing
- Credits
Getting Started
Add the latest version of the app crud project running this command.
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
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 and specify a resource name with the 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.
Entity Mapping
You may overwrite the method to map your repository entity object to the CRUD entity.
Entity Id Name
You may change the entity id name used as the id name of the entity.
Entity Actions
You may overwrite the following methods to customize the read and write repository actions.
findEntities
storeEntity
updateEntity
deleteEntity
Configure Fields
Use the method to configure any fields using the Build in Fields or creating your custom fields.
Configure Actions
Use the method to configure any actions using the Custom Actions.
Configure Filters
Use the method to configure any filters using the Build in Filters or creating your custom filters.
Route Controller
After creating the configured actions:
Routing via Crud Boot
Manually Routing
You may define the routes manually, if you need even more control:
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.
Fields
Build in Fields
Checkboxes Field
The checkboxes field displays a list of checkboxes using the specified options.
Options
Use the method to define the options to choose:
Empty Option
Use the method to change the empty option value needed when no option is selected:
Selected Options
You may use the method to define the selected value(s):
Attributes
You may set HTML attributes assigned to each input element using the method:
Validation
Data are being validated using the defined options. You may define additional rules though:
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.
Fields
Translatable
Use the method if you want to have translatable files.
FileSource Field
Use the method to customize the FileSource Field.
Storing filenames
You may use the method to store the filenames to a certain file field.
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.
Fields
File Field
Use the method to customize the File Field.
Number Of Files
Use the method to set the and/or allowed files.
FileSource Field
The file source field enables you to upload a single file, storing the file path such as . If you want to store other data such as the storage name or an alternative text for images, consider using the File Field.
File Storage
Use the method to change the storage name where to store the file. By default the storage is used. Make sure your defined storage is outside the webroot such as the default configured uploads storage.
Make sure the storage is configured in the file.
Check out the App File Storage to learn more about file storages in general.
Folder
Use the method to define a folder path.
Validation
Use the method to define the allowed file extensions. By default, only , , and are allowed.
If you need more control validating files, use the method:
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 method:
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.
Image Editor
You may define an image editor template using the 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.
You will need to define an event listener in the file to deleted generated images once an image is edited:
Picture Editor
You may define a picture using the 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.
Display Messages
Use the method to define the messages to be displayed.
Html Field
The html field may be used if you want to set HTML content.
Content
Use the method to set the HTML. Make sure any html you set is being properly escaped if needed:
In addition, you may pass a callable being resolved by autowiring:
Defaults
Items Field
The items field displays a collection of items allowing you to add, edit and delete items.
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.
Repository (required)
Use the method to define the repository implementing the Repository Interface:
toOption (required)
Use the method to create options from the repository items:
Or using option methods:
Selected Options
You may use the method to define the selected value(s):
Placeholder
Use the method to define a placeholder text for the serach input element:
Empty Option
Use the method to change the empty option value needed when no option is selected:
Validation
Data are being validated using the repository to query the options. You may define additional rules though:
You may check out the Validate Field section for more detail.
PrimaryId Field
The primary id field will not be displayed on the , and view. In additon, it cannot be edited at all.
Radios Field
The radios field displays a list of radios using the specified options.
Options
Use the method to define the options to choose:
Selected Option
You may use the method to define the selected value:
Attributes
You may set HTML attributes assigned to each radio element using the method:
Display Inline
Use the method for the radio options to be displayed inline:
Validation
Data are being validated using the defined options. You may define additional rules though:
You may check out the Validate Field section for more detail.
Select Field
The select field will be rendered as a HTML element.
Options
Use the method to define the options to select:
You may define options as groups:
Empty Option
Use the method to define an empty option which will not be saved when selected:
Selected Options
You may use the method to define the selected value(s):
Attributes
You may set additional HTML select attributes using the method:
Validation
Data are being validated using the defined options. You may define additional rules though:
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 is usually something like .
fromField
You may define a field to generate the slug from when no input from the slug field is provided.
Slugifier
You may use the slugifier method to define a custom slugifier. By default, the slugifier named is used but as the slugifier not exists, the from the will be used.
You can define a custom slugifier in the 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 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 method if you want to implement a custom strategy:
Attributes
You may set additional HTML input attributes using the method:
Example With Readonly
Text Field
The text field will be rendered as an element of the type as default. Use this field for any other input type as well.
Type
You may set another HTML input type as the default type using the method:
Value
You may set a value using the method.
Default Value
You may set a default value using the method:
Attributes
You may set additional HTML input attributes using the method:
Textarea Field
The textarea field will be rendered as an element.
Attributes
You may set additional HTML textarea attributes using the method:
TextEditor Field
This field creates a JavaScript-based WYSIWYG editor using the JS Editor.
editorConfig
This method allows you to pass a PHP array of the configuration options to set passed to the js editor attribute:
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 or method on the column to clean the value:
Sure, you may sanitize the html depending on the context such as in your view file:
Value Field
The value field may be used if you want to set the value directly on the field. The value will never be set by any user input.
Value
Use the method to set the value for the field:
Defaults
Different Fields Per Action
Option 1
Using the fields , and methods:
Option 2
Using the action name:
Option 3
Using the action method:
Validate Field
The field validation is using the Validation Service to validate the field.
applyValidationAttributes
By default, HTML validation attributes gets created based on your rules and applied for the fields select. You may disable it using the method if you want to add validation attributes by yourself.
Translatable Field
If you use a Repository Storage With Columns, make sure you use a translatable column:
Supported Locales
All locales are supported as defined in the Language Config.
Unstorable Field
When you set a field as unstorable using the method, the field gets not saved.
Readonly And Disabled Field
You may set a field as readonly or disabled which will be automatically an unstorable field.
Field Grouping
Use the method to group fields:
Field Texts
By default, the required text will be set automatically if you have any required validation rules set. But you may specify a custom text:
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:
You may specify an info text:
Field Resolving
You may use the method to set any field parameters from a resolved value or just for specific actions.
Custom Field Action
You may customize exisiting field actions or add custom actions using the method:
Actions
Build in Actions
Index Action
Configure Buttons
The default buttons are named , , , , and .
Check out the Buttons section for more information.
Create Action
Configure Buttons
The default buttons are named , , 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:
- Checkboxes Field
- Radios Field
- Select Field
- Text Field
- Textarea Field
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:
- Checkboxes Field
- Radios Field
- Select Field
- Text Field
Fields Filter
The fields filter displays an select 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(),
];
}
If you want a custom filter for the field just do not display the filter using the except
method and add your custom filter with the group 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\Fields::new()
->fields($action->fields())
->except('sku')
->toFilters(),
// custom sku filter:
Filter\Input::new(name: 'sku', field: 'sku')
->group('field'),
];
}
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 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 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' => '[email protected]',
'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' => '[email protected]',
//'smartphone' => '555', // is required
]);
$http->response()
->assertStatus(302)
->assertLocation($this->generateCreateUri());
$http->followRedirects()
->assertStatus(200)
->assertCrudFormFieldExists(field: 'smartphone', errorText: 'The smartphone is required.');
$this->assertSame(0, $this->getCrudRepository()->count());
}
Update Action
public function testUpdateAction()
{
$http = $this->fakeHttp();
$http->request(method: 'PATCH', uri: $this->generateUpdateUri(id: 1))->body([
'smartphone' => '555',
]);
$this->getSeedFactory()->times(2)->create();
$http->response()
->assertStatus(302)
->assertLocation($this->generateIndexUri());
$http->followRedirects()
->assertStatus(200)
->assertCrudIndexEntityCount(2);
$this->assertSame('555', $this->getCrudRepository()->findById(1)->smartphone());
}
Update Action With Validation Errors
public function testUpdateActionWithValidationErrors()
{
$http = $this->fakeHttp();
$http->previousUri($this->generateEditUri(id: 1));
$http->request(method: 'PATCH', uri: $this->generateUpdateUri(id: 1))->body([
'smartphone' => ['invalid'],
]);
$this->getSeedFactory()->times(2)->create();
$http->response()
->assertStatus(302)
->assertLocation($this->generateEditUri(id: 1));
$http->followRedirects()
->assertStatus(200)
->assertCrudFormFieldExists(field: 'smartphone', errorText: 'The smartphone must be a string.');
}
Delete Action
public function testDeleteAction()
{
$http = $this->fakeHttp();
$http->request(method: 'DELETE', uri: $this->generateDeleteUri(id: 1));
$this->getSeedFactory()->times(2)->create();
$http->response()
->assertStatus(302)
->assertLocation($this->generateIndexUri());
$http->followRedirects()
->assertStatus(200)
->assertCrudIndexEntityCount(1);
$this->assertSame(1, $this->getCrudRepository()->count());
}
Bulk Edit Action
public function testBulkEditAction()
{
$http = $this->fakeHttp();
$http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-edit'))->body([
'ids' => [2, 3],
'smartphone' => '555',
]);
$this->getSeedFactory()->times(5)->create();
$http->response()
->assertStatus(302)
->assertLocation($this->generateIndexUri());
$http->followRedirects()
->assertStatus(200)
->assertCrudIndexEntityCount(5);
$this->assertNotSame('555', $this->getCrudRepository()->findById(1)->smartphone());
$this->assertSame('555', $this->getCrudRepository()->findById(2)->smartphone());
$this->assertSame('555', $this->getCrudRepository()->findById(3)->smartphone());
}
Bulk Edit Action With Validation Errors
public function testBulkEditAction()
{
$http = $this->fakeHttp();
$http->previousUri($this->generateIndexUri());
$http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-edit'))->body([
'ids' => [2, 3],
'smartphone' => ['555'],
]);
$this->getSeedFactory()->times(5)->create();
$http->response()
->assertStatus(302)
->assertLocation($this->generateIndexUri());
$http->followRedirects()
->assertStatus(200)
->assertCrudIndexEntityCount(5)
->assertCrudFormFieldExists(field: 'smartphone', errorText: 'The smartphone must be a string.');
}
Bulk Delete Action
public function testBulkDeleteAction()
{
$http = $this->fakeHttp();
$http->request(method: 'POST', uri: $this->generateBulkUri(action: 'bulk-delete'))->body([
'ids' => [2, 3],
]);
$this->getSeedFactory()->times(5)->create();
$http->response()
->assertStatus(302)
->assertLocation($this->generateIndexUri());
$http->followRedirects()
->assertStatus(200)
->assertCrudIndexEntityCount(3);
$this->assertNotNull($this->getCrudRepository()->findById(1));
$this->assertNull($this->getCrudRepository()->findById(2));
$this->assertNull($this->getCrudRepository()->findById(3));
}
Credits
- Tobias Strub
- All Contributors
All versions of app-crud with dependencies
tobento/app-html-sanitizer Version ^1.0
tobento/app-http Version ^1.0.5
tobento/app-media Version ^1.0
tobento/app-message Version ^1.0
tobento/app-language Version ^1.0
tobento/app-slugging Version ^1.0
tobento/app-translation Version ^1.0
tobento/app-validation Version ^1.0
tobento/app-view Version ^1.0
tobento/service-tag Version ^1.0.5
tobento/service-iterable Version ^1.0
tobento/service-collection Version ^1.0
tobento/service-support Version ^1.0.1
tobento/service-autowire Version ^1.0
tobento/service-repository Version ^1.0.1
tobento/service-pagination Version ^1.0
tobento/service-validation Version ^1.0.1
tobento/service-uri Version ^1.0
tobento/service-menu Version ^1.0
tobento/js-editor Version ^1.0
tobento/js-notifier Version ^1.0
tobento/css-modal Version ^1.0
psr/container Version ^2.0