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
This commit is contained in:
Mark Gregory
2026-07-24 13:29:58 +01:00
parent f30fb6384a
commit 412e8134a1
25 changed files with 335 additions and 43 deletions
@@ -0,0 +1,12 @@
<?php
namespace Modules\Builder\Components;
use Modules\Builder\Contracts\BuilderComponent;
final class ContentComponent implements BuilderComponent
{
public function type(): string { return 'content'; }
public function label(): string { return 'Rich Content'; }
public function category(): string { return 'Content'; }
public function schema(): array { return ['content'=>['type'=>'textarea','required'=>true]]; }
public function defaults(): array { return ['content'=>'<p>Add your content here.</p>']; }
public function render(array $properties, array $context=[]): string { return (string)($properties['content']??''); }
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Builder\Components;
use Modules\Builder\Contracts\BuilderComponent;
final class HeadingComponent implements BuilderComponent
{
public function type(): string { return 'heading'; }
public function label(): string { return 'Heading'; }
public function category(): string { return 'Typography'; }
public function schema(): array { return ['text'=>['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 '<h'.$level.'>'.e($properties['text']??'').'</h'.$level.'>'; }
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Builder\Components;
use Modules\Builder\Contracts\BuilderComponent;
final class HeroComponent implements BuilderComponent
{
public function type(): string { return 'hero'; }
public function label(): string { return 'Hero'; }
public function category(): string { return 'Content'; }
public function schema(): array { return ['heading'=>['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(); }
}
+7
View File
@@ -0,0 +1,7 @@
<?php
return [
'max_document_size_kb' => (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'],
];
@@ -0,0 +1,11 @@
<?php
namespace Modules\Builder\Contracts;
interface BuilderComponent
{
public function type(): string;
public function label(): string;
public function category(): string;
public function schema(): array;
public function defaults(): array;
public function render(array $properties, array $context = []): string;
}
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('builder_documents', function (Blueprint $table) {
$table->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'); }
};
@@ -0,0 +1,13 @@
<?php
namespace Modules\Builder\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
use Modules\Builder\Models\BuilderDocument;
use Modules\Builder\Services\BuilderRegistry;
use Modules\Builder\Services\BuilderRenderer;
final class BuilderApiController extends Controller
{
public function components(BuilderRegistry $registry): JsonResponse { return response()->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 ?? [])]); }
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Builder\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Modules\Builder\Models\BuilderDocument;
use Modules\Builder\Services\BuilderDocumentService;
use Modules\Builder\Services\BuilderRegistry;
final class BuilderController extends Controller
{
public function index(Request $request): View
{
$type=$request->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];
}
}
@@ -0,0 +1,10 @@
<?php
namespace Modules\Builder\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class BuilderDocument extends Model
{
protected $fillable=['site_id','type','name','slug','status','version','document','settings','buildable_type','buildable_id','created_by','updated_by','published_at'];
protected $casts=['document'=>'array','settings'=>'array','published_at'=>'datetime'];
public function versions(): HasMany { return $this->hasMany(BuilderDocumentVersion::class); }
}
@@ -0,0 +1,8 @@
<?php
namespace Modules\Builder\Models;
use Illuminate\Database\Eloquent\Model;
final class BuilderDocumentVersion extends Model
{
protected $fillable=['builder_document_id','version','document','settings','created_by'];
protected $casts=['document'=>'array','settings'=>'array'];
}
@@ -0,0 +1,8 @@
<?php
namespace Modules\Builder\Models;
use Illuminate\Database\Eloquent\Model;
final class BuilderTemplate extends Model
{
protected $fillable=['type','name','slug','description','document','settings','is_system','is_active'];
protected $casts=['document'=>'array','settings'=>'array','is_system'=>'boolean','is_active'=>'boolean'];
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Builder\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Builder\Services\BuilderRegistry;
final class BuilderServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->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();
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Builder\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
Route::middleware('web')->group(module_path('Builder', 'Routes/web.php'));
Route::middleware('api')->prefix('api/v1')->group(module_path('Builder', 'Routes/api.php'));
}
}
@@ -0,0 +1 @@
<section class="py-5 text-center"><div class="container"><h1>{{ $properties['heading'] ?? '' }}</h1><p class="lead">{{ $properties['content'] ?? '' }}</p>@if(!empty($properties['button_label']))<a class="btn btn-primary" href="{{ $properties['button_url'] ?? '#' }}">{{ $properties['button_label'] }}</a>@endif</div></section>
@@ -0,0 +1,10 @@
<x-layouts.app :title="__('Builder Editor')">
<div class="flex h-full w-full flex-1 flex-col gap-5 rounded-xl">
<div><flux:heading size="xl">{{ $document->exists ? 'Edit' : 'Create' }} Builder Document</flux:heading><flux:subheading>Use the component palette and JSON document canvas. Visual drag-and-drop clients can use the component API.</flux:subheading></div>
<form method="POST" action="{{ $document->exists ? route('administration.builder.update',$document) : route('administration.builder.store') }}" class="space-y-5">@csrf @if($document->exists) @method('PUT') @endif
<div class="grid gap-4 md:grid-cols-3"><flux:input name="name" label="Name" value="{{ old('name',$document->name) }}" required/><flux:select name="type" label="Builder type">@foreach(config('builder.allowed_types') as $type)<option value="{{ $type }}" @selected(old('type',$document->type)===$type)>{{ str($type)->headline() }}</option>@endforeach</flux:select><flux:select name="status" label="Status"><option value="draft" @selected(old('status',$document->status)==='draft')>Draft</option><option value="published" @selected(old('status',$document->status)==='published')>Published</option><option value="archived" @selected(old('status',$document->status)==='archived')>Archived</option></flux:select></div>
<flux:input name="slug" label="Slug" value="{{ old('slug',$document->slug) }}"/>
<div class="grid gap-5 xl:grid-cols-[260px_1fr]"><aside class="rounded-xl border p-4"><div class="mb-3 font-semibold">Components</div>@foreach($components as $component)<div class="mb-2 rounded-lg border p-3"><div class="font-medium">{{ $component['label'] }}</div><div class="text-xs text-zinc-500">{{ $component['category'] }} · {{ $component['type'] }}</div></div>@endforeach</aside><div class="space-y-4"><flux:textarea name="document_json" label="Document JSON" rows="22" required>{{ old('document_json',json_encode($document->document ?: ['nodes'=>[]],JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) }}</flux:textarea><flux:textarea name="settings_json" label="Settings JSON" rows="6">{{ old('settings_json',json_encode($document->settings ?: new stdClass(),JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) }}</flux:textarea></div></div>
<div class="flex gap-3"><flux:button type="submit" variant="primary">Save</flux:button><flux:button :href="route('administration.builder.index')" wire:navigate>Cancel</flux:button></div>
</form>
</div></x-layouts.app>
@@ -0,0 +1,7 @@
<x-layouts.app :title="__('Builder')">
<div class="flex h-full w-full flex-1 flex-col gap-6 rounded-xl">
<div class="flex items-center justify-between"><div><flux:heading size="xl">Low-Code Builder</flux:heading><flux:subheading>Pages, forms, dashboards, reports, menus, emails, queries and workflows.</flux:subheading></div>@can('builder.manage')<flux:button variant="primary" :href="route('administration.builder.create')" wire:navigate>Create</flux:button>@endcan</div>
<div class="grid gap-3 sm:grid-cols-4">@foreach(config('builder.allowed_types') as $builderType)<a href="{{ route('administration.builder.index',['type'=>$builderType]) }}" class="rounded-xl border p-4"><div class="font-semibold">{{ str($builderType)->headline() }}</div></a>@endforeach</div>
<div class="overflow-hidden rounded-xl border"><table class="w-full text-sm"><thead><tr class="border-b text-left"><th class="p-3">Name</th><th>Type</th><th>Status</th><th>Version</th><th></th></tr></thead><tbody>@forelse($documents as $document)<tr class="border-b"><td class="p-3 font-medium">{{ $document->name }}</td><td>{{ str($document->type)->headline() }}</td><td>{{ ucfirst($document->status) }}</td><td>{{ $document->version }}</td><td class="text-right p-3"><flux:button size="sm" :href="route('administration.builder.edit',$document)" wire:navigate>Edit</flux:button></td></tr>@empty<tr><td colspan="5" class="p-6 text-center text-zinc-500">No builder documents yet.</td></tr>@endforelse</tbody></table></div>
{{ $documents->links() }}
</div></x-layouts.app>
+9
View File
@@ -0,0 +1,9 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Api\Http\Middleware\AuthenticateApiToken;
use Modules\Builder\Http\Controllers\BuilderApiController;
Route::middleware(AuthenticateApiToken::class)->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');
});
+11
View File
@@ -0,0 +1,11 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Builder\Http\Controllers\BuilderController;
Route::middleware(['auth','permission:builder.view'])->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');
});
@@ -0,0 +1,16 @@
<?php
namespace Modules\Builder\Services;
use Illuminate\Support\Facades\DB;
use Modules\Builder\Models\BuilderDocument;
final class BuilderDocumentService
{
public function save(BuilderDocument $document, array $payload, ?int $userId=null): BuilderDocument
{
return DB::transaction(function () use ($document,$payload,$userId) {
if ($document->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();
});
}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\Builder\Services;
use InvalidArgumentException;
use Modules\Builder\Contracts\BuilderComponent;
use Modules\Builder\Components\ContentComponent;
use Modules\Builder\Components\HeadingComponent;
use Modules\Builder\Components\HeroComponent;
final class BuilderRegistry
{
private array $components=[];
private array $widgets=[];
private array $fields=[];
public function registerDefaults(): void
{
foreach ([HeroComponent::class, HeadingComponent::class, ContentComponent::class] as $component) $this->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); }
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Builder\Services;
final class BuilderRenderer
{
public function __construct(private BuilderRegistry $registry) {}
public function render(array $document, array $context=[]): string
{
return collect($document['nodes'] ?? [])->map(function(array $node) use ($context) {
if (($node['kind'] ?? 'component') === 'container') return '<div class="builder-container">'.$this->render($node,$context).'</div>';
return $this->registry->component((string)($node['type'] ?? 'content'))->render($node['properties'] ?? [],$context);
})->implode("\n");
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Builder\Tests\Feature;
use Modules\Builder\Services\BuilderRegistry;
use Tests\TestCase;
final class BuilderModuleTest extends TestCase
{
public function test_builder_module_registers_components(): void
{
$components=app(BuilderRegistry::class)->components();
$this->assertNotEmpty($components);
$this->assertContains('hero',array_column($components,'type'));
}
}
+1
View File
@@ -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"]}
@@ -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) {
+44 -43
View File
@@ -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
}