diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..622f93fd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.php] +charset = utf-8 +indent_size = 4 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..60f03b98 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,62 @@ +name: "Run unit tests" + +on: + - push + - pull_request + +env: + COMPOSER_MEMORY_LIMIT: -1 + +jobs: + test: + name: "Lara ${{ matrix.laravel }} PHP ${{ matrix.php }} Unit ${{ matrix.phpunit }}" + runs-on: ubuntu-latest + strategy: + max-parallel: 6 # 12 + fail-fast: false + matrix: + laravel: [10, 11, 12] + php: ['8.2', '8.3', '8.4'] + phpunit: [10, 11] + exclude: + - {laravel: 10, php: '8.4'} + - {laravel: 10, phpunit: 11} + - {laravel: 12, phpunit: 10} + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: exif,json,mbstring,dom + + - name: Get user-level Composer cache + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Setup Composer cache + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ matrix.php }}-${{ matrix.laravel }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + composer-${{ runner.os }}-${{ matrix.php }}-${{ matrix.laravel }}-${{ env.cache-name }}- + composer-${{ runner.os }}-${{ matrix.php }}-${{ matrix.laravel }}- + composer-${{ runner.os }}-${{ matrix.php }}- + composer-${{ runner.os }}- + + - name: Install composer dependencies + run: composer require --no-progress --no-interaction illuminate/database:^${{ matrix.laravel }}.0 illuminate/validation:^${{ matrix.laravel }}.0 phpunit/phpunit:^${{ matrix.phpunit }}.0 + + - name: Run unit tests + run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover + + # - name: Upload to Scrutinizer + # continue-on-error: true + # run: | + # composer global require scrutinizer/ocular + # ~/.composer/vendor/bin/ocular code-coverage:upload --format=php-clover coverage.clover diff --git a/.gitignore b/.gitignore index 984ff523..37dc081e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,11 @@ composer.phar composer.lock .DS_Store .idea +.vscode coverage +*.taskpaper +NOTES.md +/.phpunit.result.cache +/.phpunit.cache/ +/phpunit.xml.bak +/coverage.clover diff --git a/.travis.yml b/.travis.yml index 9eed5381..564d0099 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,21 +1,24 @@ language: php php: - - 5.6 - - 7.0 - 7.1 + - 7.2 + - 7.3 + - 7.4 + +env: + - COMPOSER_MEMORY_LIMIT=-1 before_script: - travis_retry composer self-update - travis_retry composer install --prefer-source --no-interaction script: - - if [ "$TRAVIS_PHP_VERSION" == "hhvm" ]; then vendor/bin/phpunit; fi - - if [ "$TRAVIS_PHP_VERSION" != "hhvm" ]; then vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover; fi + - vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover after_script: - wget https://scrutinizer-ci.com/ocular.phar - - if [ "$TRAVIS_PHP_VERSION" != "hhvm" ]; then php ocular.phar code-coverage:upload --format=php-clover coverage.clover; fi + - php ocular.phar code-coverage:upload --format=php-clover coverage.clover notifications: email: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0799c83d..c562aea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,51 @@ +## 1.20.0 +- Add field rules event [#491](https://github.com/kristijanhusak/laravel-form-builder/pull/491)(Thanks to [@rudiedirkx](https://github.com/rudiedirkx)) +- Escape html with `e()` to respect Htmlable [#473](https://github.com/kristijanhusak/laravel-form-builder/pull/473)(Thanks to [@rudiedirkx](https://github.com/rudiedirkx)) +- Fix `datetime_local` to `datetime-local` field constant [#483](https://github.com/kristijanhusak/laravel-form-builder/pull/483)(Thanks to [@nea](https://github.com/nea)) +- Add missing `entity` field to constants [#484](https://github.com/kristijanhusak/laravel-form-builder/pull/484)(Thanks to [@nea](https://github.com/nea)) +- Fix compatibility with Laravel 5.8 by using EventDispatcher `dispatch` method instead of `fire` +## 1.16.0 +- Add option for form specific config. [#406](https://github.com/kristijanhusak/laravel-form-builder/pull/406) (Thanks to [@beghelli](https://github.com/beghelli)) +- Add class enum that contains all field types [#455](https://github.com/kristijanhusak/laravel-form-builder/pull/455) (Thanks to [@tresa02](https://github.com/tresa02)) +## 1.15.1 +- Fix issue [#441](https://github.com/kristijanhusak/laravel-form-builder/issues/441) +- Fix issue [#442](https://github.com/kristijanhusak/laravel-form-builder/issues/442) +## 1.15.0 +- Add translation template [#399](https://github.com/kristijanhusak/laravel-form-builder/pull/399) (Thanks to [@koenvu](https://github.com/koenvu)) +- Add field error class [#411](https://github.com/kristijanhusak/laravel-form-builder/pull/411) (Thanks to [@n7olkachev](https://github.com/n7olkachev)) +- Allow using different error bag per form [#414](https://github.com/kristijanhusak/laravel-form-builder/pull/414) (Thanks to [@Fellner96](https://github.com/Fellner96)) +- Get PSR-4 namespace from composer [#424](https://github.com/kristijanhusak/laravel-form-builder/pull/424) (Thanks to [@icfr](https://github.com/icfr)) +- Escape static field value [#407](https://github.com/kristijanhusak/laravel-form-builder/pull/407) (Thanks to [@beghelli](https://github.com/beghelli)) +- Fix missing field name for rule closure [#403](https://github.com/kristijanhusak/laravel-form-builder/pull/403) (Thanks to [@yemenifree](https://github.com/yemenifree)) +- Fix checking trueness of empty array in collection type [#412](https://github.com/kristijanhusak/laravel-form-builder/pull/412) (Thanks to [@kiperz](https://github.com/kiperz)) +- Fix parent type not pushing options to children [#356](https://github.com/kristijanhusak/laravel-form-builder/pull/356) (Thanks to [@pimlie](https://github.com/pimlie)) +- Use request as model when validating to properly validate collection types +- Setup named model after attaching model to form +- Fix custom closure interpreted as string when using html5 validation rules [#435](https://github.com/kristijanhusak/laravel-form-builder/pull/435) (Thanks to [@yarbsemaj](https://github.com/yarbsemaj)) +- Fix radio and checkbox help block position [#440](https://github.com/kristijanhusak/laravel-form-builder/pull/440) (Thanks to [@sagarnasit](https://github.com/sagarnasit)) +## 1.14.0 +- Fix php7.2 compatibility +## 1.13.0 +- Add Laravel 5.5 support [#377](https://github.com/kristijanhusak/laravel-form-builder/pull/377) (Thanks to [@wuwx](https://github.com/wuwx)) +- Add field filters [#376](https://github.com/kristijanhusak/laravel-form-builder/pull/376) (Thanks to [@unckleg](https://github.com/unckleg)) +- Add `data_override` closure for choice type fields [#383](https://github.com/kristijanhusak/laravel-form-builder/pull/383) (Thanks to [@yemenifree](https://github.com/yemenifree)) +- Fix adding client validation attributes to non required fields [#379](https://github.com/kristijanhusak/laravel-form-builder/pull/379) (Thanks to [@koichirose](https://github.com/koichirose)) + +## 1.12.1 +- Fix issue #354 + +## 1.12.0 +- Add `createByArray` to Form builder form building forms with simple array - #316 (Thanks to [@saeidraei](https://github.com/saeidraei)) +- Add ability to automatically validate form classes when they are instantiated by adding ValidatesWhenResolved trait - #345 (Thanks to [@mpociot](https://github.com/mpociot)) +- Allow configuring plain form class - #319 (Thanks to [@rudiedirkx](https://github.com/rudiedirkx)) +- Allow creating custom validation rules parser - #345 (Thanks to [@rudiedirkx](https://github.com/rudiedirkx)) +- Use primary key as default property_key for EntityType field - #334 (Thanks to Thanks to [@pimlie](https://github.com/pimlie)) +- Check if custom field already defined on rebuild form - #348 (Thanks to [@alamcordeiro](https://github.com/alamcordeiro)) +- Fix child models not being bound correctly in collection forms - #325 (Thanks to [@njbarrett](https://github.com/njbarrett)) +- Fix passing `choice_options` from view - #336 - (Thanks to Thanks to [@schursin](https://github.com/schursin)) +- Fix ButtonGroupType having wrong template - #344 (Thanks to [@jayjfletcher](https://github.com/jayjfletcher)) +- Fix CollectionType using request's `get()` instead of `input()` method - #346 (Thanks to [@unfalln](https://github.com/unfalln)) + ## 1.10.0 - Add `buttongroup` field type - #298 (Thanks to [@noxify](https://github.com/noxify)) - Allow custom `id` and `for` attributes for a field - #285 diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 00000000..734224fc --- /dev/null +++ b/NOTES.md @@ -0,0 +1,10 @@ +# laravel-form-builder + +#501 Refactor to use Arr class for deprecated array helpers +- array_get +- array_pull +- array_set +- array_forget + +- str_is +- str_contains diff --git a/README.md b/README.md index 5334e523..a1e5aa5d 100644 --- a/README.md +++ b/README.md @@ -12,22 +12,27 @@ Form builder for Laravel 5 inspired by Symfony's form builder. With help of Lara By default it supports Bootstrap 3. ## Laravel 4 -For laravel 4 version check [laravel4-form-builder](https://github.com/kristijanhusak/laravel4-form-builder) +For Laravel 4 version check [laravel4-form-builder](https://github.com/kristijanhusak/laravel4-form-builder). + +## Bootstrap 4 support +To use bootstrap 4 instead of bootstrap 3, install [laravel-form-builder-bs4](https://github.com/ycs77/laravel-form-builder-bs4). ## Upgrade to 1.6 If you upgraded to `>1.6.*` from `1.5.*` or earlier, and having problems with form value binding, rename `default_value` to `value`. -More info in [changelog](https://github.com/kristijanhusak/laravel-form-builder/blob/master/CHANGELOG.md) +More info in [changelog](https://github.com/kristijanhusak/laravel-form-builder/blob/master/CHANGELOG.md). ## Documentation -For detailed documentation refer to [http://kristijanhusak.github.io/laravel-form-builder/](http://kristijanhusak.github.io/laravel-form-builder/). +For detailed documentation refer to https://kristijanhusak.github.io/laravel-form-builder/. ## Changelog -Changelog can be found [here](https://github.com/kristijanhusak/laravel-form-builder/blob/master/CHANGELOG.md) +Changelog can be found [here](https://github.com/kristijanhusak/laravel-form-builder/blob/master/CHANGELOG.md). -###Installation +## Installation -``` +### Using Composer + +```sh composer require kris/laravel-form-builder ``` @@ -41,7 +46,7 @@ Or manually by modifying `composer.json` file: } ``` -run `composer install` +And run `composer install` Then add Service provider to `config/app.php` @@ -62,10 +67,10 @@ And Facade (also in `config/app.php`) ``` -**Notice**: This package will add `laravelcollective/html` package and load aliases (Form, Html) if they do not exist in the IoC container +**Notice**: This package will add `laravelcollective/html` package and load aliases (Form, Html) if they do not exist in the IoC container. -### Quick start +## Quick start Creating form classes is easy. With a simple artisan command: @@ -81,19 +86,20 @@ Form is created in path `app/Forms/SongForm.php` with content: namespace App\Forms; use Kris\LaravelFormBuilder\Form; +use Kris\LaravelFormBuilder\Field; class SongForm extends Form { public function buildForm() { $this - ->add('name', 'text', [ + ->add('name', Field::TEXT, [ 'rules' => 'required|min:5' ]) - ->add('lyrics', 'textarea', [ + ->add('lyrics', Field::TEXTAREA, [ 'rules' => 'max:5000' ]) - ->add('publish', 'checkbox'); + ->add('publish', Field::CHECKBOX); } } ``` @@ -194,22 +200,93 @@ class SongsController extends BaseController { ``` +If you want to store a model after a form submit considerating all fields are model properties: + +```php +create(\App\Forms\SongForm::class); + $form->redirectIfNotValid(); + + SongForm::create($form->getFieldValues()); + + // Do redirecting... + } +``` + +You can only save properties you need: + +```php +create(\App\Forms\SongForm::class); + $form->redirectIfNotValid(); + + $songForm = new SongForm(); + $songForm->fill($request->only(['name', 'artist'])->save(); + // Do redirecting... + } +``` +Or you can update any model after form submit: +```php +getForm($songForm); + $form->redirectIfNotValid(); + + $songForm->update($form->getFieldValues()); + + // Do redirecting... + } +``` Create the routes ```php // app/Http/routes.php Route::get('songs/create', [ - 'uses' => 'SongsController@create', - 'as' => 'song.create' + 'uses' => 'SongsController@create', + 'as' => 'song.create' ]); Route::post('songs', [ - 'uses' => 'SongsController@store', - 'as' => 'song.store' + 'uses' => 'SongsController@store', + 'as' => 'song.store' ]); ``` @@ -245,7 +322,48 @@ Go to `/songs/create`; above code will generate this html: ``` -### Contributing +Or you can generate forms easier by using simple array +```php +createByArray([ + [ + 'name' => 'name', + 'type' => Field::TEXT, + ], + [ + 'name' => 'lyrics', + 'type' => Field::TEXTAREA, + ], + [ + 'name' => 'publish', + 'type' => Field::CHECKBOX + ], + ] + ,[ + 'method' => 'POST', + 'url' => route('song.store') + ]); + + return view('song.create', compact('form')); + } +} +``` + + +## Contributing + Project follows [PSR-2](http://www.php-fig.org/psr/psr-2/) standard and it's covered with PHPUnit tests. Pull requests should include tests and pass [Travis CI](https://travis-ci.org/kristijanhusak/laravel-form-builder) build. diff --git a/composer.json b/composer.json index cc3b2b18..228ed907 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,7 @@ { "name": "kris/laravel-form-builder", "description": "Laravel form builder - symfony like", + "keywords": ["laravel", "form", "builder", "symfony"], "license": "MIT", "authors": [ { @@ -9,31 +10,41 @@ } ], "require": { - "php": ">=5.6.0", - "laravelcollective/html": "5.*", - "illuminate/database": "5.*@dev", - "illuminate/validation": "5.*@dev" + "php": "^8.0", + "rdx/laravelcollective-html": "^6", + "illuminate/database": "^10 || ^11 || ^12", + "illuminate/validation": "^10 || ^11 || ^12" }, "require-dev": { - "phpunit/phpunit": "~5.0", - "orchestra/testbench": "~3.4" + "orchestra/testbench": "^8 || ^9 || ^10", + "phpunit/phpunit": "^10.0 || ^11.0" }, "extra": { "branch-alias": { - "dev-master": "1.6.x-dev" + "dev-master": "1.x-dev" + }, + "laravel": { + "providers": [ + "Kris\\LaravelFormBuilder\\FormBuilderServiceProvider" + ], + "aliases": { + "FormBuilder": "Kris\\LaravelFormBuilder\\Facades\\FormBuilder" + } } }, "autoload": { "psr-0": { "Kris\\LaravelFormBuilder": "src/" }, - "classmap": [ - "tests/FormBuilderTestCase.php" - ], "files": [ "src/helpers.php" ] }, + "autoload-dev": { + "classmap": [ + "tests/" + ] + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/phpstan-extension.neon b/phpstan-extension.neon new file mode 100644 index 00000000..9f989f83 --- /dev/null +++ b/phpstan-extension.neon @@ -0,0 +1,5 @@ +services: + - + class: Kris\LaravelFormBuilder\PhpStan\FormGetFieldExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension diff --git a/phpunit-printer.yml b/phpunit-printer.yml new file mode 100644 index 00000000..29fc6fd3 --- /dev/null +++ b/phpunit-printer.yml @@ -0,0 +1,14 @@ +options: + cd-printer-hide-class: false + cd-printer-simple-output: false + cd-printer-show-config: true + cd-printer-hide-namespace: true + cd-printer-anybar: false + cd-printer-anybar-port: 1738 +markers: + cd-pass: "✔︎ " + cd-fail: "✖ " + cd-error: "⚈ " + cd-skipped: "=> " + cd-incomplete: "∅ " + cd-risky: "⌽ " \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index e320df8f..6ac4b5e8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,30 +1,33 @@ - ./tests/ + ./tests/resources/views/ + ./tests/resources/lang/ + ./tests/Fixtures/ + ./tests/FormBuilderTestCase.php - - - + + ./src/Kris - - ./src/Kris/LaravelFormBuilder/FormBuilderServiceProvider.php - ./src/Kris/LaravelFormBuilder/Facades/FormBuilder.php - ./src/Kris/LaravelFormBuilder/Console/FormMakeCommand.php - ./src/Kris/LaravelFormBuilder/FormBuilderTrait.php - - - + + + ./src/Kris/LaravelFormBuilder/FormBuilderServiceProvider.php + ./src/Kris/LaravelFormBuilder/Facades/FormBuilder.php + ./src/Kris/LaravelFormBuilder/Console/FormMakeCommand.php + ./src/Kris/LaravelFormBuilder/FormBuilderTrait.php + + diff --git a/src/Kris/LaravelFormBuilder/Console/FormMakeCommand.php b/src/Kris/LaravelFormBuilder/Console/FormMakeCommand.php index 3956bb73..06bdce8b 100644 --- a/src/Kris/LaravelFormBuilder/Console/FormMakeCommand.php +++ b/src/Kris/LaravelFormBuilder/Console/FormMakeCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Arr; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; @@ -109,6 +110,11 @@ protected function replaceNamespace(&$stub, $name) if ($path) { $namespace = str_replace('/', '\\', trim($path, '/')); + foreach ($this->getAutoload() as $autoloadNamespace => $autoloadPath) { + if (preg_match('|'.$autoloadPath.'|', $path)) { + $namespace = str_replace([$autoloadPath, '/'], [$autoloadNamespace, '\\'], trim($path, '/')); + } + } } } @@ -127,6 +133,24 @@ protected function getStub() return __DIR__ . '/stubs/form-class-template.stub'; } + /** + * Get psr-4 namespace. + * + * @return array + */ + protected function getAutoload() + { + $composerPath = base_path('/composer.json'); + if (! file_exists($composerPath)) { + return []; + } + $composer = json_decode(file_get_contents( + $composerPath + ), true); + + return Arr::get($composer, 'autoload.psr-4', []); + } + /** * Get the desired class name from the input. * diff --git a/src/Kris/LaravelFormBuilder/Events/AfterCollectingFieldRules.php b/src/Kris/LaravelFormBuilder/Events/AfterCollectingFieldRules.php new file mode 100644 index 00000000..f4de5d6a --- /dev/null +++ b/src/Kris/LaravelFormBuilder/Events/AfterCollectingFieldRules.php @@ -0,0 +1,54 @@ +field = $field; + $this->rules = $rules; + } + + /** + * Return the event's field. + * + * @return FormField + */ + public function getField() { + return $this->field; + } + + /** + * Return the event's field's rules. + * + * @return Rules + */ + public function getRules() { + return $this->rules; + } +} diff --git a/src/Kris/LaravelFormBuilder/Events/AfterFormCreation.php b/src/Kris/LaravelFormBuilder/Events/AfterFormCreation.php index 6c2d30c3..0312e2bc 100644 --- a/src/Kris/LaravelFormBuilder/Events/AfterFormCreation.php +++ b/src/Kris/LaravelFormBuilder/Events/AfterFormCreation.php @@ -16,7 +16,7 @@ class AfterFormCreation /** * Create a new after form creation instance. * - * @param Form $form + * @param Form $form * @return void */ public function __construct(Form $form) { diff --git a/src/Kris/LaravelFormBuilder/Field.php b/src/Kris/LaravelFormBuilder/Field.php new file mode 100644 index 00000000..bc93a644 --- /dev/null +++ b/src/Kris/LaravelFormBuilder/Field.php @@ -0,0 +1,40 @@ + ['class' => null, 'id' => $this->getName()], - 'value' => 1, + 'value' => self::DEFAULT_VALUE, 'checked' => null ]; } diff --git a/src/Kris/LaravelFormBuilder/Fields/ChildFormType.php b/src/Kris/LaravelFormBuilder/Fields/ChildFormType.php index a172fb1f..1be9df76 100644 --- a/src/Kris/LaravelFormBuilder/Fields/ChildFormType.php +++ b/src/Kris/LaravelFormBuilder/Fields/ChildFormType.php @@ -4,11 +4,17 @@ use Kris\LaravelFormBuilder\Form; +/** + * @template TFormType of Form + * + * @extends ParentType + */ class ChildFormType extends ParentType { /** * @var Form + * @phpstan-var TFormType */ protected $form; @@ -22,6 +28,7 @@ protected function getTemplate() /** * @return Form + * @phpstan-return TFormType */ public function getForm() { @@ -96,6 +103,7 @@ protected function createChildren() /** * @return Form + * @phpstan-return TFormType * @throws \Exception */ protected function getClassFromOptions() @@ -116,7 +124,8 @@ protected function getClassFromOptions() $options = [ 'model' => $this->getOption($this->valueProperty) ?: $this->parent->getModel(), 'name' => $this->name, - 'language_name' => $this->parent->getLanguageName() + 'language_name' => $this->getOption('language_name') ?: $this->parent->getLanguageName(), + 'translation_template' => $this->parent->getTranslationTemplate(), ]; if (!$this->parent->clientValidationEnabled()) { @@ -146,6 +155,10 @@ protected function getClassFromOptions() $class->setLanguageName($this->parent->getLanguageName()); } + if (!$class->getTranslationTemplate()) { + $class->setTranslationTemplate($this->parent->getTranslationTemplate()); + } + if (!$this->parent->clientValidationEnabled()) { $class->setClientValidationEnabled(false); } diff --git a/src/Kris/LaravelFormBuilder/Fields/ChoiceType.php b/src/Kris/LaravelFormBuilder/Fields/ChoiceType.php index 35a6bd5d..85ee0fcf 100644 --- a/src/Kris/LaravelFormBuilder/Fields/ChoiceType.php +++ b/src/Kris/LaravelFormBuilder/Fields/ChoiceType.php @@ -2,6 +2,11 @@ namespace Kris\LaravelFormBuilder\Fields; +use Illuminate\Support\Arr; + +/** + * @extends ParentType + */ class ChoiceType extends ParentType { /** @@ -32,7 +37,7 @@ protected function determineChoiceField() $expanded = $this->options['expanded']; $multiple = $this->options['multiple']; - if ($multiple) { + if (!$expanded && $multiple) { $this->options['attr']['multiple'] = true; } @@ -43,6 +48,8 @@ protected function determineChoiceField() if ($expanded && $multiple) { return $this->choiceType = 'checkbox'; } + + return $this->choiceType = 'select'; } /** @@ -69,6 +76,10 @@ protected function getDefaults() */ protected function createChildren() { + if (($data_override = $this->getOption('data_override')) && $data_override instanceof \Closure) { + $this->options['choices'] = $data_override($this->options['choices'], $this); + } + $this->children = []; $this->determineChoiceField(); @@ -96,12 +107,14 @@ protected function buildCheckableChildren($fieldType) { $multiple = $this->getOption('multiple') ? '[]' : ''; + $attr = $this->options['attr']?? []; + $attr = Arr::except($attr, ['class', 'multiple', 'id', 'name']); foreach ((array)$this->options['choices'] as $key => $choice) { $id = str_replace('.', '_', $this->getNameKey()) . '_' . $key; $options = $this->formHelper->mergeOptions( $this->getOption('choice_options'), [ - 'attr' => ['id' => $id], + 'attr' => array_merge(['id' => $id], $this->options['option_attributes'][$key] ?? $attr), 'label_attr' => ['for' => $id], 'label' => $choice, 'checked' => in_array($key, (array)$this->options[$this->valueProperty]), @@ -142,15 +155,25 @@ protected function setDefaultClasses(array $options = []) { $defaults = parent::setDefaultClasses($options); $choice_type = $this->determineChoiceField(); + Arr::forget($defaults, 'attr.class'); $wrapper_class = $this->formHelper->getConfig('defaults.' . $this->type . '.' . $choice_type . '_wrapper_class', ''); if ($wrapper_class) { $defaults['wrapper']['class'] = (isset($defaults['wrapper']['class']) ? $defaults['wrapper']['class'] . ' ' : '') . $wrapper_class; } - $choice_wrapper_class = $this->formHelper->getConfig('defaults.' . $this->type . '.choice_options.wrapper_class', ''); - $choice_label_class = $this->formHelper->getConfig('defaults.' . $this->type . '.choice_options.label_class', ''); - $choice_field_class = $this->formHelper->getConfig('defaults.' . $this->type . '.choice_options.field_class', ''); + $choice_wrapper_class = $this->formHelper->getConfig( + 'defaults.' . $this->type . '.choice_options.wrapper_class', + $this->formHelper->getConfig('defaults.' . $this->type . '.choice_options.' . $choice_type . '.wrapper_class', '') + ); + $choice_label_class = $this->formHelper->getConfig( + 'defaults.' . $this->type . '.choice_options.label_class', + $this->formHelper->getConfig('defaults.' . $this->type . '.choice_options.' . $choice_type . '.label_class', '') + ); + $choice_field_class = $this->formHelper->getConfig( + 'defaults.' . $this->type . '.choice_options.field_class', + $this->formHelper->getConfig('defaults.' . $this->type . '.choice_options.' . $choice_type . '.field_class', '') + ); if ($choice_wrapper_class) { $defaults['choice_options']['wrapper']['class'] = $choice_wrapper_class; diff --git a/src/Kris/LaravelFormBuilder/Fields/CollectionType.php b/src/Kris/LaravelFormBuilder/Fields/CollectionType.php index b22582ce..a06d2989 100644 --- a/src/Kris/LaravelFormBuilder/Fields/CollectionType.php +++ b/src/Kris/LaravelFormBuilder/Fields/CollectionType.php @@ -2,14 +2,21 @@ namespace Kris\LaravelFormBuilder\Fields; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; +/** + * @template TType of FormField + * + * @extends ParentType + */ class CollectionType extends ParentType { /** * Contains template for a collection element. * * @var FormField + * @phpstan-var TType */ protected $proto; @@ -38,7 +45,9 @@ protected function getDefaults() 'data' => null, 'property' => 'id', 'prototype_name' => '__NAME__', - 'empty_row' => true + 'empty_row' => true, + 'prefer_input' => false, + 'empty_model' => null, ]; } @@ -46,6 +55,7 @@ protected function getDefaults() * Get the prototype object. * * @return FormField + * @phpstan-return TType * @throws \Exception */ public function prototype() @@ -69,6 +79,26 @@ public function getAllAttributes() return $this->parent->getFormHelper()->mergeAttributes($this->children); } + /** + * Allow form-specific value alters. + * + * @param array $values + * @return void + */ + public function alterFieldValues(array &$values) + { + $stripLeft = strlen($this->getName()) + 1; + $stripRight = 1; + foreach ($this->children as $child) { + if (method_exists($child, 'alterFieldValues')) { + $itemKey = substr($child->getName(), $stripLeft, -$stripRight); + if (isset($values[$itemKey])) { + $child->alterFieldValues($values[$itemKey]); + } + } + } + } + /** * @inheritdoc */ @@ -77,7 +107,10 @@ protected function createChildren() $this->children = []; $type = $this->getOption('type'); $oldInput = $this->parent->getRequest()->old($this->getNameKey()); - $currentInput = $this->parent->getRequest()->get($this->getNameKey()); + $currentInput = $this->parent->getRequest()->input($this->getNameKey()); + + is_array($oldInput) or $oldInput = []; + is_array($currentInput) or $currentInput = []; try { $fieldType = $this->formHelper->getFieldType($type); @@ -91,20 +124,32 @@ protected function createChildren() $data = $this->getOption($this->valueProperty, []); // If no value is provided, get values from current request. - if (count($data) === 0) { - $data = $currentInput; + if (!is_null($data) && count($data) === 0) { + if ($this->getOption('prefer_input')) { + $data = $this->formatInputIntoModels($currentInput); + } + elseif ($this->getOption('empty_row')) { + $data = $this->formatInputIntoModels(array_slice($currentInput, 0, 1, true)); + } + else { + $data = []; + } } - - // Needs to have more than 1 item because 1 is rendered by default. - // This overrides current request in situations when validation fails. - if (count($oldInput) > 1) { - $data = $oldInput; + // Or if the current request input is preferred over original data. + elseif ($this->getOption('prefer_input') && count($currentInput)) { + $data = $this->formatInputIntoModels($currentInput, $data ?? []); } if ($data instanceof Collection) { $data = $data->all(); } + // Needs to have more than 1 item because 1 is rendered by default. + // This overrides current request in situations when validation fails. + if ($oldInput && count($oldInput) > 1) { + $data = $this->formatInputIntoModels($oldInput, $data ?? []); + } + $field = new $fieldType($this->name, $type, $this->parent, $this->getOption('options')); if ($this->getOption('prototype')) { @@ -113,7 +158,7 @@ protected function createChildren() if (!$data || empty($data)) { if ($this->getOption('empty_row')) { - return $this->children[] = $this->setupChild(clone $field, '[0]'); + return $this->children[] = $this->setupChild(clone $field, '[0]', $this->makeEmptyRowValue()); } return $this->children = []; @@ -128,6 +173,58 @@ protected function createChildren() foreach ($data as $key => $val) { $this->children[] = $this->setupChild(clone $field, '['.$key.']', $val); } + + return $this->children; + } + + protected function makeEmptyRowValue() + { + $empty = $this->getOption('empty_row'); + return $empty === true ? $this->makeNewEmptyModel() : $empty; + } + + protected function makeNewEmptyModel() + { + return value($this->getOption('empty_model')); + } + + protected function formatInputIntoModels(array $input, array $originalData = []) + { + if (!$this->getOption('empty_model')) { + return $input; + } + + $newData = []; + foreach ($input as $k => $inputItem) { + if (is_array($inputItem)) { + $newData[$k] = $this->formatInputIntoModel($originalData[$k] ?? $this->makeNewEmptyModel(), $inputItem); + } + else { + $newData[$k] = $inputItem; + } + } + + return $newData; + } + + protected function formatInputIntoModel($model, $input) + { + if ($model instanceof Model) { + $model->forceFill($input); + } + elseif (is_object($model)) { + foreach ($input as $key => $value) { + $model->$key = $value; + } + } + elseif (is_array($model)) { + $model = $input + $model; + } + else { + $model = $input; + } + + return $model; } /** @@ -144,9 +241,13 @@ protected function setupChild(FormField $field, $name, $value = null) $firstFieldOptions = $this->formHelper->mergeOptions( $this->getOption('options'), - ['attr' => ['id' => $newFieldName]] + ['attr' => array_merge(['id' => $newFieldName], $this->getOption('attr'))] ); + if (isset($firstFieldOptions['label'])) { + $firstFieldOptions['label'] = value($firstFieldOptions['label'], $value, $field); + } + $field->setName($newFieldName); $field->setOptions($firstFieldOptions); @@ -159,7 +260,6 @@ protected function setupChild(FormField $field, $name, $value = null) $field->setValue($value); - return $field; } @@ -171,7 +271,7 @@ protected function setupChild(FormField $field, $name, $value = null) */ protected function generatePrototype(FormField $field) { - $value = $field instanceof ChildFormType ? false : null; + $value = $this->makeNewEmptyModel(); $field->setOption('is_prototype', true); $field = $this->setupChild($field, $this->getPrototypeName(), $value); diff --git a/src/Kris/LaravelFormBuilder/Fields/EntityType.php b/src/Kris/LaravelFormBuilder/Fields/EntityType.php index 29636a18..56d87baa 100644 --- a/src/Kris/LaravelFormBuilder/Fields/EntityType.php +++ b/src/Kris/LaravelFormBuilder/Fields/EntityType.php @@ -1,6 +1,6 @@ getKeyName(); } - + if ($queryBuilder instanceof \Closure) { - $data = $queryBuilder($entity); + $data = $queryBuilder($entity, $this->parent); } else { $data = $entity; } - $data = $this->pluck($value, $key, $data); + if ($value instanceof \Closure) { + $data = $this->get($data); + } else { + $data = $this->pluck($value, $key, $data); + } if ($data instanceof Collection) { $data = $data->all(); } + if ($value instanceof \Closure) { + $part = []; + foreach ($data as $item) { + $part[$item->__get($key)] = $value($item); + } + + $data = $part; + } + $this->options['choices'] = $data; return parent::createChildren(); @@ -90,5 +103,29 @@ protected function pluck($value, $key, $data) //laravel 5.2.* return $data->lists($value, $key); } + + throw new \InvalidArgumentException(sprintf( + 'Please provide valid "property" option for entity field [%s] in form class [%s]', + $this->getRealName(), + get_class($this->parent) + )); + } + + protected function get($data) + { + if (!is_object($data)) { + return $data; + } + + if (method_exists($data, 'get') || $data instanceof Model) { + //laravel 5.3.* + return $data->get(); + } + + throw new \InvalidArgumentException(sprintf( + 'Please provide valid "query_builder" option for entity field [%s] in form class [%s]', + $this->getRealName(), + get_class($this->parent) + )); } } diff --git a/src/Kris/LaravelFormBuilder/Fields/FormField.php b/src/Kris/LaravelFormBuilder/Fields/FormField.php index ae63a815..2b32f79b 100644 --- a/src/Kris/LaravelFormBuilder/Fields/FormField.php +++ b/src/Kris/LaravelFormBuilder/Fields/FormField.php @@ -2,11 +2,14 @@ namespace Kris\LaravelFormBuilder\Fields; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; +use Kris\LaravelFormBuilder\Filters\Exception\FilterAlreadyBindedException; +use Kris\LaravelFormBuilder\Filters\FilterInterface; +use Kris\LaravelFormBuilder\Filters\FilterResolver; use Kris\LaravelFormBuilder\Form; -use Illuminate\Database\Eloquent\Model; use Kris\LaravelFormBuilder\FormHelper; -use Kris\LaravelFormBuilder\RulesParser; -use Illuminate\Database\Eloquent\Collection; +use Kris\LaravelFormBuilder\Rules; /** * Class FormField @@ -84,6 +87,27 @@ abstract class FormField */ protected $valueClosure = null; + /** + * Array of filters key(alias/name) => objects. + * + * @var array + */ + protected $filters = []; + + /** + * Raw/unfiltered field value. + * + * @var mixed $rawValues + */ + protected $rawValue; + + /** + * Override filters with same alias/name for field. + * + * @var bool + */ + protected $filtersOverride = false; + /** * @param string $name * @param string $type @@ -99,6 +123,7 @@ public function __construct($name, $type, Form $parent, array $options = []) $this->setTemplate(); $this->setDefaultOptions($options); $this->setupValue(); + $this->initFilters(); } @@ -117,7 +142,13 @@ protected function setupValue() } if (($value === null || $value instanceof \Closure) && !$isChild) { - $this->setValue($this->getModelValueAttribute($this->parent->getModel(), $this->name)); + if ($this instanceof EntityType) { + $attributeName = $this->name; + } else { + $attributeName = $this->getOption('value_property', $this->name); + } + + $this->setValue($this->getModelValueAttribute($this->parent->getModel(), $attributeName)); } elseif (!$isChild) { $this->hasDefault = true; } @@ -181,7 +212,9 @@ public function render(array $options = [], $showLabel = true, $showField = true 'options' => $this->options, 'showLabel' => $showLabel, 'showField' => $showField, - 'showError' => $showError + 'showError' => $showError, + 'errorBag' => $this->parent->getErrorBag(), + 'translationTemplate' => $this->parent->getTranslationTemplate(), ] )->render(); } @@ -191,7 +224,8 @@ public function render(array $options = [], $showLabel = true, $showField = true * * @return array */ - protected function getRenderData() { + protected function getRenderData() + { return []; } @@ -205,13 +239,24 @@ protected function getRenderData() { protected function getModelValueAttribute($model, $name) { $transformedName = $this->transformKey($name); + + if (is_null($model)) { + return null; + } + if (is_string($model)) { return $model; - } elseif (is_object($model)) { + } + + if (is_object($model)) { return object_get($model, $transformedName); - } elseif (is_array($model)) { - return array_get($model, $transformedName); } + + if (is_array($model)) { + return Arr::get($model, $transformedName); + } + + throw new \InvalidArgumentException('Invalid model given to field'); } /** @@ -229,23 +274,26 @@ protected function transformKey($key) * Prepare options for rendering. * * @param array $options - * @return array + * @return array The parsed options */ protected function prepareOptions(array $options = []) { $helper = $this->formHelper; - $rulesParser = new RulesParser($this); + + $this->options = $this->prepareRules($options); + $this->options = $helper->mergeOptions($this->options, $options); + + $rulesParser = $helper->createRulesParser($this); $rules = $this->getOption('rules'); $parsedRules = $rules ? $rulesParser->parse($rules) : []; - $this->options = $helper->mergeOptions($this->options, $options); foreach (['attr', 'label_attr', 'wrapper'] as $appendable) { // Append values to the 'class' attribute if ($this->getOption("{$appendable}.class_append")) { // Combine the current class attribute with the appends $append = $this->getOption("{$appendable}.class_append"); - $classAttribute = $this->getOption("{$appendable}.class", '').' '.$append; + $classAttribute = $this->getOption("{$appendable}.class", '') . ' ' . $append; $this->setOption("{$appendable}.class", $classAttribute); // Then remove the class_append option to prevent it from showing up as an attribute in the HTML @@ -254,7 +302,7 @@ protected function prepareOptions(array $options = []) } if ($this->getOption('attr.multiple') && !$this->getOption('tmp.multipleBracesSet')) { - $this->name = $this->name.'[]'; + $this->name = $this->name . '[]'; $this->setOption('tmp.multipleBracesSet', true); } @@ -264,20 +312,25 @@ protected function prepareOptions(array $options = []) if ($this->getOption('required') === true || isset($parsedRules['required'])) { $lblClass = $this->getOption('label_attr.class', ''); - $requiredClass = $helper->getConfig('defaults.required_class', 'required'); + $requiredClass = $this->getConfig('defaults.required_class', 'required'); - if (! str_contains($lblClass, $requiredClass)) { - $lblClass .= ' '.$requiredClass; + if (!Str::contains($lblClass, $requiredClass)) { + $lblClass .= ' ' . $requiredClass; $this->setOption('label_attr.class', $lblClass); } if ($this->parent->clientValidationEnabled()) { $this->setOption('attr.required', 'required'); + } - if ($parsedRules) { - $attrs = $this->getOption('attr') + $parsedRules; - $this->setOption('attr', $attrs); - } + if (isset($parsedRules['required'])) { + unset($parsedRules['required']); + } + } + + if ($this->parent->clientValidationEnabled() && $parsedRules) { + foreach($parsedRules as $rule => $param){ + $this->setOption('attr.' . $rule, $param); } } @@ -294,6 +347,73 @@ protected function prepareOptions(array $options = []) return $this->options; } + /** + * Normalize and merge rules. + * @param array $sourceOptions + * @return array + */ + protected function prepareRules(array &$sourceOptions = []) + { + $options = $this->options; + + // Normalize rules + if (array_key_exists('rules_append', $sourceOptions)) { + $sourceOptions['rules_append'] = $this->normalizeRules($sourceOptions['rules_append']); + } + + if (array_key_exists('rules', $sourceOptions)) { + $sourceOptions['rules'] = $this->normalizeRules($sourceOptions['rules']); + } + + if (array_key_exists('rules', $options)) { + $options['rules'] = $this->normalizeRules($options['rules']); + } + + + // Append rules + if ($rulesToBeAppended = Arr::pull($sourceOptions, 'rules_append')) { + $mergedRules = $this->mergeRules($options['rules'], $rulesToBeAppended); + $options['rules'] = $mergedRules; + } + + return $options; + } + + /** + * Normalize the the given rule expression to an array. + * @param mixed $rules + * @return array + */ + protected function normalizeRules($rules) + { + if (empty($rules)) { + return []; + } + + if (is_string($rules)) { + return explode('|', $rules); + } + + if (is_array($rules)) { + return array_values(array_unique(Arr::flatten($rules), SORT_REGULAR)); + } + + return $rules; + } + + /** + * Merges two sets of rules into one + * + * @param array $first first set of rules + * @param array $second second set of rules + * @return array merged set of rules without duplicates + */ + protected function mergeRules($first, $second) + { + return array_values(array_unique(array_merge($first, $second), SORT_REGULAR)); + } + + /** * Get name of the field. * @@ -346,7 +466,7 @@ public function getOptions() */ public function getOption($option, $default = null) { - return array_get($this->options, $option, $default); + return Arr::get($this->options, $option, $default); } /** @@ -371,7 +491,7 @@ public function setOptions($options) */ public function setOption($name, $value) { - array_set($this->options, $name, $value); + Arr::set($this->options, $name, $value); return $this; } @@ -419,6 +539,18 @@ public function isRendered() return $this->rendered; } + /** + * Marks the view as rendered. + * + * @return $this + */ + public function setRendered() + { + $this->rendered = true; + + return $this; + } + /** * Default options for field. * @@ -437,18 +569,20 @@ protected function getDefaults() private function allDefaults() { return [ - 'wrapper' => ['class' => $this->formHelper->getConfig('defaults.wrapper_class')], - 'attr' => ['class' => $this->formHelper->getConfig('defaults.field_class')], - 'help_block' => ['text' => null, 'tag' => 'p', 'attr' => [ - 'class' => $this->formHelper->getConfig('defaults.help_block_class') - ]], + 'wrapper' => ['class' => $this->getConfig('defaults.wrapper_class')], + 'attr' => ['class' => $this->getConfig('defaults.field_class')], + 'help_block' => [ + 'text' => null, + 'tag' => $this->getConfig('defaults.help_block_tag', 'p'), + 'attr' => ['class' => $this->getConfig('defaults.help_block_class')], + ], 'value' => null, 'default_value' => null, 'label' => null, 'label_show' => true, 'is_child' => false, - 'label_attr' => ['class' => $this->formHelper->getConfig('defaults.label_class')], - 'errors' => ['class' => $this->formHelper->getConfig('defaults.error_class')], + 'label_attr' => ['class' => $this->getConfig('defaults.label_class')], + 'errors' => ['class' => $this->getConfig('defaults.error_class')], 'rules' => [], 'error_messages' => [] ]; @@ -496,7 +630,7 @@ public function setValue($value) */ private function setTemplate() { - $this->template = $this->formHelper->getConfig($this->getTemplate(), $this->getTemplate()); + $this->template = $this->getConfig($this->getTemplate(), $this->getTemplate()); } /** @@ -506,20 +640,31 @@ private function setTemplate() */ protected function addErrorClass() { - $errors = $this->parent->getRequest()->session()->get('errors'); + $errors = []; + if ($this->parent->getRequest()->hasSession()) { + $errors = $this->parent->getRequest()->session()->get('errors'); + } + $errorBag = $this->parent->getErrorBag(); - if ($errors && $errors->has($this->getNameKey())) { - $errorClass = $this->formHelper->getConfig('defaults.wrapper_error_class'); + if ($errors && $errors->hasBag($errorBag) && $errors->getBag($errorBag)->has($this->getNameKey())) { + $fieldErrorClass = $this->getConfig('defaults.field_error_class'); + $fieldClass = $this->getOption('attr.class'); + + if ($fieldErrorClass && !Str::contains($fieldClass, $fieldErrorClass)) { + $fieldClass .= ' ' . $fieldErrorClass; + $this->setOption('attr.class', $fieldClass); + } + + $wrapperErrorClass = $this->getConfig('defaults.wrapper_error_class'); $wrapperClass = $this->getOption('wrapper.class'); - if ($this->getOption('wrapper') && !str_contains($wrapperClass, $errorClass)) { - $wrapperClass .= ' ' . $errorClass; + if ($wrapperErrorClass && $this->getOption('wrapper') && !Str::contains($wrapperClass, $wrapperErrorClass)) { + $wrapperClass .= ' ' . $wrapperErrorClass; $this->setOption('wrapper.class', $wrapperClass); } } } - /** * Merge all defaults with field specific defaults and set template if passed. * @@ -527,12 +672,16 @@ protected function addErrorClass() */ protected function setDefaultOptions(array $options = []) { + // Get default defaults from config (eg. defaults.field_class) $this->options = $this->formHelper->mergeOptions($this->allDefaults(), $this->getDefaults()); - $this->options = $this->prepareOptions($options); + // Maybe overwrite with field type defaults from config (eg. defaults.checkbox.field_class) $defaults = $this->setDefaultClasses($options); $this->options = $this->formHelper->mergeOptions($this->options, $defaults); + // Add specific field classes (eg. attr.class_append) + $this->options = $this->prepareOptions($options); + $this->setupLabel(); } @@ -544,18 +693,18 @@ protected function setDefaultOptions(array $options = []) */ protected function setDefaultClasses(array $options = []) { - $wrapper_class = $this->formHelper->getConfig('defaults.' . $this->type . '.wrapper_class', ''); - $label_class = $this->formHelper->getConfig('defaults.' . $this->type . '.label_class', ''); - $field_class = $this->formHelper->getConfig('defaults.' . $this->type . '.field_class', ''); + $wrapper_class = $this->getConfig('defaults.' . $this->type . '.wrapper_class', ''); + $label_class = $this->getConfig('defaults.' . $this->type . '.label_class', ''); + $field_class = $this->getConfig('defaults.' . $this->type . '.field_class', ''); $defaults = []; - if ($wrapper_class && !array_get($options, 'wrapper.class')) { + if ($wrapper_class && !Arr::get($options, 'wrapper.class')) { $defaults['wrapper']['class'] = $wrapper_class; } - if ($label_class && !array_get($options, 'label_attr.class')) { + if ($label_class && !Arr::get($options, 'label_attr.class')) { $defaults['label_attr']['class'] = $label_class; } - if ($field_class && !array_get($options, 'attr.class')) { + if ($field_class && !Arr::get($options, 'attr.class')) { $defaults['attr']['class'] = $field_class; } return $defaults; @@ -572,7 +721,13 @@ protected function setupLabel() return; } - if ($langName = $this->parent->getLanguageName()) { + if ($template = $this->parent->getTranslationTemplate()) { + $label = str_replace( + ['{name}', '{type}'], + [$this->getRealName(), 'label'], + $template + ); + } elseif ($langName = $this->parent->getLanguageName()) { $label = sprintf('%s.%s', $langName, $this->getRealName()); } else { $label = $this->getRealName(); @@ -617,22 +772,34 @@ public function disable() */ public function enable() { - array_forget($this->options, 'attr.disabled'); + Arr::forget($this->options, 'attr.disabled'); return $this; } + /** + * Whether this field is disabled. + * + * @return bool + */ + public function isDisabled() + { + $disabled = $this->getOption('attr.disabled'); + + return $disabled !== null && $disabled !== false; + } + /** * Get validation rules for a field if any with label for attributes. * - * @return array|null + * @return Rules */ public function getValidationRules() { $rules = $this->getOption('rules', []); $name = $this->getNameKey(); $messages = $this->getOption('error_messages', []); - $formName = $this->formHelper->transformToDotSyntax($this->parent->getName()); + $formName = $this->parent->getNameKey(); if ($messages && $formName) { $newMessages = []; @@ -644,14 +811,14 @@ public function getValidationRules() } if (!$rules) { - return []; + return (new Rules([]))->setFieldName($this->getNameKey()); } - return [ - 'rules' => [$name => $rules], - 'attributes' => [$name => $this->getOption('label')], - 'error_messages' => $messages - ]; + return (new Rules( + [$name => $rules], + [$name => $this->getOption('label')], + $messages + ))->setFieldName($this->getNameKey()); } /** @@ -661,7 +828,7 @@ public function getValidationRules() */ public function getAllAttributes() { - return [$this->getNameKey()]; + return $this->isDisabled() ? [] : [$this->getNameKey()]; } /** @@ -695,4 +862,198 @@ protected function isValidValue($value) { return $value !== null; } + + /** + * Method initFilters used to initialize filters + * from field options and bind it to the same. + * + * @return $this + */ + protected function initFilters() + { + // If override status is set in field options to true + // we will change filtersOverride property value to true + // so we can override existing filters with registered + // alias/name in addFilter method. + $overrideStatus = $this->getOption('filters_override', false); + if ($overrideStatus) { + $this->setFiltersOverride(true); + } + + // Get filters and bind it to field. + $filters = $this->getOption('filters', []); + foreach ($filters as $filter) { + $this->addFilter($filter); + } + + return $this; + } + + /** + * Method setFilters used to set filters to current filters property. + * + * @param array $filters + * + * @return \Kris\LaravelFormBuilder\Fields\FormField + */ + public function setFilters(array $filters) + { + $this->clearFilters(); + foreach ($filters as $filter) { + $this->addFilter($filter); + } + + return $this; + } + + /** + * Method getFilters returns array of binded filters + * if there are any binded. Otherwise empty array. + * + * @return array + */ + public function getFilters() + { + return $this->filters; + } + + /** + * @param string|FilterInterface $filter + * + * @return \Kris\LaravelFormBuilder\Fields\FormField + * + * @throws FilterAlreadyBindedException + */ + public function addFilter($filter) + { + // Resolve filter object from string/object or throw Ex. + $filterObj = FilterResolver::instance($filter); + + // If filtersOverride is allowed we will override filter + // with same alias/name if there is one with new resolved filter. + if ($this->getFiltersOverride()) { + if ($key = array_search($filterObj->getName(), $this->getFilters())) { + $this->filters[$key] = $filterObj; + } else { + $this->filters[$filterObj->getName()] = $filterObj; + } + } else { + // If filtersOverride is disabled and we found + // equal alias defined we will throw Ex. + if (array_key_exists($filterObj->getName(), $this->getFilters())) { + $ex = new FilterAlreadyBindedException($filterObj->getName(), $this->getName()); + throw $ex; + } + + // Filter with resolvedFilter alias/name doesn't exist + // so we will bind it as new one to field. + $this->filters[$filterObj->getName()] = $filterObj; + } + + return $this; + } + + /** + * Method removeFilter used to remove filter by provided alias/name. + * + * @param string $name + * + * @return \Kris\LaravelFormBuilder\Fields\FormField + */ + public function removeFilter($name) + { + $filters = $this->getFilters(); + if (array_key_exists($name, $filters)) { + unset($filters[$name]); + $this->filters = $filters; + } + + return $this; + } + + /** + * Method removeFilters used to remove filters by provided aliases/names. + * + * @param array $filterNames + * + * @return \Kris\LaravelFormBuilder\Fields\FormField + */ + public function removeFilters(array $filterNames) + { + $filters = $this->getFilters(); + foreach ($filterNames as $filterName) { + if (array_key_exists($filterName, $filters)) { + unset($filters[$filterName]); + $this->filters = $filters; + } + } + + return $this; + } + + /** + * Method clearFilters used to empty current filters property. + * + * @return \Kris\LaravelFormBuilder\Fields\FormField + */ + public function clearFilters() + { + $this->filters = []; + return $this; + } + + /** + * Method used to set FiltersOverride status to provided value. + * + * @param $status + * + * @return \Kris\LaravelFormBuilder\Fields\FormField + */ + public function setFiltersOverride($status) + { + $this->filtersOverride = $status; + return $this; + } + + /** + * @return bool + */ + public function getFiltersOverride() + { + return $this->filtersOverride; + } + + /** + * Method used to set Unfiltered/Unmutated field value. + * Method is called before field value mutating starts - request value filtering. + * + * @param mixed $value + * + * @return \Kris\LaravelFormBuilder\Fields\FormField + */ + public function setRawValue($value) + { + $this->rawValue = $value; + return $this; + } + + /** + * Returns unfiltered raw value of field. + * + * @return mixed + */ + public function getRawValue() + { + return $this->rawValue; + } + + /** + * Get config from the form. + * + * @return mixed + */ + private function getConfig($key = null, $default = null) + { + return $this->parent->getConfig($key, $default); + } } diff --git a/src/Kris/LaravelFormBuilder/Fields/ParentType.php b/src/Kris/LaravelFormBuilder/Fields/ParentType.php index 4a283bd0..1b92d18d 100644 --- a/src/Kris/LaravelFormBuilder/Fields/ParentType.php +++ b/src/Kris/LaravelFormBuilder/Fields/ParentType.php @@ -2,13 +2,18 @@ namespace Kris\LaravelFormBuilder\Fields; +use Illuminate\Support\Arr; use Kris\LaravelFormBuilder\Form; +/** + * @template TChildType of FormField + */ abstract class ParentType extends FormField { /** * @var FormField[] + * @phpstan-var TChildType[] */ protected $children; @@ -28,7 +33,7 @@ abstract protected function createChildren(); */ public function __construct($name, $type, Form $parent, array $options = []) { - parent::__construct($name, $type, $parent, $options); + parent::__construct($name, $type, $parent, $options + ['copy_options_to_children' => true]); // If there is default value provided and setValue was not triggered // in the parent call, make sure we generate child elements. if ($this->hasDefault) { @@ -40,7 +45,7 @@ public function __construct($name, $type, Form $parent, array $options = []) /** * @param mixed $val * - * @return ChildFormType + * @return $this */ public function setValue($val) { @@ -63,6 +68,7 @@ public function render(array $options = [], $showLabel = true, $showField = true * Get all children of the choice field. * * @return mixed + * @phpstan-return TChildType[] */ public function getChildren() { @@ -73,10 +79,11 @@ public function getChildren() * Get a child of the choice field. * * @return mixed + * @phpstan-return ?TChildType */ public function getChild($key) { - return array_get($this->children, $key); + return Arr::get($this->children, $key); } /** @@ -93,6 +100,38 @@ public function removeChild($key) return $this; } + /** + * @inheritdoc + */ + public function setOption($name, $value) + { + parent::setOption($name, $value); + + if ($this->options['copy_options_to_children']) { + foreach ((array) $this->children as $key => $child) { + $this->children[$key]->setOption($name, $value); + } + } + + return $this; + } + + /** + * @inheritdoc + */ + public function setOptions($options) + { + parent::setOptions($options); + + if ($this->options['copy_options_to_children']) { + foreach ((array) $this->children as $key => $child) { + $this->children[$key]->setOptions($options); + } + } + + return $this; + } + /** * @inheritdoc */ @@ -112,6 +151,7 @@ public function isRendered() * * @param string $name * @return FormField + * @phpstan-return TChildType */ public function __get($name) { @@ -142,9 +182,11 @@ public function __clone() */ public function disable() { + parent::disable(); foreach ($this->children as $field) { $field->disable(); } + return $this; } /** @@ -152,9 +194,11 @@ public function disable() */ public function enable() { + parent::enable(); foreach ($this->children as $field) { $field->enable(); } + return $this; } /** @@ -164,7 +208,7 @@ public function getValidationRules() { $rules = parent::getValidationRules(); $childrenRules = $this->formHelper->mergeFieldsRules($this->children); - return array_replace_recursive($rules, $childrenRules); + return $rules->append($childrenRules); } } diff --git a/src/Kris/LaravelFormBuilder/Fields/RepeatedType.php b/src/Kris/LaravelFormBuilder/Fields/RepeatedType.php index 6e9c34fc..2c46055f 100644 --- a/src/Kris/LaravelFormBuilder/Fields/RepeatedType.php +++ b/src/Kris/LaravelFormBuilder/Fields/RepeatedType.php @@ -2,6 +2,11 @@ namespace Kris\LaravelFormBuilder\Fields; +use Illuminate\Support\Arr; + +/** + * @extends ParentType + */ class RepeatedType extends ParentType { @@ -42,6 +47,8 @@ public function getAllAttributes() */ protected function createChildren() { + $this->prepareOptions(); + $firstName = $this->getRealName(); $secondName = $this->getOption('second_name'); @@ -49,14 +56,27 @@ protected function createChildren() $secondName = $firstName.'_confirmation'; } + // merge field rules and first field rules + $firstOptions = $this->getOption('first_options'); + $firstOptions['rules'] = $this->normalizeRules(Arr::pull($firstOptions, 'rules', [])); + if ($mainRules = $this->getOption('rules')) { + $firstOptions['rules'] = $this->mergeRules($mainRules, $firstOptions['rules']); + } + + $sameRule = 'same:' . $secondName; + if (!in_array($sameRule, $firstOptions['rules'])) { + $firstOptions['rules'][] = $sameRule; + } + $form = $this->parent->getFormBuilder()->plain([ 'name' => $this->parent->getName(), 'model' => $this->parent->getModel() ]) - ->add($firstName, $this->getOption('type'), $this->getOption('first_options')) + ->add($firstName, $this->getOption('type'), $firstOptions) ->add($secondName, $this->getOption('type'), $this->getOption('second_options')); $this->children['first'] = $form->getField($firstName); $this->children['second'] = $form->getField($secondName); } + } diff --git a/src/Kris/LaravelFormBuilder/Fields/SelectType.php b/src/Kris/LaravelFormBuilder/Fields/SelectType.php index a0d4f93b..40f82719 100644 --- a/src/Kris/LaravelFormBuilder/Fields/SelectType.php +++ b/src/Kris/LaravelFormBuilder/Fields/SelectType.php @@ -27,6 +27,7 @@ public function getDefaults() { return [ 'choices' => [], + 'option_attributes' => [], 'empty_value' => null, 'selected' => null ]; diff --git a/src/Kris/LaravelFormBuilder/Filters/Collection/BaseName.php b/src/Kris/LaravelFormBuilder/Filters/Collection/BaseName.php new file mode 100644 index 00000000..b2b1e5c6 --- /dev/null +++ b/src/Kris/LaravelFormBuilder/Filters/Collection/BaseName.php @@ -0,0 +1,34 @@ + + */ +class BaseName implements FilterInterface +{ + /** + * @param string $value + * @param array $options + * + * @return string + */ + public function filter($value, $options = []) + { + $value = (string) $value; + return basename($value); + } + + /** + * @return string + */ + public function getName() + { + return 'BaseName'; + } +} \ No newline at end of file diff --git a/src/Kris/LaravelFormBuilder/Filters/Collection/HtmlEntities.php b/src/Kris/LaravelFormBuilder/Filters/Collection/HtmlEntities.php new file mode 100644 index 00000000..5594861b --- /dev/null +++ b/src/Kris/LaravelFormBuilder/Filters/Collection/HtmlEntities.php @@ -0,0 +1,196 @@ + + */ +class HtmlEntities implements FilterInterface +{ + + /** + * Second arg of htmlentities function. + * + * @var integer + */ + protected $quoteStyle; + + /** + * Third arg of htmlentities function. + * + * @var string + */ + protected $encoding; + + /** + * Fourth arg of htmlentities function. + * + * @var string + */ + protected $doubleQuote; + + /** + * HtmlEntities constructor. + * + * @param array $options + */ + public function __construct(array $options = []) + { + if (!isset($options['quotestyle'])) { + $options['quotestyle'] = ENT_COMPAT; + } + + if (!isset($options['encoding'])) { + $options['encoding'] = 'UTF-8'; + } + + if (isset($options['charset'])) { + $options['encoding'] = $options['charset']; + } + + if (!isset($options['doublequote'])) { + $options['doublequote'] = true; + } + + $this->setQuoteStyle($options['quotestyle']); + $this->setEncoding($options['encoding']); + $this->setDoubleQuote($options['doublequote']); + } + + /** + * @return integer + */ + public function getQuoteStyle() + { + return $this->quoteStyle; + } + + /** + * @param integer $style + * + * @return \Kris\LaravelFormBuilder\Filters\Collection\HtmlEntities + */ + public function setQuoteStyle($style) + { + $this->quoteStyle = $style; + return $this; + } + + /** + * @return string + */ + public function getEncoding() + { + return $this->encoding; + } + + /** + * @param string $encoding + * + * @return \Kris\LaravelFormBuilder\Filters\Collection\HtmlEntities + */ + public function setEncoding($encoding) + { + $this->encoding = (string) $encoding; + return $this; + } + + /** + * Returns the charSet property + * + * Proxies to {@link getEncoding()} + * + * @return string + */ + public function getCharSet() + { + return $this->getEncoding(); + } + + /** + * Sets the charSet property. + * + * Proxies to {@link setEncoding()}. + * + * @param string $charSet + * + * @return \Kris\LaravelFormBuilder\Filters\Collection\HtmlEntities + */ + public function setCharSet($charSet) + { + return $this->setEncoding($charSet); + } + + /** + * Returns the doubleQuote property. + * + * @return boolean + */ + public function getDoubleQuote() + { + return $this->doubleQuote; + } + + /** + * Sets the doubleQuote property. + * + * @param boolean $doubleQuote + * + * @return \Kris\LaravelFormBuilder\Filters\Collection\HtmlEntities + */ + public function setDoubleQuote($doubleQuote) + { + $this->doubleQuote = (boolean) $doubleQuote; + return $this; + } + + /** + * @param string $value + * @param array $options + * + * @return mixed + * + * @throws \Exception + */ + public function filter($value, $options = []) + { + $value = (string) $value; + $filtered = htmlentities( + $value, + $this->getQuoteStyle(), + $this->getEncoding(), + $this->getDoubleQuote() + ); + + if (strlen($value) && !strlen($filtered)) { + if (!function_exists('iconv')) { + $ex = new \Exception('Encoding mismatch has resulted in htmlentities errors.'); + throw $ex; + } + + $enc = $this->getEncoding(); + $value = iconv('', $enc . '//IGNORE', $value); + $filtered = htmlentities($value, $this->getQuoteStyle(), $enc, $this->getDoubleQuote()); + + if (!strlen($filtered)) { + $ex = new \Exception('Encoding mismatch has resulted in htmlentities errors.'); + throw $ex; + } + } + + return $filtered; + } + + /** + * @return string + */ + public function getName() + { + return 'HtmlEntities'; + } +} \ No newline at end of file diff --git a/src/Kris/LaravelFormBuilder/Filters/Collection/Integer.php b/src/Kris/LaravelFormBuilder/Filters/Collection/Integer.php new file mode 100644 index 00000000..72e1d3c2 --- /dev/null +++ b/src/Kris/LaravelFormBuilder/Filters/Collection/Integer.php @@ -0,0 +1,34 @@ + + */ +class Integer implements FilterInterface +{ + /** + * @param mixed $value + * @param array $options + * + * @return mixed + */ + public function filter($value, $options = []) + { + $value = (int) ((string) $value); + return $value; + } + + /** + * @return string + */ + public function getName() + { + return 'Integer'; + } +} \ No newline at end of file diff --git a/src/Kris/LaravelFormBuilder/Filters/Collection/Lowercase.php b/src/Kris/LaravelFormBuilder/Filters/Collection/Lowercase.php new file mode 100644 index 00000000..0aa79d73 --- /dev/null +++ b/src/Kris/LaravelFormBuilder/Filters/Collection/Lowercase.php @@ -0,0 +1,99 @@ + + */ +class Lowercase implements FilterInterface +{ + /** + * Encoding for string input. + * + * @var string $encoding + */ + protected $encoding = null; + + /** + * StringToLower constructor. + * + * @param array $options + */ + public function __construct(array $options = []) + { + if (!array_key_exists('encoding', $options) && function_exists('mb_internal_encoding')) { + $options['encoding'] = mb_internal_encoding(); + } + + if (array_key_exists('encoding', $options)) { + $this->setEncoding($options['encoding']); + } + } + + /** + * Returns current encoding. + * + * @return string + */ + public function getEncoding() + { + return $this->encoding; + } + + /** + * @param null $encoding + * + * @return \Kris\LaravelFormBuilder\Filters\Collection\Lowercase + * + * @throws \Exception + */ + public function setEncoding($encoding = null) + { + if ($encoding !== null) { + if (!function_exists('mb_strtolower')) { + $ex = new \Exception('mbstring extension is required for value mutating.'); + throw $ex; + } + + $encoding = (string) $encoding; + if (!in_array(strtolower($encoding), array_map('strtolower', mb_list_encodings()))) { + $ex = new \Exception('The given encoding '.$encoding.' is not supported by mbstring ext.'); + throw $ex; + } + } + + $this->encoding = $encoding; + return $this; + } + + /** + * Returns the string lowercased $value. + * + * @param mixed $value + * @param array $options + * + * @return mixed + */ + public function filter($value, $options = []) + { + $value = (string) $value; + if ($this->getEncoding() !== null) { + return mb_strtolower($value, $this->getEncoding()); + } + + return strtolower($value); + } + + /** + * @return string + */ + public function getName() + { + return 'Lowercase'; + } +} \ No newline at end of file diff --git a/src/Kris/LaravelFormBuilder/Filters/Collection/PregReplace.php b/src/Kris/LaravelFormBuilder/Filters/Collection/PregReplace.php new file mode 100644 index 00000000..c1d87633 --- /dev/null +++ b/src/Kris/LaravelFormBuilder/Filters/Collection/PregReplace.php @@ -0,0 +1,116 @@ + + */ +class PregReplace implements FilterInterface +{ + /** + * Pattern to match + * + * @var mixed $pattern + */ + protected $pattern = null; + + /** + * Replacement against matches. + * + * @var mixed $replacement + */ + protected $replacement = ''; + + /** + * PregReplace constructor. + * + * @param array $options + */ + public function __construct($options = []) + { + if (array_key_exists('pattern', $options)) { + $this->setPattern($options['pattern']); + } + + if (array_key_exists('replace', $options)) { + $this->setReplacement($options['replace']); + } + } + + /** + * Set the match pattern for the regex being called within filter(). + * + * @param mixed $pattern - first arg of preg_replace + * + * @return \Kris\LaravelFormBuilder\Filters\Collection\PregReplace + */ + public function setPattern($pattern) + { + $this->pattern = $pattern; + return $this; + } + + /** + * Get currently set match pattern. + * + * @return string + */ + public function getPattern() + { + return $this->pattern; + } + + /** + * Set the Replacement pattern/string for the preg_replace called in filter. + * + * @param mixed $replacement - same as the second argument of preg_replace + * + * @return \Kris\LaravelFormBuilder\Filters\Collection\PregReplace + */ + public function setReplacement($replacement) + { + $this->replacement = $replacement; + return $this; + } + + /** + * Get currently set replacement value. + * + * @return string + */ + public function getReplacement() + { + return $this->replacement; + } + + /** + * @param mixed $value + * @param array $options + * + * @return mixed + * + * @throws \Exception + */ + public function filter($value, $options = []) + { + if ($this->getPattern() == null) { + $ex = new \Exception(get_class($this) . ' does not have a valid MatchPattern set.'); + throw $ex; + } + + return preg_replace($this->getPattern(), $this->getReplacement(), $value); + } + + /** + * @return string + */ + public function getName() + { + return 'PregReplace'; + } +} \ No newline at end of file diff --git a/src/Kris/LaravelFormBuilder/Filters/Collection/StripNewlines.php b/src/Kris/LaravelFormBuilder/Filters/Collection/StripNewlines.php new file mode 100644 index 00000000..1eeed785 --- /dev/null +++ b/src/Kris/LaravelFormBuilder/Filters/Collection/StripNewlines.php @@ -0,0 +1,33 @@ + + */ +class StripNewlines implements FilterInterface +{ + /** + * @param mixed $value + * @param array $options + * + * @return mixed + */ + public function filter($value, $options = []) + { + return str_replace(["\n", "\r"], '', $value); + } + + /** + * @return string + */ + public function getName() + { + return 'StripNewlines'; + } +} \ No newline at end of file diff --git a/src/Kris/LaravelFormBuilder/Filters/Collection/StripTags.php b/src/Kris/LaravelFormBuilder/Filters/Collection/StripTags.php new file mode 100644 index 00000000..81050ac1 --- /dev/null +++ b/src/Kris/LaravelFormBuilder/Filters/Collection/StripTags.php @@ -0,0 +1,262 @@ + + */ +class StripTags implements FilterInterface +{ + /** + * Array of allowed tags and allowed attributes for each allowed tag. + * + * Tags are stored in the array keys, and the array values are themselves + * arrays of the attributes allowed for the corresponding tag. + * + * @var array $allowedTags + */ + protected $allowedTags = []; + + /** + * + * Array of allowed attributes for all allowed tags. + * + * Attributes stored here are allowed for all of the allowed tags. + * + * @var array $allowedAttributes + */ + protected $allowedAttributes = []; + + /** + * StripTags constructor. + * + * @param array $options + */ + public function __construct($options = []) + { + if (array_key_exists('allowedTags', $options)) { + $this->setAllowedTags($options['allowedTags']); + } + + if (array_key_exists('allowedAttribs', $options)) { + $this->setAllowedAttributes($options['allowedAttribs']); + } + } + + /** + * Sets the allowedTags property. + * + * @param array|string $allowedTags + * + * @return \Kris\LaravelFormBuilder\Filters\Collection\StripTags + */ + public function setAllowedTags($allowedTags) + { + if (!is_array($allowedTags)) { + $allowedTags = array($allowedTags); + } + + foreach ($allowedTags as $index => $element) { + + // If the tag was provided without attributes + if (is_int($index) && is_string($element)) { + // Canonicalize the tag name + $tagName = strtolower($element); + // Store the tag as allowed with no attributes + $this->allowedTags[$tagName] = []; + } + + // Otherwise, if a tag was provided with attributes + else if (is_string($index) && (is_array($element) || is_string($element))) { + + // Canonicalize the tag name + $tagName = strtolower($index); + // Canonicalize the attributes + if (is_string($element)) { + $element = [$element]; + } + + // Store the tag as allowed with the provided attributes + $this->allowedTags[$tagName] = []; + foreach ($element as $attribute) { + if (is_string($attribute)) { + // Canonicalize the attribute name + $attributeName = strtolower($attribute); + $this->allowedTags[$tagName][$attributeName] = null; + } + } + + } + } + + return $this; + } + + /** + * @return array + */ + public function getAllowedTags() + { + return $this->allowedTags; + } + + /** + * Sets the allowedAttributes property. + * + * @param array|string $allowedAttribs + * + * @return \Kris\LaravelFormBuilder\Filters\Collection\StripTags + */ + public function setAllowedAttributes($allowedAttribs) + { + if (!is_array($allowedAttribs)) { + $allowedAttribs = [$allowedAttribs]; + } + + // Store each attribute as allowed. + foreach ($allowedAttribs as $attribute) { + if (is_string($attribute)) { + // Canonicalize the attribute name. + $attributeName = strtolower($attribute); + $this->allowedAttributes[$attributeName] = null; + } + } + + return $this; + } + + /** + * @param mixed $value + * @param array $options + * + * @return string + */ + public function filter($value, $options = []) + { + $value = (string) $value; + + // Strip HTML comments first + while (strpos($value, ' +