From 412e8134a1aa842b8839d62b9db8fa7c556305b2 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 24 Jul 2026 13:29:58 +0100 Subject: [PATCH] Builder administration area * Page builder foundation * Form builder document type * Dashboard builder document type * Report builder document type * Menu builder document type * Email builder document type * Query builder document type * Workflow builder document type * Reusable component registry * Form-field registry * Dashboard-widget registry * Hero, heading, and rich-content components * JSON document schema * Saved builder documents * Draft, published, and archived states * Document version history * Reusable builder templates * Component rendering service * Extensible BuilderComponent contract * Authenticated Builder REST endpoints * Administration navigation * Builder permissions * Module registration and test --- .../Builder/Components/ContentComponent.php | 12 +++ .../Builder/Components/HeadingComponent.php | 12 +++ Modules/Builder/Components/HeroComponent.php | 12 +++ Modules/Builder/Config/config.php | 7 ++ .../Builder/Contracts/BuilderComponent.php | 11 +++ ...026_07_24_160000_create_builder_tables.php | 31 +++++++ .../Http/Controllers/BuilderApiController.php | 13 +++ .../Http/Controllers/BuilderController.php | 29 +++++++ Modules/Builder/Models/BuilderDocument.php | 10 +++ .../Builder/Models/BuilderDocumentVersion.php | 8 ++ Modules/Builder/Models/BuilderTemplate.php | 8 ++ .../Providers/BuilderServiceProvider.php | 20 +++++ .../Providers/RouteServiceProvider.php | 12 +++ .../Resources/views/components/hero.blade.php | 1 + .../Builder/Resources/views/editor.blade.php | 10 +++ .../Builder/Resources/views/index.blade.php | 7 ++ Modules/Builder/Routes/api.php | 9 ++ Modules/Builder/Routes/web.php | 11 +++ .../Services/BuilderDocumentService.php | 16 ++++ Modules/Builder/Services/BuilderRegistry.php | 24 +++++ Modules/Builder/Services/BuilderRenderer.php | 13 +++ .../Tests/Feature/BuilderModuleTest.php | 13 +++ Modules/Builder/module.json | 1 + .../Database/Seeders/RolePermissionSeeder.php | 1 + modules_statuses.json | 87 ++++++++++--------- 25 files changed, 335 insertions(+), 43 deletions(-) create mode 100644 Modules/Builder/Components/ContentComponent.php create mode 100644 Modules/Builder/Components/HeadingComponent.php create mode 100644 Modules/Builder/Components/HeroComponent.php create mode 100644 Modules/Builder/Config/config.php create mode 100644 Modules/Builder/Contracts/BuilderComponent.php create mode 100644 Modules/Builder/Database/Migrations/2026_07_24_160000_create_builder_tables.php create mode 100644 Modules/Builder/Http/Controllers/BuilderApiController.php create mode 100644 Modules/Builder/Http/Controllers/BuilderController.php create mode 100644 Modules/Builder/Models/BuilderDocument.php create mode 100644 Modules/Builder/Models/BuilderDocumentVersion.php create mode 100644 Modules/Builder/Models/BuilderTemplate.php create mode 100644 Modules/Builder/Providers/BuilderServiceProvider.php create mode 100644 Modules/Builder/Providers/RouteServiceProvider.php create mode 100644 Modules/Builder/Resources/views/components/hero.blade.php create mode 100644 Modules/Builder/Resources/views/editor.blade.php create mode 100644 Modules/Builder/Resources/views/index.blade.php create mode 100644 Modules/Builder/Routes/api.php create mode 100644 Modules/Builder/Routes/web.php create mode 100644 Modules/Builder/Services/BuilderDocumentService.php create mode 100644 Modules/Builder/Services/BuilderRegistry.php create mode 100644 Modules/Builder/Services/BuilderRenderer.php create mode 100644 Modules/Builder/Tests/Feature/BuilderModuleTest.php create mode 100644 Modules/Builder/module.json diff --git a/Modules/Builder/Components/ContentComponent.php b/Modules/Builder/Components/ContentComponent.php new file mode 100644 index 0000000..c7380e6 --- /dev/null +++ b/Modules/Builder/Components/ContentComponent.php @@ -0,0 +1,12 @@ +['type'=>'textarea','required'=>true]]; } + public function defaults(): array { return ['content'=>'

Add your content here.

']; } + public function render(array $properties, array $context=[]): string { return (string)($properties['content']??''); } +} diff --git a/Modules/Builder/Components/HeadingComponent.php b/Modules/Builder/Components/HeadingComponent.php new file mode 100644 index 0000000..b1ed9b0 --- /dev/null +++ b/Modules/Builder/Components/HeadingComponent.php @@ -0,0 +1,12 @@ +['type'=>'text','required'=>true],'level'=>['type'=>'select','options'=>[1,2,3,4,5,6]]]; } + public function defaults(): array { return ['text'=>'Heading','level'=>2]; } + public function render(array $properties, array $context=[]): string { $level=max(1,min(6,(int)($properties['level']??2))); return ''.e($properties['text']??'').''; } +} diff --git a/Modules/Builder/Components/HeroComponent.php b/Modules/Builder/Components/HeroComponent.php new file mode 100644 index 0000000..cabc7a4 --- /dev/null +++ b/Modules/Builder/Components/HeroComponent.php @@ -0,0 +1,12 @@ +['type'=>'text','required'=>true],'content'=>['type'=>'textarea'],'button_label'=>['type'=>'text'],'button_url'=>['type'=>'text']]; } + public function defaults(): array { return ['heading'=>'A compelling headline','content'=>'Add supporting content here.','button_label'=>'Learn more','button_url'=>'#']; } + public function render(array $properties, array $context=[]): string { return view('builder::components.hero',['properties'=>$properties])->render(); } +} diff --git a/Modules/Builder/Config/config.php b/Modules/Builder/Config/config.php new file mode 100644 index 0000000..d676191 --- /dev/null +++ b/Modules/Builder/Config/config.php @@ -0,0 +1,7 @@ + (int) env('BUILDER_MAX_DOCUMENT_SIZE_KB', 512), + 'autosave_seconds' => (int) env('BUILDER_AUTOSAVE_SECONDS', 30), + 'preview_route' => env('BUILDER_PREVIEW_ROUTE', 'builder.preview'), + 'allowed_types' => ['page','form','dashboard','report','menu','email','query','workflow'], +]; diff --git a/Modules/Builder/Contracts/BuilderComponent.php b/Modules/Builder/Contracts/BuilderComponent.php new file mode 100644 index 0000000..be6dfc2 --- /dev/null +++ b/Modules/Builder/Contracts/BuilderComponent.php @@ -0,0 +1,11 @@ +id(); $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete(); + $table->string('type',30)->index(); $table->string('name'); $table->string('slug')->nullable(); + $table->string('status',20)->default('draft')->index(); $table->unsignedInteger('version')->default(1); + $table->json('document')->nullable(); $table->json('settings')->nullable(); + $table->nullableMorphs('buildable'); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('published_at')->nullable(); $table->timestamps(); + $table->unique(['site_id','type','slug'],'builder_document_site_type_slug_unique'); + }); + Schema::create('builder_document_versions', function (Blueprint $table) { + $table->id(); $table->foreignId('builder_document_id')->constrained('builder_documents')->cascadeOnDelete(); + $table->unsignedInteger('version'); $table->json('document'); $table->json('settings')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps(); + $table->unique(['builder_document_id','version']); + }); + Schema::create('builder_templates', function (Blueprint $table) { + $table->id(); $table->string('type',30)->index(); $table->string('name'); $table->string('slug')->unique(); + $table->text('description')->nullable(); $table->json('document'); $table->json('settings')->nullable(); + $table->boolean('is_system')->default(false); $table->boolean('is_active')->default(true)->index(); $table->timestamps(); + }); + } + public function down(): void { Schema::dropIfExists('builder_templates'); Schema::dropIfExists('builder_document_versions'); Schema::dropIfExists('builder_documents'); } +}; diff --git a/Modules/Builder/Http/Controllers/BuilderApiController.php b/Modules/Builder/Http/Controllers/BuilderApiController.php new file mode 100644 index 0000000..d962c79 --- /dev/null +++ b/Modules/Builder/Http/Controllers/BuilderApiController.php @@ -0,0 +1,13 @@ +json(['data'=>$registry->components(),'widgets'=>$registry->widgets(),'fields'=>$registry->fields()]); } + public function show(BuilderDocument $document): JsonResponse { return response()->json(['data'=>$document]); } + public function preview(BuilderDocument $document, BuilderRenderer $renderer): JsonResponse { return response()->json(['html'=>$renderer->render($document->document ?? [])]); } +} diff --git a/Modules/Builder/Http/Controllers/BuilderController.php b/Modules/Builder/Http/Controllers/BuilderController.php new file mode 100644 index 0000000..386adde --- /dev/null +++ b/Modules/Builder/Http/Controllers/BuilderController.php @@ -0,0 +1,29 @@ +string('type')->toString(); + $documents=BuilderDocument::query()->when($type,fn($q)=>$q->where('type',$type))->latest()->paginate(20)->withQueryString(); + return view('builder::index',compact('documents','type')); + } + public function create(BuilderRegistry $registry): View { return view('builder::editor',['document'=>new BuilderDocument(['type'=>request('type','page'),'document'=>['nodes'=>[]]]),'components'=>$registry->components()]); } + public function edit(BuilderDocument $document, BuilderRegistry $registry): View { return view('builder::editor',['document'=>$document,'components'=>$registry->components()]); } + public function store(Request $request, BuilderDocumentService $service): RedirectResponse { $document=$service->save(new BuilderDocument(),$this->validated($request),$request->user()?->id); return redirect()->route('administration.builder.edit',$document)->with('status','Builder document created.'); } + public function update(Request $request, BuilderDocument $document, BuilderDocumentService $service): RedirectResponse { $service->save($document,$this->validated($request),$request->user()?->id); return back()->with('status','Builder document saved.'); } + public function destroy(BuilderDocument $document): RedirectResponse { $document->delete(); return redirect()->route('administration.builder.index')->with('status','Builder document deleted.'); } + private function validated(Request $request): array + { + $data=$request->validate(['type'=>['required','in:'.implode(',',config('builder.allowed_types'))],'name'=>['required','string','max:255'],'slug'=>['nullable','string','max:255'],'status'=>['required','in:draft,published,archived'],'document_json'=>['required','json'],'settings_json'=>['nullable','json']]); + return ['type'=>$data['type'],'name'=>$data['name'],'slug'=>$data['slug'] ?: Str::slug($data['name']),'status'=>$data['status'],'document'=>json_decode($data['document_json'],true,512,JSON_THROW_ON_ERROR),'settings'=>json_decode($data['settings_json'] ?: '{}',true,512,JSON_THROW_ON_ERROR),'published_at'=>$data['status']==='published'?now():null]; + } +} diff --git a/Modules/Builder/Models/BuilderDocument.php b/Modules/Builder/Models/BuilderDocument.php new file mode 100644 index 0000000..d8162be --- /dev/null +++ b/Modules/Builder/Models/BuilderDocument.php @@ -0,0 +1,10 @@ +'array','settings'=>'array','published_at'=>'datetime']; + public function versions(): HasMany { return $this->hasMany(BuilderDocumentVersion::class); } +} diff --git a/Modules/Builder/Models/BuilderDocumentVersion.php b/Modules/Builder/Models/BuilderDocumentVersion.php new file mode 100644 index 0000000..6ff29f4 --- /dev/null +++ b/Modules/Builder/Models/BuilderDocumentVersion.php @@ -0,0 +1,8 @@ +'array','settings'=>'array']; +} diff --git a/Modules/Builder/Models/BuilderTemplate.php b/Modules/Builder/Models/BuilderTemplate.php new file mode 100644 index 0000000..e857e66 --- /dev/null +++ b/Modules/Builder/Models/BuilderTemplate.php @@ -0,0 +1,8 @@ +'array','settings'=>'array','is_system'=>'boolean','is_active'=>'boolean']; +} diff --git a/Modules/Builder/Providers/BuilderServiceProvider.php b/Modules/Builder/Providers/BuilderServiceProvider.php new file mode 100644 index 0000000..b6059c1 --- /dev/null +++ b/Modules/Builder/Providers/BuilderServiceProvider.php @@ -0,0 +1,20 @@ +app->singleton(BuilderRegistry::class); + $this->app->alias(BuilderRegistry::class, 'builder.registry'); + $this->app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Builder', 'Config/config.php'), 'builder'); + } + public function boot(BuilderRegistry $registry): void + { + $this->loadMigrationsFrom(module_path('Builder', 'Database/Migrations')); + $this->loadViewsFrom(module_path('Builder', 'Resources/views'), 'builder'); + $registry->registerDefaults(); + } +} diff --git a/Modules/Builder/Providers/RouteServiceProvider.php b/Modules/Builder/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..83469a6 --- /dev/null +++ b/Modules/Builder/Providers/RouteServiceProvider.php @@ -0,0 +1,12 @@ +group(module_path('Builder', 'Routes/web.php')); + Route::middleware('api')->prefix('api/v1')->group(module_path('Builder', 'Routes/api.php')); + } +} diff --git a/Modules/Builder/Resources/views/components/hero.blade.php b/Modules/Builder/Resources/views/components/hero.blade.php new file mode 100644 index 0000000..34083c8 --- /dev/null +++ b/Modules/Builder/Resources/views/components/hero.blade.php @@ -0,0 +1 @@ +

{{ $properties['heading'] ?? '' }}

{{ $properties['content'] ?? '' }}

@if(!empty($properties['button_label'])){{ $properties['button_label'] }}@endif
diff --git a/Modules/Builder/Resources/views/editor.blade.php b/Modules/Builder/Resources/views/editor.blade.php new file mode 100644 index 0000000..82c8070 --- /dev/null +++ b/Modules/Builder/Resources/views/editor.blade.php @@ -0,0 +1,10 @@ + +
+
{{ $document->exists ? 'Edit' : 'Create' }} Builder DocumentUse the component palette and JSON document canvas. Visual drag-and-drop clients can use the component API.
+
@csrf @if($document->exists) @method('PUT') @endif +
@foreach(config('builder.allowed_types') as $type)@endforeach
+ +
{{ old('document_json',json_encode($document->document ?: ['nodes'=>[]],JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) }}{{ old('settings_json',json_encode($document->settings ?: new stdClass(),JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) }}
+
SaveCancel
+ +
diff --git a/Modules/Builder/Resources/views/index.blade.php b/Modules/Builder/Resources/views/index.blade.php new file mode 100644 index 0000000..c146805 --- /dev/null +++ b/Modules/Builder/Resources/views/index.blade.php @@ -0,0 +1,7 @@ + +
+
Low-Code BuilderPages, forms, dashboards, reports, menus, emails, queries and workflows.
@can('builder.manage')Create@endcan
+
@foreach(config('builder.allowed_types') as $builderType)
{{ str($builderType)->headline() }}
@endforeach
+
@forelse($documents as $document)@empty@endforelse
NameTypeStatusVersion
{{ $document->name }}{{ str($document->type)->headline() }}{{ ucfirst($document->status) }}{{ $document->version }}Edit
No builder documents yet.
+ {{ $documents->links() }} +
diff --git a/Modules/Builder/Routes/api.php b/Modules/Builder/Routes/api.php new file mode 100644 index 0000000..b50c600 --- /dev/null +++ b/Modules/Builder/Routes/api.php @@ -0,0 +1,9 @@ +prefix('builder')->name('api.builder.')->group(function () { + Route::get('/components',[BuilderApiController::class,'components'])->name('components'); + Route::get('/documents/{document}',[BuilderApiController::class,'show'])->name('show'); + Route::get('/documents/{document}/preview',[BuilderApiController::class,'preview'])->name('preview'); +}); diff --git a/Modules/Builder/Routes/web.php b/Modules/Builder/Routes/web.php new file mode 100644 index 0000000..cd8fbe0 --- /dev/null +++ b/Modules/Builder/Routes/web.php @@ -0,0 +1,11 @@ +prefix('administration/builder')->name('administration.builder.')->group(function () { + Route::get('/',[BuilderController::class,'index'])->name('index'); + Route::get('/create',[BuilderController::class,'create'])->middleware('permission:builder.manage')->name('create'); + Route::post('/',[BuilderController::class,'store'])->middleware('permission:builder.manage')->name('store'); + Route::get('/{document}/edit',[BuilderController::class,'edit'])->middleware('permission:builder.manage')->name('edit'); + Route::put('/{document}',[BuilderController::class,'update'])->middleware('permission:builder.manage')->name('update'); + Route::delete('/{document}',[BuilderController::class,'destroy'])->middleware('permission:builder.manage')->name('destroy'); +}); diff --git a/Modules/Builder/Services/BuilderDocumentService.php b/Modules/Builder/Services/BuilderDocumentService.php new file mode 100644 index 0000000..430b7ec --- /dev/null +++ b/Modules/Builder/Services/BuilderDocumentService.php @@ -0,0 +1,16 @@ +exists && $document->isDirty()) $document->versions()->create(['version'=>$document->version,'document'=>$document->getOriginal('document') ?: [],'settings'=>$document->getOriginal('settings') ?: [],'created_by'=>$userId]); + $document->fill($payload); $document->updated_by=$userId; + if (!$document->exists) $document->created_by=$userId; else $document->version++; + $document->save(); return $document->refresh(); + }); + } +} diff --git a/Modules/Builder/Services/BuilderRegistry.php b/Modules/Builder/Services/BuilderRegistry.php new file mode 100644 index 0000000..b561a89 --- /dev/null +++ b/Modules/Builder/Services/BuilderRegistry.php @@ -0,0 +1,24 @@ +registerComponent(new $component()); + foreach (['metric','chart','table','activity','calendar'] as $widget) $this->widgets[$widget]=['type'=>$widget,'label'=>str($widget)->headline()->toString()]; + foreach (['text','textarea','email','number','select','checkbox','date','file'] as $field) $this->fields[$field]=['type'=>$field,'label'=>str($field)->headline()->toString()]; + } + public function registerComponent(BuilderComponent $component): void { $this->components[$component->type()]=$component; } + public function component(string $type): BuilderComponent { return $this->components[$type] ?? throw new InvalidArgumentException("Unknown builder component [$type]."); } + public function components(): array { return array_map(fn(BuilderComponent $c)=>['type'=>$c->type(),'label'=>$c->label(),'category'=>$c->category(),'schema'=>$c->schema(),'defaults'=>$c->defaults()],array_values($this->components)); } + public function widgets(): array { return array_values($this->widgets); } + public function fields(): array { return array_values($this->fields); } +} diff --git a/Modules/Builder/Services/BuilderRenderer.php b/Modules/Builder/Services/BuilderRenderer.php new file mode 100644 index 0000000..7b76e87 --- /dev/null +++ b/Modules/Builder/Services/BuilderRenderer.php @@ -0,0 +1,13 @@ +map(function(array $node) use ($context) { + if (($node['kind'] ?? 'component') === 'container') return '
'.$this->render($node,$context).'
'; + return $this->registry->component((string)($node['type'] ?? 'content'))->render($node['properties'] ?? [],$context); + })->implode("\n"); + } +} diff --git a/Modules/Builder/Tests/Feature/BuilderModuleTest.php b/Modules/Builder/Tests/Feature/BuilderModuleTest.php new file mode 100644 index 0000000..3ed3a78 --- /dev/null +++ b/Modules/Builder/Tests/Feature/BuilderModuleTest.php @@ -0,0 +1,13 @@ +components(); + $this->assertNotEmpty($components); + $this->assertContains('hero',array_column($components,'type')); + } +} diff --git a/Modules/Builder/module.json b/Modules/Builder/module.json new file mode 100644 index 0000000..a17c5b0 --- /dev/null +++ b/Modules/Builder/module.json @@ -0,0 +1 @@ +{"name":"Builder","alias":"builder","version":"1.0.0","description":"Low-code visual builders for pages, forms, dashboards, reports, menus, emails, queries and workflows.","keywords":["builder","low-code","visual","components","layouts"],"priority":160,"providers":["Modules\\Builder\\Providers\\BuilderServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Pages","Forms","Menus","Workflow","Analytics","Sites"]} diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index eb903e7..ea8bc99 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -79,6 +79,7 @@ final class RolePermissionSeeder extends Seeder 'analytics.view', 'analytics.manage', 'analytics.export', + 'builder.view', 'builder.manage', 'builder.publish', ]; foreach ($permissions as $permission) { diff --git a/modules_statuses.json b/modules_statuses.json index 16d0ae6..8b16202 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -1,45 +1,46 @@ { - "Stylist": true, - "Core": true, - "Administration": true, - "Users": true, - "Roles": true, - "Settings": true, - "Audit": true, - "Media": true, - "Seo": true, - "Search": true, - "Pages": true, - "Menus": true, - "Redirects": true, - "Forms": true, - "Documents": true, - "Banners": true, - "Faq": true, - "Team": true, - "Testimonials": true, - "Categories": true, - "Tags": true, - "Authors": true, - "Blog": true, - "News": true, - "Comments": true, - "Feeds": true, - "Sitemap": true, - "CRM": true, - "Newsletter": true, - "Events": true, - "Careers": true, - "Locations": true, - "Workflow": true, - "Localization": true, - "Sites": true, - "Developer": true, - "Api": true, - "Enterprise": true, - "Commerce": true, - "Security": true, - "Automation": true, - "AI": true, - "Analytics": true + "Stylist": true, + "Core": true, + "Administration": true, + "Users": true, + "Roles": true, + "Settings": true, + "Audit": true, + "Media": true, + "Seo": true, + "Search": true, + "Pages": true, + "Menus": true, + "Redirects": true, + "Forms": true, + "Documents": true, + "Banners": true, + "Faq": true, + "Team": true, + "Testimonials": true, + "Categories": true, + "Tags": true, + "Authors": true, + "Blog": true, + "News": true, + "Comments": true, + "Feeds": true, + "Sitemap": true, + "CRM": true, + "Newsletter": true, + "Events": true, + "Careers": true, + "Locations": true, + "Workflow": true, + "Localization": true, + "Sites": true, + "Developer": true, + "Api": true, + "Enterprise": true, + "Commerce": true, + "Security": true, + "Automation": true, + "AI": true, + "Analytics": true, + "Builder": true }