', $output);
75 | $this->assertStringContainsString('
', $output);
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/Widgets/DateWidget.php:
--------------------------------------------------------------------------------
1 | true,
22 | 'altFormat' => 'd F Y',
23 | 'dateFormat' => 'Y-m-d',
24 | 'locale' => 'it',
25 | ];
26 |
27 | // Merge default options with user-provided options
28 | $userFlatpickrOptions = $fieldConfig['flatpickr'] ?? [];
29 | $mergedOptions = array_merge($defaultFlatpickrOptions, $userFlatpickrOptions);
30 |
31 | // Propagate validation state to Flatpickr's alt input
32 | $hasErrors = !empty($errors);
33 | if (!empty($mergedOptions['altInput'])) {
34 | $existingAltClass = $mergedOptions['altInputClass']
35 | ?? ($fieldConfig['attributes']['class'] ?? 'form-control');
36 | $mergedOptions['altInputClass'] = trim($existingAltClass . ($hasErrors ? ' is-invalid' : ''));
37 | }
38 |
39 | // Pass the final options to the view
40 | $fieldConfig['attributes']['data-formello-datepicker'] = json_encode($mergedOptions);
41 |
42 | $format = $fieldConfig['format'] ?? 'Y-m-d';
43 |
44 | if ($value instanceof \DateTime) {
45 | $value = $value->format($format);
46 | } elseif (is_string($value) && $format !== 'Y-m-d') {
47 | $date = \DateTime::createFromFormat($format, $value);
48 | if ($date) {
49 | $value = $date->format('Y-m-d');
50 | }
51 | }
52 |
53 | return [
54 | 'name' => $name,
55 | 'value' => old($name, $value),
56 | 'label' => $fieldConfig['label'] ?? null,
57 | 'config' => $fieldConfig,
58 | 'errors' => $errors,
59 | 'format' => $format,
60 | ];
61 | }
62 |
63 | public function getAssets(?array $fieldConfig = null): ?array
64 | {
65 | return [
66 | 'scripts' => ['flatpickr.min.js', 'l10n/it.js'],
67 | 'styles' => ['flatpickr.min.css'],
68 | ];
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/Widgets/Select2Widget.php:
--------------------------------------------------------------------------------
1 | 'form-control',
17 | ];
18 |
19 | $fieldConfig = $this->mergeDefaultAttributes($fieldConfig, $defaults, $name);
20 | if(isset($fieldConfig['multiple']) && $fieldConfig['multiple'] == true){
21 | $fieldConfig['multiple'] = true;
22 | $name .= '[]';
23 | }else{
24 | $fieldConfig['multiple'] = false;
25 | }
26 |
27 | // Estrai la configurazione specifica di select2
28 | $select2Config = $fieldConfig['select2'] ?? [];
29 | $usesAjax = ! empty($select2Config['route']);
30 |
31 | $currentValue = old($name, $value);
32 | $choices = [];
33 |
34 | // Se usiamo AJAX e c'è un valore, dobbiamo caricare l'opzione iniziale
35 | if ($usesAjax && ! empty($currentValue)) {
36 | $modelClass = $select2Config['model'] ?? null;
37 | $labelField = $select2Config['label_field'] ?? 'name';
38 | $valueField = $select2Config['value_field'] ?? 'id';
39 |
40 | if ($modelClass) {
41 | $initialItems = $modelClass::whereIn($valueField, (array) $currentValue)->get();
42 | foreach ($initialItems as $item) {
43 | $choices[$item->$valueField] = data_get($item, $labelField);
44 | }
45 | }
46 | } elseif (! $usesAjax) {
47 | // Altrimenti, se non usiamo AJAX, risolviamo le choices come prima
48 | $choices = $this->resolveChoices($fieldConfig['choices'] ?? []);
49 | }
50 |
51 | return [
52 | 'name' => $name,
53 | 'value' => $currentValue,
54 | 'label' => $fieldConfig['label'] ?? null,
55 | 'config' => $fieldConfig,
56 | 'errors' => $errors,
57 | 'choices' => $choices,
58 | 'usesAjax' => $usesAjax,
59 | ];
60 | }
61 |
62 | protected function resolveChoices($choices): array
63 | {
64 | if (is_callable($choices)) {
65 | return call_user_func($choices);
66 | }
67 |
68 | return $choices;
69 | }
70 |
71 | public function getAssets(?array $fieldConfig = null): ?array
72 | {
73 | return [
74 | 'scripts' => ['select2.min.js'],
75 | 'styles' => ['select2.min.css', 'select2-bootstrap-5-theme.min.css'],
76 | ];
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/tests/Unit/FormelloMultipartTest.php:
--------------------------------------------------------------------------------
1 | set('formello.css_framework', 'bootstrap5');
16 | }
17 |
18 | protected function getPackageProviders($app)
19 | {
20 | return [
21 | \Metalogico\Formello\FormelloServiceProvider::class,
22 | ];
23 | }
24 |
25 | private function makeDummyModel()
26 | {
27 | return new class extends Model {
28 | public function getTable() {
29 | return 'dummy';
30 | }
31 | };
32 | }
33 |
34 | private function makeFormWithUpload()
35 | {
36 | return new class($this->makeDummyModel(), new ViewErrorBag()) extends Formello {
37 | protected function fields(): array {
38 | return [
39 | 'file' => [
40 | 'widget' => new UploadWidget(),
41 | ],
42 | ];
43 | }
44 | protected function create(): array { return []; }
45 | protected function edit(): array { return []; }
46 | };
47 | }
48 |
49 | private function makeFormWithNoUpload()
50 | {
51 | return new class($this->makeDummyModel(), new ViewErrorBag()) extends Formello {
52 | protected function fields(): array {
53 | return [
54 | 'name' => [
55 | 'widget' => 'TextWidget',
56 | ],
57 | ];
58 | }
59 | protected function create(): array { return []; }
60 | protected function edit(): array { return []; }
61 | };
62 | }
63 |
64 | public function test_form_with_upload_has_multipart_enctype()
65 | {
66 | $form = $this->makeFormWithUpload();
67 | $formConfig = (new \ReflectionClass($form))->getProperty('formConfig');
68 | $formConfig->setAccessible(true);
69 | $config = $formConfig->getValue($form);
70 | $this->assertArrayHasKey('attributes', $config);
71 | $this->assertArrayHasKey('enctype', $config['attributes']);
72 | $this->assertEquals('multipart/form-data', $config['attributes']['enctype']);
73 | }
74 |
75 | public function test_form_without_upload_does_not_have_multipart_enctype()
76 | {
77 | $form = $this->makeFormWithNoUpload();
78 | $formConfig = (new \ReflectionClass($form))->getProperty('formConfig');
79 | $formConfig->setAccessible(true);
80 | $config = $formConfig->getValue($form);
81 | $this->assertTrue(!isset($config['attributes']) || !isset($config['attributes']['enctype']));
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/tests/Unit/CustomWidgetsOverrideTest.php:
--------------------------------------------------------------------------------
1 | set('formello.css_framework', 'bootstrap5');
18 | }
19 |
20 | protected function getPackageProviders($app)
21 | {
22 | return [
23 | \Metalogico\Formello\FormelloServiceProvider::class,
24 | ];
25 | }
26 |
27 | private function makeDummyModel()
28 | {
29 | return new class extends Model {
30 | public function getTable() { return 'dummy'; }
31 | };
32 | }
33 |
34 | public function test_custom_widgets_override_built_in_alias()
35 | {
36 | // Register a custom widget that overrides the 'text' alias
37 | config()->set('formello.custom_widgets', [
38 | 'text' => \Tests\Unit\TestTextWidget::class,
39 | ]);
40 |
41 | // Build a simple form that uses the 'text' alias
42 | $form = new class($this->makeDummyModel(), new ViewErrorBag()) extends Formello {
43 | protected function fields(): array {
44 | return [
45 | 'field' => [
46 | 'widget' => 'text',
47 | ],
48 | ];
49 | }
50 | protected function create(): array { return []; }
51 | protected function edit(): array { return []; }
52 | };
53 |
54 | $fields = $form->getFields();
55 | $this->assertArrayHasKey('field', $fields);
56 | $this->assertInstanceOf(TestTextWidget::class, $fields['field']['widget']);
57 | $output = $form->renderField('field');
58 | $this->assertIsString($output);
59 | $this->assertStringContainsString('data-test-text-widget', $output);
60 | }
61 | }
62 |
63 | // Simple test widget that replaces the built-in TextWidget
64 | class TestTextWidget extends BaseWidget implements WidgetInterface
65 | {
66 | public function getWidgetName(): string
67 | {
68 | return 'text';
69 | }
70 |
71 | public function getViewData($name, $value, array $fieldConfig, $errors = null): array
72 | {
73 | return [
74 | 'name' => $name,
75 | 'value' => $value,
76 | 'label' => $fieldConfig['label'] ?? null,
77 | 'config' => $fieldConfig,
78 | 'errors' => $errors,
79 | ];
80 | }
81 |
82 | public function render($name, $value, array $fieldConfig, $errors = null): string
83 | {
84 | // Render a minimal recognizable markup for assertion
85 | return '
overridden
';
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/resources/views/widgets/bootstrap5/checkboxes.blade.php:
--------------------------------------------------------------------------------
1 |
47 |
48 |
49 | @if(isset($config['select-all']['enabled']))
50 |
76 | @endif
--------------------------------------------------------------------------------
/src/FormelloServiceProvider.php:
--------------------------------------------------------------------------------
1 | mergeConfigFrom(__DIR__.'/../config/formello.php', 'formello');
14 |
15 | $this->app->singleton('formello', FormelloManager::class);
16 | $this->app->bind(Formello::class, FormelloManager::class);
17 |
18 | // Register factory and inspector
19 | $this->app->singleton(WidgetFactory::class);
20 | $this->app->singleton(SchemaInspector::class);
21 | }
22 |
23 | public function boot(): void
24 | {
25 | $this->loadViewsFrom(__DIR__.'/../resources/views', 'formello');
26 |
27 | $this->publishes([
28 | __DIR__.'/../config/formello.php' => config_path('formello.php'),
29 | ], 'formello-config');
30 |
31 | $this->publishes([
32 | __DIR__.'/../resources/views' => resource_path('views/vendor/formello'),
33 | ], 'formello-views');
34 |
35 | $this->publishes([
36 | // Main script
37 | __DIR__.'/../resources/assets/js/formello.js' => public_path('vendor/formello/js/formello.js'),
38 | // IMask
39 | __DIR__.'/../resources/assets/js/imask.min.js' => public_path('vendor/formello/js/imask.min.js'),
40 | // Flatpickr
41 | __DIR__.'/../resources/assets/js/flatpickr.min.js' => public_path('vendor/formello/js/flatpickr.min.js'),
42 | __DIR__.'/../resources/assets/css/flatpickr.min.css' => public_path('vendor/formello/css/flatpickr.min.css'),
43 | __DIR__.'/../resources/assets/js/l10n/it.js' => public_path('vendor/formello/js/l10n/it.js'),
44 | // Pickr
45 | __DIR__.'/../resources/assets/js/pickr.min.js' => public_path('vendor/formello/js/pickr.min.js'),
46 | __DIR__.'/../resources/assets/css/nano.min.css' => public_path('vendor/formello/css/nano.min.css'),
47 | // Quill.js
48 | __DIR__.'/../resources/assets/js/jodit.min.js' => public_path('vendor/formello/js/jodit.min.js'),
49 | __DIR__.'/../resources/assets/css/jodit.min.css' => public_path('vendor/formello/css/jodit.min.css'),
50 | // Select2
51 | __DIR__.'/../resources/assets/js/select2.min.js' => public_path('vendor/formello/js/select2.min.js'),
52 | __DIR__.'/../resources/assets/css/select2.min.css' => public_path('vendor/formello/css/select2.min.css'),
53 | __DIR__.'/../resources/assets/css/select2-bootstrap-5-theme.min.css' => public_path('vendor/formello/css/select2-bootstrap-5-theme.min.css'),
54 | ], 'formello-assets');
55 |
56 | if ($this->app->runningInConsole()) {
57 | $this->commands([MakeFormelloCommand::class]);
58 | }
59 |
60 | $this->registerBladeDirectives();
61 | }
62 |
63 | /**
64 | * Register Blade directives for Formello assets
65 | */
66 | protected function registerBladeDirectives(): void
67 | {
68 | Blade::directive('formelloStyles', function () {
69 | return "render(); ?>";
70 | });
71 |
72 | Blade::directive('formelloScripts', function () {
73 | return "render(); ?>";
74 | });
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/tests/Unit/FormelloTest.php:
--------------------------------------------------------------------------------
1 | set('formello.css_framework', 'bootstrap5');
16 | }
17 |
18 | protected function getPackageProviders($app)
19 | {
20 | return [
21 | \Metalogico\Formello\FormelloServiceProvider::class,
22 | ];
23 | }
24 |
25 | private function makeDummyModel()
26 | {
27 | return new class extends Model
28 | {
29 | public function getTable()
30 | {
31 | return 'dummy';
32 | }
33 | };
34 | }
35 |
36 | public function test_formello_can_be_instantiated_and_renders()
37 | {
38 | $form = new class($this->makeDummyModel(), new ViewErrorBag) extends Formello
39 | {
40 | protected function fields(): array
41 | {
42 | return [
43 | 'field' => [
44 | 'name' => 'test',
45 | 'label' => 'Test',
46 | 'widget' => new TextWidget,
47 | ],
48 | ];
49 | }
50 |
51 | protected function create(): array
52 | {
53 | return [];
54 | }
55 |
56 | protected function edit(): array
57 | {
58 | return [];
59 | }
60 | };
61 | $this->assertInstanceOf(Formello::class, $form);
62 | try {
63 | $output = $form->render();
64 | $this->assertIsString($output);
65 | } catch (\Throwable $e) {
66 | $this->fail('Render exception: '.$e->getMessage()."\n".$e->getTraceAsString());
67 | }
68 | }
69 |
70 | public function test_is_creating_returns_true_for_new_model()
71 | {
72 | $model = $this->makeDummyModel();
73 |
74 | $form = new class($model, new ViewErrorBag) extends Formello
75 | {
76 | protected function fields(): array
77 | {
78 | return [];
79 | }
80 |
81 | protected function create(): array
82 | {
83 | return [];
84 | }
85 |
86 | protected function edit(): array
87 | {
88 | return [];
89 | }
90 | };
91 |
92 | $this->assertTrue($form->isCreating());
93 | $this->assertFalse($form->isEditing());
94 | }
95 |
96 | public function test_is_editing_returns_true_for_existing_model()
97 | {
98 | $model = $this->makeDummyModel();
99 | $model->exists = true; // Simulate an existing model
100 |
101 | $form = new class($model, new ViewErrorBag) extends Formello
102 | {
103 | protected function fields(): array
104 | {
105 | return [];
106 | }
107 |
108 | protected function create(): array
109 | {
110 | return [];
111 | }
112 |
113 | protected function edit(): array
114 | {
115 | return [];
116 | }
117 | };
118 |
119 | $this->assertTrue($form->isEditing());
120 | $this->assertFalse($form->isCreating());
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/tests/Unit/WysiwygWidgetTest.php:
--------------------------------------------------------------------------------
1 | set('formello.css_framework', 'bootstrap5');
16 | }
17 |
18 | protected function getPackageProviders($app)
19 | {
20 | return [
21 | \Metalogico\Formello\FormelloServiceProvider::class,
22 | ];
23 | }
24 |
25 | private function makeDummyModel()
26 | {
27 | return new class extends Model
28 | {
29 | public function getTable()
30 | {
31 | return 'dummy';
32 | }
33 | };
34 | }
35 |
36 | private function makeFormWithWysiwygWidget()
37 | {
38 | return new class($this->makeDummyModel(), new ViewErrorBag) extends Formello
39 | {
40 | protected function fields(): array
41 | {
42 | return [
43 | 'content' => [
44 | 'widget' => new WysiwygWidget,
45 | 'jodit' => [
46 | 'toolbar' => ['bold', 'italic', 'link'],
47 | 'language' => 'it',
48 | ],
49 | ],
50 | ];
51 | }
52 |
53 | protected function create(): array
54 | {
55 | return [];
56 | }
57 |
58 | protected function edit(): array
59 | {
60 | return [];
61 | }
62 | };
63 | }
64 |
65 | public function test_wysiwyg_widget_is_instantiated_and_renders()
66 | {
67 | $form = $this->makeFormWithWysiwygWidget();
68 | $fields = $form->getFields();
69 |
70 | $this->assertArrayHasKey('content', $fields);
71 | $this->assertInstanceOf(WysiwygWidget::class, $fields['content']['widget']);
72 |
73 | $output = $form->renderField('content');
74 | $this->assertIsString($output);
75 | $this->assertStringContainsString('data-formello-wysiwyg', $output);
76 | }
77 |
78 | public function test_wysiwyg_widget_includes_configuration()
79 | {
80 | $widget = new WysiwygWidget;
81 | $viewData = $widget->getViewData('content', 'test value', [
82 | 'jodit' => [
83 | 'toolbar' => ['bold', 'italic'],
84 | 'language' => 'en',
85 | ],
86 | ]);
87 |
88 | $this->assertArrayHasKey('config', $viewData);
89 | $this->assertArrayHasKey('attributes', $viewData['config']);
90 | $this->assertArrayHasKey('data-formello-wysiwyg', $viewData['config']['attributes']);
91 |
92 | $fieldConfig = json_decode($viewData['config']['attributes']['data-formello-wysiwyg'], true);
93 | $this->assertEquals(['bold', 'italic'], $fieldConfig['toolbar']);
94 | $this->assertEquals('en', $fieldConfig['language']);
95 | }
96 |
97 | public function test_wysiwyg_widget_with_empty_configuration()
98 | {
99 | $widget = new WysiwygWidget;
100 | $viewData = $widget->getViewData('content', 'test value', []);
101 |
102 | $this->assertArrayHasKey('config', $viewData);
103 | $this->assertArrayHasKey('attributes', $viewData['config']);
104 | $this->assertArrayHasKey('data-formello-wysiwyg', $viewData['config']['attributes']);
105 |
106 | $fieldConfig = json_decode($viewData['config']['attributes']['data-formello-wysiwyg'], true);
107 | $this->assertEquals([], $fieldConfig);
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/Console/MakeFormelloCommand.php:
--------------------------------------------------------------------------------
1 | files = $files;
21 | }
22 |
23 | public function handle()
24 | {
25 | $model = $this->option('model');
26 |
27 | if (! $model) {
28 | $this->error('The --model option is required.');
29 |
30 | return;
31 | }
32 |
33 | $modelClass = $this->qualifyModel($model);
34 |
35 | if (! class_exists($modelClass)) {
36 | $this->error("Model {$modelClass} does not exist.");
37 |
38 | return;
39 | }
40 |
41 | $name = $this->option('name');
42 | $formName = $name ? $name : class_basename($model).'Form';
43 | $formPath = app_path("Forms/{$formName}.php");
44 |
45 | if ($this->files->exists($formPath)) {
46 | $this->error("Form {$formName} already exists!");
47 |
48 | return;
49 | }
50 |
51 | $this->makeDirectory($formPath);
52 |
53 | // compiling the stub file
54 | $stubPath = __DIR__.'/stubs/formello.stub';
55 | $stub = $this->files->get($stubPath);
56 | $content = $this->replaceNamespace($stub, $formName);
57 | $content = $this->replaceClass($content, $formName);
58 | $content = $this->replaceModel($content, $model);
59 | $content = $this->replaceFields($content, $modelClass);
60 | $this->files->put($formPath, $content);
61 |
62 | $this->info("Form {$formName} created successfully.");
63 | }
64 |
65 | protected function qualifyModel($model)
66 | {
67 | $model = ltrim($model, '\\/');
68 | $model = str_replace('/', '\\', $model);
69 | $rootNamespace = $this->laravel->getNamespace();
70 |
71 | if (Str::startsWith($model, $rootNamespace)) {
72 | return $model;
73 | }
74 |
75 | return is_dir(app_path('Models'))
76 | ? $rootNamespace.'Models\\'.$model
77 | : $rootNamespace.$model;
78 | }
79 |
80 | protected function makeDirectory($path)
81 | {
82 | if (! $this->files->isDirectory(dirname($path))) {
83 | $this->files->makeDirectory(dirname($path), 0777, true, true);
84 | }
85 | }
86 |
87 | protected function replaceNamespace($stub, $name)
88 | {
89 | $stub = str_replace(
90 | ['DummyNamespace', 'DummyRootNamespace'],
91 | [$this->getNamespace($name), $this->rootNamespace()],
92 | $stub
93 | );
94 |
95 | return $stub;
96 | }
97 |
98 | protected function replaceClass($stub, $name)
99 | {
100 | $class = str_replace($this->getNamespace($name).'\\', '', $name);
101 | $stub = str_replace('DummyClass', $class, $stub);
102 |
103 | return $stub;
104 | }
105 |
106 | protected function replaceModel($stub, $model)
107 | {
108 | $stub = str_replace('DummyModel', Str::plural(strtolower($model)), $stub);
109 |
110 | return $stub;
111 | }
112 |
113 | protected function replaceFields($stub, $modelClass)
114 | {
115 | $model = new $modelClass;
116 | $fillable = $model->getFillable();
117 |
118 | $fields = '';
119 | foreach ($fillable as $field) {
120 | $fields .= " '{$field}' => [\n";
121 | $fields .= " 'label' => __('".Str::title(str_replace('_', ' ', $field))."'),\n";
122 | $fields .= " ],\n";
123 | }
124 |
125 | $stub = str_replace('DummyFields', $fields, $stub);
126 |
127 | return $stub;
128 | }
129 |
130 | protected function getNamespace($name)
131 | {
132 | return 'App\\Forms';
133 | }
134 |
135 | protected function rootNamespace()
136 | {
137 | return $this->laravel->getNamespace();
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/tests/Unit/Select2WidgetTest.php:
--------------------------------------------------------------------------------
1 | set('formello.css_framework', 'bootstrap5');
16 | }
17 |
18 | protected function getPackageProviders($app)
19 | {
20 | return [
21 | \Metalogico\Formello\FormelloServiceProvider::class,
22 | ];
23 | }
24 |
25 | private function makeDummyModel()
26 | {
27 | return new class extends Model {
28 | public function getTable() { return 'dummy'; }
29 | };
30 | }
31 |
32 | private function makeFormWithSelect2Widget()
33 | {
34 | return new class($this->makeDummyModel(), new ViewErrorBag()) extends Formello {
35 | protected function fields(): array {
36 | return [
37 | 'field' => [
38 | 'widget' => new Select2Widget(),
39 | 'choices' => ['a' => 'A', 'b' => 'B'],
40 | ],
41 | ];
42 | }
43 | protected function create(): array { return []; }
44 | protected function edit(): array { return []; }
45 | };
46 | }
47 |
48 | public function test_select2_widget_is_instantiated_and_renders()
49 | {
50 | $form = $this->makeFormWithSelect2Widget();
51 | $fields = $form->getFields();
52 | $this->assertArrayHasKey('field', $fields);
53 | $this->assertInstanceOf(Select2Widget::class, $fields['field']['widget']);
54 | $output = $form->renderField('field');
55 | $this->assertIsString($output);
56 | }
57 |
58 | public function test_select2_does_not_render_multiple_attribute_by_default()
59 | {
60 | $form = new class($this->makeDummyModel(), new ViewErrorBag()) extends Formello {
61 | protected function fields(): array {
62 | return [
63 | 'field' => [
64 | 'widget' => new Select2Widget(),
65 | 'choices' => ['a' => 'A', 'b' => 'B'],
66 | // 'multiple' omitted
67 | ],
68 | ];
69 | }
70 | protected function create(): array { return []; }
71 | protected function edit(): array { return []; }
72 | };
73 |
74 | $output = $form->renderField('field');
75 | // Ensure the HTML boolean attribute 'multiple' is not present
76 | $this->assertStringNotContainsString(' multiple', $output);
77 | }
78 |
79 | public function test_select2_does_not_render_multiple_attribute_when_config_false()
80 | {
81 | $form = new class($this->makeDummyModel(), new ViewErrorBag()) extends Formello {
82 | protected function fields(): array {
83 | return [
84 | 'field' => [
85 | 'widget' => new Select2Widget(),
86 | 'choices' => ['a' => 'A', 'b' => 'B'],
87 | 'multiple' => false,
88 | ],
89 | ];
90 | }
91 | protected function create(): array { return []; }
92 | protected function edit(): array { return []; }
93 | };
94 |
95 | $output = $form->renderField('field');
96 | // Ensure the HTML boolean attribute 'multiple' is not present
97 | $this->assertStringNotContainsString(' multiple', $output);
98 | }
99 |
100 | public function test_select2_renders_multiple_attribute_when_config_true()
101 | {
102 | $form = new class($this->makeDummyModel(), new ViewErrorBag()) extends Formello {
103 | protected function fields(): array {
104 | return [
105 | 'field' => [
106 | 'widget' => new Select2Widget(),
107 | 'choices' => ['a' => 'A', 'b' => 'B'],
108 | 'multiple' => true,
109 | ],
110 | ];
111 | }
112 | protected function create(): array { return []; }
113 | protected function edit(): array { return []; }
114 | };
115 |
116 | $output = $form->renderField('field');
117 | // HTML boolean attribute present
118 | $this->assertStringContainsString('multiple', $output);
119 | // Name should be suffixed with [] for multiple selects
120 | $this->assertStringContainsString('name="field[]"', $output);
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [1.2.8] - 2025-09-02
9 |
10 | ### Changed
11 | - Separator widget for dividing sections in your forms.
12 | - config/formello.php removed "default_widgets" and added "custom_widgets" to alias your custom widgets.
13 |
14 | ## [1.2.7] - 2025-09-01
15 |
16 | ### Fixed
17 | - Date and DateTime: error messages now appear below the field and the red highlight works correctly, including with icons/prefix/suffix.
18 | - Select2: fields remain single-select unless the multiple option is enabled.
19 |
20 | ## [1.2.6] - 2025-08-06
21 |
22 | ### Changed
23 | - 'boolean' widget is now 'toggle'
24 | - 'select2' widget now uses a custom data-formello-select2 trigger to avoid collisions
25 |
26 | ## [1.2.5] - 2025-08-01
27 |
28 | ### Changed
29 | - README corrections for the custom widgets section.
30 |
31 | ### Added
32 | - Added --name option to make command to create forms with a custom name.
33 |
34 | ## [1.2.4] - 2025-07-30
35 |
36 | ### Fixed
37 | - Fixed an issue where the Select2 widget would incorrectly get the `multiple` attribute even when not specified in the field configuration. The logic now correctly passes the `multiple` state from the PHP backend to the JavaScript initialization via a `data-multiple` attribute, ensuring the widget behaves as expected.
38 |
39 | ## [1.2.2] - 2025-07-24
40 |
41 | ### Added
42 | - **Select2 Bootstrap 5 Theme**: it's possible to define a theme for select2 using the 'theme' config option.
43 |
44 |
45 | ## [1.2.0] - 2025-07-24
46 |
47 | ### Added
48 | - **WYSIWYG Editor Widget**: New `WysiwygWidget` using Jodit Editor for rich text editing with comprehensive features including tables, images, links, and formatting.
49 | - **Dedicated Mask Widget**: New `MaskWidget` extending `TextWidget` specifically for input masking with IMask.js, providing better separation of concerns.
50 | - **Enhanced Asset Management System**: Completely redesigned asset configuration system allowing users to disable specific widget libraries to prevent conflicts with existing theme assets.
51 |
52 | ### Changed
53 | - **Improved Asset Configuration**: Asset configuration now uses widget names directly (`'wysiwyg' => false`) instead of library names, making it more intuitive and maintainable.
54 | - **TextWidget Simplification**: Removed mask logic from `TextWidget` as it's now handled by the dedicated `MaskWidget`.
55 | - **Streamlined Asset Loading**: Simplified asset registration logic with direct widget-to-config mapping, eliminating hardcoded mappings.
56 |
57 | ### Fixed
58 | - **Asset Configuration Bug**: Fixed issue where setting libraries to `false` in config didn't prevent asset loading.
59 | - **Widget Type Detection**: Improved widget type detection in asset management system for more reliable asset filtering.
60 |
61 |
62 | ## [1.1.0] - 2025-07-22
63 |
64 | ### Added
65 | - **Icon Support for Text Fields**: Added a new `icon` option to the text widget to display an icon inside the input field using Bootstrap's input groups.
66 | - **Input Masking for Text Fields**: Integrated IMask.js to add input masking capabilities. Added a new `mask` option to the text widget to define custom input masks.
67 | - **Flatpickr Date/DateTime Widgets**: Replaced native HTML5 date inputs with Flatpickr for better UX and cross-browser compatibility.
68 | - **Italian Localization**: Added Italian locale support for Flatpickr date/datetime pickers with proper month and day names.
69 | - **Color Picker Widget**: New `ColorWidget` using Pickr nano library for full color selection with preview, opacity, and multiple format support.
70 | - **Color Swatch Widget**: New `ColorSwatchWidget` for predefined color selection from customizable swatches, perfect for brand colors and design systems.
71 | - **Enhanced Asset Management**: Added Pickr library assets (JS/CSS) with automatic publishing via ServiceProvider.
72 |
73 | ### Changed
74 | - **DateTimeWidget Refactoring**: DateTimeWidget now extends DateWidget for better code reuse and consistency.
75 | - **Template Optimization**: Eliminated duplicate datetime.blade.php template by reusing the date template.
76 | - **Improved JavaScript Integration**: Enhanced formello.js with proper Pickr initialization and event handling.
77 |
78 |
79 |
80 | ## [1.0.0] - 2025-07-21
81 |
82 | ### Added
83 |
84 | - Initial release of Laravel Formello.
85 | - Automatic form generation from Eloquent models.
86 | - Declarative form definition using simple PHP classes.
87 | - Built-in support for Bootstrap 5 CSS framework.
88 | - A comprehensive set of widgets: `Text`, `Textarea`, `Select`, `Select2`, `Radio`, `Checkboxes`, `Toggle`, `Range`, `Date`, `DateTime`, `Upload`, and `Hidden`.
89 | - Automatic rendering of form fields, labels, help text, and validation errors.
90 | - `isCreating()` and `isEditing()` methods for conditional logic within form classes.
91 | - Support for custom, user-defined widgets.
92 | - Artisan command `php artisan make:formello` to quickly scaffold form classes from models.
93 | - Publishable configuration file for easy customization.
94 | - Publishable views for full control over the rendered HTML.
95 | - Unit and Feature tests to ensure reliability.
96 |
97 | ### Fixed
98 |
99 | - Correctly detect form mode (`create` vs `edit`) based on the model's existence in the database (`$model->exists`).
100 |
--------------------------------------------------------------------------------
/resources/assets/js/formello.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', function () {
2 |
3 | // IMask initialization
4 | const maskElements = document.querySelectorAll('[data-formello-mask]');
5 | maskElements.forEach(function (el) {
6 | try {
7 | const maskOptions = JSON.parse(el.getAttribute('data-formello-mask'));
8 | IMask(el, maskOptions);
9 | } catch (e) {
10 | console.error('Error parsing Formello mask options:', e);
11 | }
12 | });
13 |
14 | // Flatpickr initialization
15 | const datepickerElements = document.querySelectorAll('[data-formello-datepicker]');
16 | datepickerElements.forEach(function (el) {
17 | try {
18 | const flatpickrOptions = JSON.parse(el.getAttribute('data-formello-datepicker'));
19 | flatpickr(el, flatpickrOptions);
20 | } catch (e) {
21 | console.error('Error parsing Formello datepicker options:', e);
22 | }
23 | });
24 |
25 | // Pickr color picker initialization
26 | const colorpickerElements = document.querySelectorAll('[data-formello-colorpicker]');
27 | colorpickerElements.forEach(function (el) {
28 | try {
29 | const pickrOptions = JSON.parse(el.getAttribute('data-formello-colorpicker'));
30 |
31 | // Create Pickr instance
32 | const pickr = Pickr.create({
33 | el: el,
34 | ...pickrOptions
35 | });
36 |
37 | // Update input value when color changes
38 | pickr.on('change', (color) => {
39 | el.value = color.toHEXA().toString();
40 | // Trigger change event for form validation
41 | el.dispatchEvent(new Event('change', { bubbles: true }));
42 | });
43 |
44 | // Handle swatch clicks for swatches-only mode
45 | pickr.on('swatchselect', (color) => {
46 | const hexColor = color.toHEXA().toString();
47 | el.value = hexColor;
48 |
49 | // Update the visual representation
50 | pickr.setColor(hexColor);
51 |
52 | // Trigger change event for form validation
53 | el.dispatchEvent(new Event('change', { bubbles: true }));
54 |
55 | // Close the picker automatically for swatches
56 | pickr.hide();
57 | });
58 |
59 | } catch (e) {
60 | console.error('Error parsing Formello colorpicker options:', e);
61 | }
62 | });
63 |
64 | // Jodit WYSIWYG initialization
65 | const wysiwygElements = document.querySelectorAll('[data-formello-wysiwyg]');
66 | if (wysiwygElements.length > 0 && typeof Jodit !== 'undefined') {
67 | wysiwygElements.forEach(function (el) {
68 | try {
69 | const joditOptions = JSON.parse(el.getAttribute('data-formello-wysiwyg'));
70 |
71 | // Default configuration - simple and stable
72 | const defaultConfig = {
73 | minHeight: 300,
74 | maxHeight: 300,
75 | iframe: true,
76 | toolbarSticky: false,
77 | showCharsCounter: false,
78 | showWordsCounter: false,
79 | showXPathInStatusbar: false,
80 | buttons: [
81 | 'bold',
82 | 'italic',
83 | 'underline',
84 | '|',
85 | 'ul',
86 | 'ol',
87 | '|',
88 | 'fontsize',
89 | 'brush',
90 | '|',
91 | 'left',
92 | 'center',
93 | 'right',
94 | '|',
95 | 'undo',
96 | 'redo',
97 | '|',
98 | 'hr',
99 | '|',
100 | 'fullsize'
101 | ],
102 | removeButtons: ['about', 'print'],
103 | };
104 |
105 | // Merge user options with defaults
106 | const finalConfig = { ...defaultConfig, ...joditOptions };
107 |
108 | // Initialize Jodit
109 | const editor = new Jodit(el, finalConfig);
110 |
111 | // Update textarea value when content changes
112 | editor.events.on('change', function () {
113 | // Trigger change event for form validation
114 | el.dispatchEvent(new Event('change', { bubbles: true }));
115 | });
116 |
117 | } catch (e) {
118 | console.error('Error parsing Formello WYSIWYG options:', e);
119 | }
120 | });
121 | } else if (wysiwygElements.length > 0) {
122 | console.warn('Jodit not loaded but WYSIWYG elements found. Make sure to include Jodit script before formello.js');
123 | }
124 |
125 | // Select2 initialization
126 | const select2Elements = document.querySelectorAll('[data-formello-select2]');
127 | if (typeof $ !== 'undefined' && $.fn.select2) {
128 | select2Elements.forEach(function (el) {
129 | try {
130 |
131 | $(el).select2({
132 | theme: 'bootstrap-5',
133 | });
134 |
135 | } catch (e) {
136 | console.error('Error initializing Formello Select2:', e);
137 | }
138 | });
139 | } else if (select2Elements.length > 0) {
140 | console.warn('Select2 not loaded but Select2 elements found. Make sure to include Select2 script and jQuery before formello.js');
141 | }
142 | });
143 |
--------------------------------------------------------------------------------
/resources/views/widgets/bootstrap5/select2.blade.php:
--------------------------------------------------------------------------------
1 |
134 |
135 |
--------------------------------------------------------------------------------
/resources/assets/css/nano.min.css:
--------------------------------------------------------------------------------
1 | /*! Pickr 1.9.1 MIT | https://github.com/Simonwep/pickr */
2 | .pickr{position:relative;overflow:visible;transform:translateY(0)}.pickr *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr .pcr-button{position:relative;height:2em;width:2em;padding:.5em;cursor:pointer;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Helvetica Neue",Arial,sans-serif;border-radius:.15em;background:url("data:image/svg+xml;utf8,
") no-repeat center;background-size:0;transition:all .3s}.pickr .pcr-button::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8,
");background-size:.5em;border-radius:.15em;z-index:-1}.pickr .pcr-button::before{z-index:initial}.pickr .pcr-button::after{position:absolute;content:"";top:0;left:0;height:100%;width:100%;transition:background .3s;background:var(--pcr-color);border-radius:.15em}.pickr .pcr-button.clear{background-size:70%}.pickr .pcr-button.clear::before{opacity:0}.pickr .pcr-button.clear:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-button.disabled{cursor:not-allowed}.pickr *,.pcr-app *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr input:focus,.pickr input.pcr-active,.pickr button:focus,.pickr button.pcr-active,.pcr-app input:focus,.pcr-app input.pcr-active,.pcr-app button:focus,.pcr-app button.pcr-active{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-palette,.pickr .pcr-slider,.pcr-app .pcr-palette,.pcr-app .pcr-slider{transition:box-shadow .3s}.pickr .pcr-palette:focus,.pickr .pcr-slider:focus,.pcr-app .pcr-palette:focus,.pcr-app .pcr-slider:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(0,0,0,.25)}.pcr-app{position:fixed;display:flex;flex-direction:column;z-index:10000;border-radius:.1em;background:#fff;opacity:0;visibility:hidden;transition:opacity .3s,visibility 0s .3s;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Helvetica Neue",Arial,sans-serif;box-shadow:0 .15em 1.5em 0 rgba(0,0,0,.1),0 0 1em 0 rgba(0,0,0,.03);left:0;top:0}.pcr-app.visible{transition:opacity .3s;visibility:visible;opacity:1}.pcr-app .pcr-swatches{display:flex;flex-wrap:wrap;margin-top:.75em}.pcr-app .pcr-swatches.pcr-last{margin:0}@supports(display: grid){.pcr-app .pcr-swatches{display:grid;align-items:center;grid-template-columns:repeat(auto-fit, 1.75em)}}.pcr-app .pcr-swatches>button{font-size:1em;position:relative;width:calc(1.75em - 5px);height:calc(1.75em - 5px);border-radius:.15em;cursor:pointer;margin:2.5px;flex-shrink:0;justify-self:center;transition:all .15s;overflow:hidden;background:rgba(0,0,0,0);z-index:1}.pcr-app .pcr-swatches>button::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8,
");background-size:6px;border-radius:.15em;z-index:-1}.pcr-app .pcr-swatches>button::after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:var(--pcr-color);border:1px solid rgba(0,0,0,.05);border-radius:.15em;box-sizing:border-box}.pcr-app .pcr-swatches>button:hover{filter:brightness(1.05)}.pcr-app .pcr-swatches>button:not(.pcr-active){box-shadow:none}.pcr-app .pcr-interaction{display:flex;flex-wrap:wrap;align-items:center;margin:0 -0.2em 0 -0.2em}.pcr-app .pcr-interaction>*{margin:0 .2em}.pcr-app .pcr-interaction input{letter-spacing:.07em;font-size:.75em;text-align:center;cursor:pointer;color:#75797e;background:#f1f3f4;border-radius:.15em;transition:all .15s;padding:.45em .5em;margin-top:.75em}.pcr-app .pcr-interaction input:hover{filter:brightness(0.975)}.pcr-app .pcr-interaction input:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(66,133,244,.75)}.pcr-app .pcr-interaction .pcr-result{color:#75797e;text-align:left;flex:1 1 8em;min-width:8em;transition:all .2s;border-radius:.15em;background:#f1f3f4;cursor:text}.pcr-app .pcr-interaction .pcr-result::-moz-selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-result::selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-type.active{color:#fff;background:#4285f4}.pcr-app .pcr-interaction .pcr-save,.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{color:#fff;width:auto}.pcr-app .pcr-interaction .pcr-save,.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{color:#fff}.pcr-app .pcr-interaction .pcr-save:hover,.pcr-app .pcr-interaction .pcr-cancel:hover,.pcr-app .pcr-interaction .pcr-clear:hover{filter:brightness(0.925)}.pcr-app .pcr-interaction .pcr-save{background:#4285f4}.pcr-app .pcr-interaction .pcr-clear,.pcr-app .pcr-interaction .pcr-cancel{background:#f44250}.pcr-app .pcr-interaction .pcr-clear:focus,.pcr-app .pcr-interaction .pcr-cancel:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(244,66,80,.75)}.pcr-app .pcr-selection .pcr-picker{position:absolute;height:18px;width:18px;border:2px solid #fff;border-radius:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none}.pcr-app .pcr-selection .pcr-color-palette,.pcr-app .pcr-selection .pcr-color-chooser,.pcr-app .pcr-selection .pcr-color-opacity{position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none;display:flex;flex-direction:column;cursor:grab;cursor:-webkit-grab}.pcr-app .pcr-selection .pcr-color-palette:active,.pcr-app .pcr-selection .pcr-color-chooser:active,.pcr-app .pcr-selection .pcr-color-opacity:active{cursor:grabbing;cursor:-webkit-grabbing}.pcr-app[data-theme=nano]{width:14.25em;max-width:95vw}.pcr-app[data-theme=nano] .pcr-swatches{margin-top:.6em;padding:0 .6em}.pcr-app[data-theme=nano] .pcr-interaction{padding:0 .6em .6em .6em}.pcr-app[data-theme=nano] .pcr-selection{display:grid;grid-gap:.6em;grid-template-columns:1fr 4fr;grid-template-rows:5fr auto auto;align-items:center;height:10.5em;width:100%;align-self:flex-start}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview{grid-area:2/1/4/1;height:100%;width:100%;display:flex;flex-direction:row;justify-content:center;margin-left:.6em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-last-color{display:none}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-current-color{position:relative;background:var(--pcr-color);width:2em;height:2em;border-radius:50em;overflow:hidden}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-current-color::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8,
");background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette{grid-area:1/1/2/3;width:100%;height:100%;z-index:1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette .pcr-palette{border-radius:.15em;width:100%;height:100%}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette .pcr-palette::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8,
");background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser{grid-area:2/2/2/2}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity{grid-area:3/2/3/2}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity{height:.5em;margin:0 .6em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-picker,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-picker{top:50%;transform:translateY(-50%)}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-slider,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-slider{flex-grow:1;border-radius:50em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-slider{background:linear-gradient(to right, hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%), hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(0, 100%, 50%))}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-slider{background:linear-gradient(to right, transparent, black),url("data:image/svg+xml;utf8,
");background-size:100%,.25em}
3 |
--------------------------------------------------------------------------------
/src/Formello.php:
--------------------------------------------------------------------------------
1 | model = new $model;
44 | } else {
45 | $this->model = $model;
46 | }
47 | $this->errors = $errors ?? session()->get('errors', new ViewErrorBag);
48 | $this->widgetFactory = $widgetFactory ?? new WidgetFactory;
49 | $this->schemaInspector = $schemaInspector ?? new SchemaInspector;
50 |
51 | // Set the form mode based on the model's existence
52 | if ($this->model->exists) {
53 | $this->setFormMode('edit');
54 | } else {
55 | $this->setFormMode('create');
56 | }
57 |
58 | $this->initializeFields();
59 | $this->initializeForm();
60 | }
61 |
62 | abstract protected function fields(): array;
63 |
64 | abstract protected function create(): array;
65 |
66 | abstract protected function edit(): array;
67 |
68 | /**
69 | * Returns the model instance associated with the form.
70 | */
71 | public function getModel(): Model
72 | {
73 | return $this->model;
74 | }
75 |
76 | /**
77 | * Initialize the form
78 | */
79 | protected function initializeForm()
80 | {
81 | if (method_exists($this, 'create') && ! $this->model->exists) {
82 | $this->formConfig = $this->create();
83 | } elseif (method_exists($this, 'edit') && $this->model->exists) {
84 | $this->formConfig = $this->edit();
85 | } else {
86 | throw new \RuntimeException('No form configuration method found.');
87 | }
88 |
89 | // if there's an upload widget in the form add the multipart form attribute
90 | if ($this->hasUploadWidget()) {
91 | if (! isset($this->formConfig['attributes'])) {
92 | $this->formConfig['attributes'] = [];
93 | }
94 | $this->formConfig['attributes']['enctype'] = 'multipart/form-data';
95 | }
96 | }
97 |
98 | protected function hasUploadWidget(): bool
99 | {
100 | foreach ($this->fields as $field) {
101 | if ($field['widget'] instanceof UploadWidget || $field['widget'] == 'upload') {
102 | return true;
103 | }
104 | }
105 |
106 | return false;
107 | }
108 |
109 | /**
110 | * Initialize the fields
111 | */
112 | protected function initializeFields(): void
113 | {
114 | $definedFields = $this->fields();
115 |
116 | foreach ($definedFields as $name => $fieldConfig) {
117 |
118 | $widget = $this->resolveWidget($fieldConfig, $name);
119 | $this->fields[$name] = [
120 | 'widget' => $widget,
121 | 'config' => $fieldConfig,
122 | ];
123 |
124 | // Register assets for this widget
125 | $this->registerWidgetAssets($widget, $fieldConfig);
126 | }
127 | }
128 |
129 | protected function resolveWidget(array $fieldConfig, string $fieldName): WidgetInterface
130 | {
131 | // Se widget specificato esplicitamente
132 | if (isset($fieldConfig['widget'])) {
133 | // Se è un alias (stringa breve, es: 'text', 'select2', ecc.)
134 | if (is_string($fieldConfig['widget'])) {
135 | // Usa la factory per risolvere l'alias
136 | return $this->widgetFactory->make($fieldConfig['widget']);
137 | }
138 | // Se è una classe completa
139 | if (is_string($fieldConfig['widget']) && class_exists($fieldConfig['widget'])) {
140 | return new $fieldConfig['widget'];
141 | }
142 | // Se è già un oggetto widget
143 | if ($fieldConfig['widget'] instanceof WidgetInterface) {
144 | return $fieldConfig['widget'];
145 | }
146 | // Se arriva qui, il valore non è valido
147 | throw new \InvalidArgumentException("Invalid widget definition for field '$fieldName'");
148 | }
149 |
150 | // Auto-detect dal database schema
151 | $columnType = $this->schemaInspector->getColumnType($this->model, $fieldName);
152 |
153 | return $this->widgetFactory->make($columnType);
154 | }
155 |
156 | protected function getDefaultFields()
157 | {
158 | $defaults = [];
159 | foreach ($this->fields() as $field => $config) {
160 | $defaults[$field] = $this->getDefaultWidgetForField($field);
161 | }
162 |
163 | return $defaults;
164 | }
165 |
166 | /**
167 | * Map database field types to default widgets
168 | */
169 | protected function getDefaultWidgetForField($field)
170 | {
171 | // checks if the column exists and gets its type
172 | $schema = $this->model->getConnection()->getSchemaBuilder();
173 | if ($schema->hasColumn($this->model->getTable(), $field)) {
174 | $columnType = $schema->getColumnType($this->model->getTable(), $field);
175 | } else {
176 | $columnType = 'text';
177 | }
178 |
179 | switch ($columnType) {
180 | case 'char':
181 | case 'varchar':
182 | case 'text':
183 | return new Widgets\TextWidget;
184 | case 'textarea':
185 | return new Widgets\TextareaWidget;
186 | case 'boolean':
187 | case 'tinyint':
188 | return new Widgets\ToggleWidget;
189 | case 'date':
190 | return new Widgets\DateWidget;
191 | case 'datetime':
192 | case 'timestamp':
193 | return new Widgets\DateTimeWidget;
194 | default:
195 | return new Widgets\TextWidget;
196 | }
197 | }
198 |
199 | public function renderForm()
200 | {
201 | return view('formello::form', [
202 | 'formello' => $this,
203 | 'formConfig' => $this->formConfig,
204 | ])->render();
205 | }
206 |
207 | public function render()
208 | {
209 | return view('formello::form', [
210 | 'formello' => $this,
211 | 'formConfig' => $this->formConfig,
212 | ])->render();
213 | }
214 |
215 | public function renderField(string $name): string
216 | {
217 | if (! isset($this->fields[$name])) {
218 | throw new \InvalidArgumentException("Field '{$name}' not found");
219 | }
220 |
221 | $fieldConfig = $this->fields[$name];
222 | $widget = $fieldConfig['widget'];
223 | $config = $fieldConfig['config'];
224 |
225 | $value = old($name, $config['value'] ?? $this->model->{$name} ?? null);
226 | $errors = $this->errors->get($name);
227 |
228 | return $widget->render($name, $value, $config, $errors);
229 | }
230 |
231 | public function getCssFramework()
232 | {
233 | return config('formello.css_framework', 'bootstrap5');
234 | }
235 |
236 | public function getFields()
237 | {
238 | return $this->fields;
239 | }
240 |
241 | public function isCreating(): bool
242 | {
243 | return $this->formMode === 'create';
244 | }
245 |
246 | public function isEditing(): bool
247 | {
248 | return $this->formMode === 'edit';
249 | }
250 |
251 | public function setFormMode(string $mode): self
252 | {
253 | $this->formMode = $mode;
254 |
255 | return $this;
256 | }
257 |
258 | /**
259 | * Register assets for a widget
260 | */
261 | protected function registerWidgetAssets(WidgetInterface $widget, array $fieldConfig): void
262 | {
263 | $type = $widget->getWidgetName();
264 | $assetConfig = config('formello.assets', []);
265 |
266 | // Check if assets are enabled for this widget type
267 | if (! ($assetConfig[$type] ?? true)) {
268 | return;
269 | }
270 |
271 | // Get assets from widget, passing field configuration for conditional assets
272 | $assets = $widget->getAssets($fieldConfig);
273 |
274 | if ($assets) {
275 | $this->registerAssets($assets);
276 | }
277 | }
278 |
279 | /**
280 | * Register an array of assets
281 | */
282 | protected function registerAssets(array $assets): void
283 | {
284 | if (isset($assets['scripts'])) {
285 | foreach ($assets['scripts'] as $script) {
286 | AssetManager::addScript($script);
287 | }
288 | }
289 |
290 | if (isset($assets['styles'])) {
291 | foreach ($assets['styles'] as $style) {
292 | AssetManager::addStyle($style);
293 | }
294 | }
295 | }
296 | }
297 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # Formello
4 |
5 | A Laravel package for generating Bootstrap 5 forms based on models. Laravel 9+
6 |
7 | Formello is a comprehensive form generation and handling tool for Laravel applications, inspired by Django forms.
8 |
9 |

10 |
11 | ## 🎉 Motivation
12 |
13 | The Laravel ecosystem offers powerful tools for building applications, from full-featured admin panels like Nova and Filament to complex form-handling libraries. However, I felt there was a need for a tool that sits in the "sweet spot" between these solutions.
14 |
15 | Formello was created for developers who need to generate forms quickly without the overhead of a complete admin panel, but who also want a simpler, more intuitive API than more complex form libraries. It's designed to automate the repetitive aspects of form creation while giving you full control over the final output.
16 |
17 | Currently, Formello ships with built-in support for **Bootstrap 5**, and support for **Tailwind CSS** is coming soon™!
18 |
19 | If you use this project, please consider giving it a ⭐.
20 |
21 | ## ✨ Features
22 |
23 | - Easy form definition using Laravel classes
24 | - Automatic form rendering
25 | - Support for various field types:
26 | - Text
27 | - Textarea
28 | - Select (with multiple)
29 | - Select2
30 | - Radio
31 | - Checkboxes
32 | - Toggle
33 | - Range
34 | - Date
35 | - DateTime
36 | - Upload
37 | - Hidden
38 | - Customizable widgets
39 | - Automatic error handling and display
40 | - Form validation integration
41 |
42 | ## 🛠️ How to install
43 |
44 | 1. Install the package via Composer:
45 |
46 | ```bash
47 | composer require metalogico/laravel-formello
48 | ```
49 |
50 | 2. Publish the assets:
51 |
52 | ```bash
53 | php artisan vendor:publish --tag=formello-assets
54 | ```
55 |
56 | 3. (Optional) Auto-publish assets on update
57 |
58 | To ensure that Formello's assets are automatically updated every time you run `composer update`, you can add a command to the `post-update-cmd` script in your project's `composer.json` file.
59 |
60 | ```json
61 | "scripts": {
62 | "post-update-cmd": [
63 | "@php artisan vendor:publish --tag=formello-assets --force"
64 | ]
65 | }
66 | ```
67 |
68 | This will overwrite the existing assets with the latest ones from the package.
69 |
70 |
71 | ## 😎 How to use
72 |
73 | Creating a Form
74 | Create a new form class that extends `Metalogico\Formello\Formello`.
75 |
76 | Here's a simple example for a product form.
77 |
78 | ```php
79 | 'POST',
92 | 'action' => route('products.store'),
93 | ];
94 | }
95 |
96 | protected function edit(): array
97 | {
98 | return [
99 | 'method' => 'POST',
100 | 'action' => route('products.update', $this->model->id),
101 | ];
102 | }
103 |
104 | protected function fields(): array
105 | {
106 | return [
107 | 'name' => [
108 | 'label' => __('Product Name'),
109 | 'help' => 'Enter the name of the product',
110 | ],
111 | 'description' => [
112 | 'label' => __('Description'),
113 | ],
114 | 'category_id' => [
115 | 'label' => __('Category'),
116 | 'widget' => 'select',
117 | 'choices' => Category::pluck('name', 'id')->toArray();
118 | ],
119 | 'in_stock' => [
120 | 'label' => __('In Stock'),
121 | ],
122 | ];
123 | }
124 | }
125 | ```
126 |
127 | Remember to add these fields to your model's `$fillable` array otherwise Formello will not render them.
128 |
129 | ```php
130 |
131 | class Product extends Model
132 | {
133 | // ...
134 | protected $fillable = [
135 | 'name',
136 | 'category_id',
137 | 'description',
138 | 'in_stock',
139 | ];
140 |
141 | }
142 | ```
143 |
144 | ## Using the provided artisan command
145 |
146 | You can generate a basic formello file using this command:
147 |
148 | ```bash
149 | php artisan make:formello --model=Product
150 | ```
151 |
152 | The script will generate a skeleton file that contains a basic field definition for each fillable field found in your model.
153 |
154 |
155 | ## Rendering the Form
156 |
157 | In your controller for an empty form (create action):
158 |
159 | ```php
160 | public function create()
161 | {
162 | // create the form
163 | $formello = new ProductForm(Product::class);
164 | // pass it to the view
165 | return view('products.create', [
166 | 'formello' => $formello
167 | ]);
168 | }
169 | ```
170 |
171 | or, for an edit form:
172 |
173 | ```php
174 | public function edit(Product $product)
175 | {
176 | // pass the model to the form
177 | $formello = new ProductForm($product);
178 | // pass it to the view
179 | return view('products.edit', [
180 | 'formello' => $formello
181 | ]);
182 | }
183 | ```
184 |
185 | Then in you blade template:
186 |
187 | ```php
188 | {!! $formello->render() !!}
189 | ```
190 |
191 | If you want to render only the fields (without the \