From 712b985bc3a7391f3df07517aef31a1c7e23ed8e Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 27 Jul 2026 10:05:53 +0100 Subject: [PATCH] fixed content module ( well reverted) --- Modules/Content/Config/config.php | 5 +- .../Console/InstallSaaslandContent.php | 64 +- ...0000_create_content_composition_tables.php | 58 +- .../Controllers/ContentDocumentController.php | 177 +++-- .../Controllers/PageComposerController.php | 56 -- .../Controllers/PageCompositionController.php | 157 ++++ .../Requests/SaveContentDocumentRequest.php | 37 - Modules/Content/Models/ContentDocument.php | 11 +- Modules/Content/Models/ContentType.php | 8 +- Modules/Content/Models/PageContent.php | 10 +- .../Providers/ContentServiceProvider.php | 17 +- Modules/Content/README.md | 37 + .../views/admin/composition.blade.php | 146 ++++ .../Resources/views/admin/form.blade.php | 692 +++--------------- .../Resources/views/admin/index.blade.php | 57 +- .../views/admin/pages/edit.blade.php | 95 --- .../views/documents/unsupported.blade.php | 3 + Modules/Content/Routes/web.php | 18 +- Modules/Content/Services/PageRenderer.php | 75 +- .../Tests/Feature/ContentModuleTest.php | 11 - Modules/Content/composer.json | 7 - Modules/Content/module.json | 11 +- 22 files changed, 774 insertions(+), 978 deletions(-) delete mode 100644 Modules/Content/Http/Controllers/PageComposerController.php create mode 100644 Modules/Content/Http/Controllers/PageCompositionController.php delete mode 100644 Modules/Content/Http/Requests/SaveContentDocumentRequest.php create mode 100644 Modules/Content/README.md create mode 100644 Modules/Content/Resources/views/admin/composition.blade.php delete mode 100644 Modules/Content/Resources/views/admin/pages/edit.blade.php create mode 100644 Modules/Content/Resources/views/documents/unsupported.blade.php delete mode 100644 Modules/Content/Tests/Feature/ContentModuleTest.php delete mode 100644 Modules/Content/composer.json diff --git a/Modules/Content/Config/config.php b/Modules/Content/Config/config.php index 050383f..b0ba6d5 100644 --- a/Modules/Content/Config/config.php +++ b/Modules/Content/Config/config.php @@ -1,2 +1,5 @@ 'Content']; +return [ + 'default_page_key' => 'home', + 'regions' => ['header', 'body', 'footer'], +]; diff --git a/Modules/Content/Console/InstallSaaslandContent.php b/Modules/Content/Console/InstallSaaslandContent.php index 4e4e558..67fd9e0 100644 --- a/Modules/Content/Console/InstallSaaslandContent.php +++ b/Modules/Content/Console/InstallSaaslandContent.php @@ -1,7 +1,4 @@ error("Missing {$table}. Run php artisan migrate first."); return self::FAILURE; }} - DB::transaction(function(){ - $types=[ - 'hero'=>['name'=>'Hero','schema'=>['eyebrow'=>['label'=>'Eyebrow','control'=>'text'],'title'=>['label'=>'Title','control'=>'text'],'text'=>['label'=>'Text','control'=>'textarea'],'primary_label'=>['label'=>'Primary button label','control'=>'text'],'primary_url'=>['label'=>'Primary button URL','control'=>'url']]], - 'rich-text'=>['name'=>'Rich text','schema'=>['heading'=>['label'=>'Heading','control'=>'text'],'body'=>['label'=>'Body','control'=>'textarea']]], - 'cta'=>['name'=>'Call to action','schema'=>['title'=>['label'=>'Title','control'=>'text'],'text'=>['label'=>'Text','control'=>'textarea'],'button_label'=>['label'=>'Button label','control'=>'text'],'button_url'=>['label'=>'Button URL','control'=>'url']]], - ]; - foreach($types as $key=>$definition) ContentType::updateOrCreate(['key'=>$key],$definition+['description'=>'Saasland '.$definition['name'].' component']); - if($this->option('force')) PageContent::where('page_key','home')->delete(); - if(!PageContent::where('page_key','home')->exists()){ - $type=ContentType::where('key','hero')->firstOrFail(); - $doc=ContentDocument::updateOrCreate(['slug'=>'home-hero'],['content_type_id'=>$type->id,'title'=>'Homepage hero','status'=>'published','data'=>['eyebrow'=>'Composable CMS','title'=>'Build your digital experience','text'=>'Edit this homepage from the Content Composer.','primary_label'=>'Get started','primary_url'=>'/admin/content']]); - PageContent::create(['page_key'=>'home','region'=>'body','content_document_id'=>$doc->id,'sort_order'=>0,'is_enabled'=>true]); + $requiredTables = ['content_types', 'content_documents', 'page_content']; + $missingTables = array_values(array_filter( + $requiredTables, + static fn (string $table): bool => ! Schema::hasTable($table), + )); + + if ($missingTables !== []) { + $this->components->error('Content composition tables are missing: '.implode(', ', $missingTables).'.'); + $this->components->info('Run php artisan migrate before installing the Saasland composition.'); + + return self::FAILURE; + } + + DB::transaction(function (): void { + if ($this->option('force')) PageContent::query()->where('page_key','home')->delete(); + $definitions = $this->definitions(); + $order = ['site-header'=>['header',10], 'hero'=>['body',10], 'feature-grid'=>['body',20], 'product-feed'=>['body',30], 'testimonial-feed'=>['body',40], 'team-feed'=>['body',50], 'blog-feed'=>['body',60], 'news-feed'=>['body',70], 'faq-feed'=>['body',80], 'newsletter-form'=>['body',90], 'call-to-action'=>['body',100], 'site-footer'=>['footer',10]]; + foreach ($definitions as $key => $definition) { + $type = ContentType::query()->updateOrCreate(['key'=>$key], $definition['type']); + $document = ContentDocument::query()->updateOrCreate(['slug'=>'saasland-'.$key], ['content_type_id'=>$type->id,'title'=>$definition['title'],'data'=>$definition['data'],'status'=>'published','published_at'=>now()]); + [$region,$sort] = $order[$key]; + PageContent::query()->updateOrCreate(['page_key'=>'home','region'=>$region,'sort_order'=>$sort], ['content_document_id'=>$document->id,'is_enabled'=>true]); } }); - $this->info('Saasland content installed.'); return self::SUCCESS; + $this->components->info('Saasland content composition installed.'); + return self::SUCCESS; + } + + private function definitions(): array + { + $type = fn(string $name,string $renderer,array $regions) => ['name'=>$name,'renderer'=>$renderer,'allowed_regions'=>$regions,'is_system'=>true,'is_active'=>true,'schema'=>[]]; + return [ + 'site-header'=>['title'=>'Main website header','type'=>$type('Site header','site-header',['header']),'data'=>['show_login'=>true,'show_registration'=>true,'sticky'=>true]], + 'hero'=>['title'=>'Homepage hero','type'=>$type('Hero','hero',['body']),'data'=>['eyebrow'=>'A better way to run your business','heading'=>'Transform Your Business With Our SaaS Solution','description'=>'Streamline operations, boost productivity, and improve customer satisfaction with one connected platform built for modern teams.','primary_label'=>'Get started free','primary_url'=>'/register','secondary_label'=>'Book demo','secondary_url'=>'#contact','show_metrics'=>true]], + 'feature-grid'=>['title'=>'Core features','type'=>$type('Feature grid','feature-grid',['body']),'data'=>['eyebrow'=>'Powerful features','heading'=>'Everything your team needs to grow','description'=>'Planning, collaboration, reporting, publishing, commerce and customer insight in one extensible platform.','items'=>[['title'=>'Smart automation','description'=>'Remove repetitive work and delays.'],['title'=>'Real-time analytics','description'=>'Turn activity into useful business insight.'],['title'=>'Content operations','description'=>'Create, review and publish confidently.'],['title'=>'Secure by design','description'=>'Protect users and data with dependable controls.'],['title'=>'Flexible integrations','description'=>'Connect the tools your organisation already uses.'],['title'=>'Modular architecture','description'=>'Activate only the capabilities each site needs.']]]], + 'product-feed'=>['title'=>'Pricing plans','type'=>$type('Product feed','product-feed',['body']),'data'=>['eyebrow'=>'Simple pricing','heading'=>'Choose a plan that fits','description'=>'Plans are managed from Products & Pricing in the CMS.','limit'=>6]], + 'testimonial-feed'=>['title'=>'Customer stories','type'=>$type('Testimonial feed','testimonial-feed',['body']),'data'=>['eyebrow'=>'Customer stories','heading'=>'Trusted by ambitious teams','limit'=>6]], + 'team-feed'=>['title'=>'Team','type'=>$type('Team feed','team-feed',['body']),'data'=>['eyebrow'=>'Our team','heading'=>'People behind the platform','limit'=>8]], + 'blog-feed'=>['title'=>'Latest blog posts','type'=>$type('Blog feed','blog-feed',['body']),'data'=>['eyebrow'=>'Insights','heading'=>'Latest from the blog','limit'=>3]], + 'news-feed'=>['title'=>'Latest news','type'=>$type('News feed','news-feed',['body']),'data'=>['eyebrow'=>'Newsroom','heading'=>'Latest news','limit'=>3]], + 'faq-feed'=>['title'=>'Frequently asked questions','type'=>$type('FAQ feed','faq-feed',['body']),'data'=>['eyebrow'=>'FAQ','heading'=>'Answers before you begin','limit'=>12]], + 'newsletter-form'=>['title'=>'Newsletter signup','type'=>$type('Newsletter form','newsletter-form',['body']),'data'=>['eyebrow'=>'Newsletter','heading'=>'Useful updates, no noise','description'=>'Receive product news, publishing guidance, and practical platform updates.','button_label'=>'Subscribe']], + 'call-to-action'=>['title'=>'Homepage final call to action','type'=>$type('Call to action','call-to-action',['body']),'data'=>['heading'=>'Ready to transform your business?','description'=>'Start building a faster, more connected operation today.','button_label'=>'Get started free','button_url'=>'/register']], + 'site-footer'=>['title'=>'Main website footer','type'=>$type('Site footer','site-footer',['footer']),'data'=>['description'=>'One connected platform for modern teams.','show_social_links'=>true,'show_back_to_top'=>true]], + ]; } } diff --git a/Modules/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php b/Modules/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php index d935f3a..2fcd905 100644 --- a/Modules/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php +++ b/Modules/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php @@ -5,10 +5,58 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { - public function up(): void { - if (!Schema::hasTable('content_types')) Schema::create('content_types', function(Blueprint $t){$t->id();$t->string('key')->unique();$t->string('name');$t->text('description')->nullable();$t->json('schema')->nullable();$t->timestamps();}); - if (!Schema::hasTable('content_documents')) Schema::create('content_documents', function(Blueprint $t){$t->id();$t->foreignId('content_type_id')->constrained('content_types')->cascadeOnDelete();$t->string('title');$t->string('slug')->unique();$t->string('status')->default('draft')->index();$t->json('data')->nullable();$t->timestamp('published_at')->nullable();$t->timestamp('expires_at')->nullable();$t->timestamps();}); - if (!Schema::hasTable('page_content')) Schema::create('page_content', function(Blueprint $t){$t->id();$t->string('page_key')->index();$t->string('region')->default('body')->index();$t->foreignId('content_document_id')->constrained('content_documents')->cascadeOnDelete();$t->unsignedInteger('sort_order')->default(0);$t->boolean('is_enabled')->default(true);$t->timestamp('published_at')->nullable();$t->timestamp('expires_at')->nullable();$t->timestamps();$t->index(['page_key','region','sort_order']);}); + public function up(): void + { + Schema::create('content_types', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('key')->unique(); + $table->text('description')->nullable(); + $table->json('schema')->nullable(); + $table->string('renderer'); + $table->json('allowed_regions')->nullable(); + $table->boolean('is_system')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('content_documents', function (Blueprint $table): void { + $table->id(); + $table->foreignId('content_type_id')->constrained('content_types')->cascadeOnDelete(); + $table->string('title'); + $table->string('slug')->unique(); + $table->json('data')->nullable(); + $table->string('status')->default('draft')->index(); + $table->timestamp('published_at')->nullable()->index(); + $table->timestamp('expires_at')->nullable(); + $table->string('language', 12)->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('page_content', function (Blueprint $table): void { + $table->id(); + $table->foreignId('page_id')->nullable()->constrained('pages')->cascadeOnDelete(); + $table->string('page_key')->nullable()->index(); + $table->foreignId('content_document_id')->constrained('content_documents')->cascadeOnDelete(); + $table->string('region')->index(); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('is_enabled')->default(true); + $table->json('visibility_rules')->nullable(); + $table->json('presentation')->nullable(); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + $table->unique(['page_key', 'region', 'sort_order'], 'page_content_key_region_order_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('page_content'); + Schema::dropIfExists('content_documents'); + Schema::dropIfExists('content_types'); } - public function down(): void { Schema::dropIfExists('page_content'); Schema::dropIfExists('content_documents'); Schema::dropIfExists('content_types'); } }; diff --git a/Modules/Content/Http/Controllers/ContentDocumentController.php b/Modules/Content/Http/Controllers/ContentDocumentController.php index 936456b..af35ce9 100644 --- a/Modules/Content/Http/Controllers/ContentDocumentController.php +++ b/Modules/Content/Http/Controllers/ContentDocumentController.php @@ -1,16 +1,14 @@ with(['type','placements']) - ->when($request->filled('search'), fn ($q) => $q->where(fn ($q) => $q->where('title','like','%'.$request->string('search').'%')->orWhere('slug','like','%'.$request->string('search').'%'))) - ->when($request->filled('type'), fn ($q) => $q->whereHas('type', fn ($q) => $q->where('key',$request->string('type')))) - ->when($request->filled('status'), fn ($q) => $q->where('status',$request->string('status'))) - ->latest()->paginate(25)->withQueryString(); - return view('content::admin.index', ['documents'=>$documents, 'types'=>ContentType::orderBy('name')->get()]); + $documents = ContentDocument::query() + ->with('type') + ->withCount('placements') + ->when($request->string('type')->isNotEmpty(), fn ($query) => $query->whereHas('type', fn ($type) => $type->where('key', $request->string('type')->toString()))) + ->when($request->string('status')->isNotEmpty(), fn ($query) => $query->where('status', $request->string('status')->toString())) + ->when($request->string('search')->isNotEmpty(), fn ($query) => $query->where('title', 'like', '%'.$request->string('search')->toString().'%')) + ->orderBy('title') + ->paginate(30) + ->withQueryString(); + + return view('content::admin.index', [ + 'documents' => $documents, + 'types' => ContentType::query()->where('is_active', true)->orderBy('name')->get(), + ]); } public function create(Request $request): View { - $types = ContentType::orderBy('name')->get(); + $types = ContentType::query()->where('is_active', true)->orderBy('name')->get(); $selectedType = $types->firstWhere('id', (int) $request->integer('type')) ?? $types->first(); - return view('content::admin.form', ['document'=>new ContentDocument(['status'=>'draft','content_type_id'=>$selectedType?->id]), 'types'=>$types, 'selectedType'=>$selectedType]); + + return view('content::admin.form', [ + 'document' => new ContentDocument(['status' => 'draft', 'data' => []]), + 'types' => $types, + 'selectedType' => $selectedType, + ]); } - public function store(SaveContentDocumentRequest $request): RedirectResponse + public function store(Request $request): RedirectResponse { - $document = ContentDocument::create($request->payload()); - return redirect()->route('content.documents.edit',$document)->with('status','Content document created.'); + $validated = $this->validateDocument($request); + $type = ContentType::query()->where('is_active', true)->findOrFail($validated['content_type_id']); + + $document = ContentDocument::query()->create([ + 'content_type_id' => $type->id, + 'title' => $validated['title'], + 'slug' => $this->uniqueSlug($validated['slug'] ?: $validated['title']), + 'status' => $validated['status'], + 'published_at' => $validated['status'] === 'published' ? ($validated['published_at'] ?? now()) : null, + 'expires_at' => $validated['expires_at'] ?? null, + 'data' => json_decode($validated['data_json'], true, 512, JSON_THROW_ON_ERROR), + 'created_by' => $request->user()?->id, + 'updated_by' => $request->user()?->id, + ]); + + return redirect()->route('content.documents.edit', $document)->with('status', 'Content document created.'); } public function edit(ContentDocument $document): View { - $document->load('placements'); - return view('content::admin.form', ['document'=>$document, 'types'=>ContentType::orderBy('name')->get(), 'selectedType'=>$document->type()->first()]); - } + $document->load('type', 'placements'); - public function update(SaveContentDocumentRequest $request, ContentDocument $document): RedirectResponse - { - $document->update($request->payload()); - return back()->with('status','Content document saved.'); - } - - public function autosave(Request $request, ContentDocument $document): JsonResponse - { - $validated = $request->validate([ - 'content_type_id' => ['required', 'integer', 'exists:content_types,id'], - 'title' => ['required', 'string', 'max:255'], - 'slug' => ['nullable', 'string', 'max:255'], - 'status' => ['required', 'in:draft,published,archived'], - 'data_json' => ['required', 'json'], - 'published_at' => ['nullable', 'date'], - 'expires_at' => ['nullable', 'date', 'after:published_at'], + return view('content::admin.form', [ + 'document' => $document, + 'types' => ContentType::query()->where('is_active', true)->orderBy('name')->get(), + 'selectedType' => $document->type, ]); + } + + public function update(Request $request, ContentDocument $document): RedirectResponse + { + $validated = $this->validateDocument($request, $document); $document->update([ 'content_type_id' => $validated['content_type_id'], 'title' => $validated['title'], - 'slug' => filled($validated['slug'] ?? null) ? $validated['slug'] : str($validated['title'])->slug(), + 'slug' => $this->uniqueSlug($validated['slug'] ?: $validated['title'], $document), 'status' => $validated['status'], - 'data' => json_decode($validated['data_json'], true, flags: JSON_THROW_ON_ERROR), - 'published_at' => $validated['published_at'] ?? null, + 'published_at' => $validated['status'] === 'published' ? ($validated['published_at'] ?? $document->published_at ?? now()) : null, 'expires_at' => $validated['expires_at'] ?? null, + 'data' => json_decode($validated['data_json'], true, 512, JSON_THROW_ON_ERROR), + 'updated_by' => $request->user()?->id, ]); - return response()->json([ - 'saved' => true, - 'saved_at' => now()->toIso8601String(), - 'updated_at' => $document->fresh()->updated_at?->toIso8601String(), - ]); + return back()->with('status', 'Content document updated.'); } - public function preview(Request $request): JsonResponse + public function duplicate(Request $request, ContentDocument $document): RedirectResponse { - $validated = $request->validate([ - 'content_type_id' => ['required', 'integer', 'exists:content_types,id'], - 'data_json' => ['required', 'json'], - ]); - - $type = ContentType::query()->findOrFail($validated['content_type_id']); - $view = 'stylist-theme-saasland::documents.'.$type->key; - - if (! ViewFacade::exists($view)) { - return response()->json([ - 'html' => '
No Saasland renderer exists for this content type yet.
', - 'view' => $view, - ]); - } - - $data = json_decode($validated['data_json'], true, flags: JSON_THROW_ON_ERROR); - - return response()->json([ - 'html' => ViewFacade::make($view, array_merge($data, [ - 'data' => $data, - 'document' => new ContentDocument(['data' => $data]), - 'placement' => null, - 'preview' => true, - ]))->render(), - 'view' => $view, - ]); - } - - public function duplicate(ContentDocument $document): RedirectResponse - { - $copy = $document->replicate(['slug']); + $copy = $document->replicate(['slug', 'status', 'published_at', 'created_by', 'updated_by']); $copy->title = $document->title.' copy'; - $copy->slug = str($copy->title)->slug().'-'.str()->lower(str()->random(6)); + $copy->slug = $this->uniqueSlug($copy->title); $copy->status = 'draft'; + $copy->published_at = null; + $copy->created_by = $request->user()?->id; + $copy->updated_by = $request->user()?->id; $copy->save(); - return redirect()->route('content.documents.edit',$copy)->with('status','Independent copy created.'); + + return redirect()->route('content.documents.edit', $copy)->with('status', 'Independent draft copy created.'); } public function destroy(ContentDocument $document): RedirectResponse { - abort_if($document->placements()->exists(), 422, 'A document used by a page cannot be deleted.'); + if ($document->placements()->exists()) { + return back()->withErrors(['document' => 'Remove this document from every page before deleting it.']); + } + $document->delete(); - return redirect()->route('content.index')->with('status','Content document deleted.'); + + return redirect()->route('content.index')->with('status', 'Content document deleted.'); + } + + private function validateDocument(Request $request, ?ContentDocument $document = null): array + { + return $request->validate([ + 'content_type_id' => ['required', 'integer', Rule::exists('content_types', 'id')->where('is_active', true)], + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255'], + 'status' => ['required', Rule::in(['draft', 'published'])], + 'published_at' => ['nullable', 'date'], + 'expires_at' => ['nullable', 'date', 'after:published_at'], + 'data_json' => ['required', 'json'], + ]); + } + + private function uniqueSlug(string $value, ?ContentDocument $except = null): string + { + $base = Str::slug($value) ?: 'content-document'; + $slug = $base; + $suffix = 2; + + while (ContentDocument::withTrashed()->where('slug', $slug)->when($except, fn ($query) => $query->whereKeyNot($except->getKey()))->exists()) { + $slug = $base.'-'.$suffix++; + } + + return $slug; } } diff --git a/Modules/Content/Http/Controllers/PageComposerController.php b/Modules/Content/Http/Controllers/PageComposerController.php deleted file mode 100644 index 8c2ac8b..0000000 --- a/Modules/Content/Http/Controllers/PageComposerController.php +++ /dev/null @@ -1,56 +0,0 @@ -$pageKey, - 'placements'=>PageContent::with('document.type')->where('page_key',$pageKey)->orderBy('region')->orderBy('sort_order')->get()->groupBy('region'), - 'documents'=>ContentDocument::with('type')->orderBy('title')->get(), - ]); - } - - public function store(Request $request, string $pageKey): RedirectResponse - { - $data=$request->validate(['content_document_id'=>['required','exists:content_documents,id'],'region'=>['required',Rule::in(['header','body','footer'])]]); - $data['page_key']=$pageKey; $data['is_enabled']=true; - $data['sort_order']=(int) PageContent::where('page_key',$pageKey)->where('region',$data['region'])->max('sort_order')+1; - PageContent::create($data); - return back()->with('status','Placement added.'); - } - - public function update(Request $request, string $pageKey, PageContent $placement): RedirectResponse - { - abort_unless($placement->page_key===$pageKey,404); - $placement->update($request->validate(['region'=>['sometimes',Rule::in(['header','body','footer'])],'is_enabled'=>['sometimes','boolean'],'published_at'=>['nullable','date'],'expires_at'=>['nullable','date','after:published_at']])); - return back()->with('status','Placement updated.'); - } - - public function destroy(string $pageKey, PageContent $placement): RedirectResponse - { - abort_unless($placement->page_key===$pageKey,404); $placement->delete(); - return back()->with('status','Placement removed.'); - } - - public function reorder(Request $request, string $pageKey): JsonResponse - { - $data=$request->validate(['region'=>['required',Rule::in(['header','body','footer'])],'ids'=>['required','array'],'ids.*'=>['integer']]); - DB::transaction(function() use($data,$pageKey){ foreach($data['ids'] as $i=>$id){ PageContent::whereKey($id)->where('page_key',$pageKey)->where('region',$data['region'])->update(['sort_order'=>$i]); }}); - return response()->json(['saved'=>true]); - } -} diff --git a/Modules/Content/Http/Controllers/PageCompositionController.php b/Modules/Content/Http/Controllers/PageCompositionController.php new file mode 100644 index 0000000..af0ce60 --- /dev/null +++ b/Modules/Content/Http/Controllers/PageCompositionController.php @@ -0,0 +1,157 @@ +with('document.type') + ->where('page_key', $pageKey) + ->orderByRaw("CASE region WHEN 'header' THEN 1 WHEN 'body' THEN 2 ELSE 3 END") + ->orderBy('sort_order') + ->get() + ->groupBy('region'); + + $documents = ContentDocument::query() + ->with('type') + ->withCount('placements') + ->orderBy('title') + ->get(); + + return view('content::admin.composition', compact('pageKey', 'placements', 'documents')); + } + + public function preview(string $pageKey): View + { + abort_unless($pageKey === 'home', 404, 'This phase currently provides the Saasland homepage preview.'); + + return view('stylist-theme-saasland::home', ['pageKey' => $pageKey]); + } + + public function store(Request $request, string $pageKey): RedirectResponse + { + $validated = $request->validate([ + 'content_document_id' => ['required', 'integer', Rule::exists('content_documents', 'id')->whereNull('deleted_at')], + 'region' => ['required', Rule::in(self::REGIONS)], + ]); + + $document = ContentDocument::query()->with('type')->findOrFail($validated['content_document_id']); + abort_unless(in_array($validated['region'], $document->type?->allowed_regions ?? [], true), 422, 'This content type is not allowed in the selected region.'); + + $nextOrder = ((int) PageContent::query()->where('page_key', $pageKey)->where('region', $validated['region'])->max('sort_order')) + 10; + PageContent::query()->create([ + 'page_key' => $pageKey, + 'content_document_id' => $document->id, + 'region' => $validated['region'], + 'sort_order' => $nextOrder, + 'is_enabled' => true, + ]); + + return back()->with('status', 'Document added to the '.$validated['region'].' region.'); + } + + public function update(Request $request, PageContent $placement): RedirectResponse + { + $validated = $request->validate([ + 'is_enabled' => ['nullable', 'boolean'], + 'starts_at' => ['nullable', 'date'], + 'ends_at' => ['nullable', 'date', 'after:starts_at'], + ]); + + $placement->update([ + 'is_enabled' => $request->boolean('is_enabled'), + 'starts_at' => $validated['starts_at'] ?? null, + 'ends_at' => $validated['ends_at'] ?? null, + ]); + + return back()->with('status', 'Page placement updated.'); + } + + public function destroy(PageContent $placement): RedirectResponse + { + $placement->delete(); + $this->normaliseRegion($placement->page_key, $placement->region); + + return back()->with('status', 'Document removed from this page. The reusable document was not deleted.'); + } + + public function duplicate(Request $request, PageContent $placement): RedirectResponse + { + $source = $placement->document()->firstOrFail(); + $copy = DB::transaction(function () use ($request, $placement, $source): ContentDocument { + $copy = $source->replicate(['slug', 'status', 'published_at', 'created_by', 'updated_by']); + $copy->title = $source->title.' copy'; + $copy->slug = $source->slug.'-copy-'.strtolower(str()->random(6)); + $copy->status = 'draft'; + $copy->published_at = null; + $copy->created_by = $request->user()?->id; + $copy->updated_by = $request->user()?->id; + $copy->save(); + + PageContent::query()->create([ + 'page_id' => $placement->page_id, + 'page_key' => $placement->page_key, + 'content_document_id' => $copy->id, + 'region' => $placement->region, + 'sort_order' => $placement->sort_order + 5, + 'is_enabled' => false, + 'visibility_rules' => $placement->visibility_rules, + 'presentation' => $placement->presentation, + ]); + + return $copy; + }); + + $this->normaliseRegion($placement->page_key, $placement->region); + + return redirect()->route('content.documents.edit', $copy)->with('status', 'Page-specific draft copy created and added after the original.'); + } + + public function reorder(Request $request, string $pageKey): JsonResponse|RedirectResponse + { + $validated = $request->validate([ + 'region' => ['required', Rule::in(self::REGIONS)], + 'placements' => ['required', 'array'], + 'placements.*' => ['required', 'integer'], + ]); + + $ids = array_values(array_unique(array_map('intval', $validated['placements']))); + $owned = PageContent::query()->where('page_key', $pageKey)->where('region', $validated['region'])->whereIn('id', $ids)->pluck('id')->all(); + abort_unless(count($owned) === count($ids), 422, 'One or more placements do not belong to this page region.'); + + DB::transaction(function () use ($ids): void { + PageContent::query()->whereIn('id', $ids)->increment('sort_order', 100000); + foreach ($ids as $index => $id) { + PageContent::query()->whereKey($id)->update(['sort_order' => ($index + 1) * 10]); + } + }); + + return $request->expectsJson() + ? response()->json(['message' => 'Order saved.']) + : back()->with('status', 'Page order updated.'); + } + + private function normaliseRegion(?string $pageKey, string $region): void + { + $ids = PageContent::query()->where('page_key', $pageKey)->where('region', $region)->orderBy('sort_order')->pluck('id'); + DB::transaction(function () use ($ids): void { + foreach ($ids as $index => $id) { + PageContent::query()->whereKey($id)->update(['sort_order' => ($index + 1) * 10]); + } + }); + } +} diff --git a/Modules/Content/Http/Requests/SaveContentDocumentRequest.php b/Modules/Content/Http/Requests/SaveContentDocumentRequest.php deleted file mode 100644 index 83fec3a..0000000 --- a/Modules/Content/Http/Requests/SaveContentDocumentRequest.php +++ /dev/null @@ -1,37 +0,0 @@ -route('document'); - return [ - 'content_type_id' => ['required','integer','exists:content_types,id'], - 'title' => ['required','string','max:255'], - 'slug' => ['nullable','string','max:255', Rule::unique('content_documents','slug')->ignore($document instanceof ContentDocument ? $document->id : null)], - 'status' => ['required', Rule::in(['draft','published','archived'])], - 'data_json' => ['required','json'], - 'published_at' => ['nullable','date'], - 'expires_at' => ['nullable','date','after:published_at'], - ]; - } - - public function payload(): array - { - $validated = $this->validated(); - $validated['slug'] = filled($validated['slug'] ?? null) ? $validated['slug'] : str($validated['title'])->slug(); - $validated['data'] = json_decode($validated['data_json'], true, flags: JSON_THROW_ON_ERROR); - unset($validated['data_json']); - return $validated; - } -} diff --git a/Modules/Content/Models/ContentDocument.php b/Modules/Content/Models/ContentDocument.php index 5370b57..edf93be 100644 --- a/Modules/Content/Models/ContentDocument.php +++ b/Modules/Content/Models/ContentDocument.php @@ -1,18 +1,17 @@ 'array','published_at'=>'datetime','expires_at'=>'datetime']; - + use SoftDeletes; + protected $fillable = ['content_type_id','title','slug','data','status','published_at','expires_at','language','created_by','updated_by']; + protected function casts(): array { return ['data'=>'array','published_at'=>'datetime','expires_at'=>'datetime']; } public function type(): BelongsTo { return $this->belongsTo(ContentType::class, 'content_type_id'); } public function placements(): HasMany { return $this->hasMany(PageContent::class); } + public function scopePublished($query) { return $query->where('status','published')->where(fn($q) => $q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn($q) => $q->whereNull('expires_at')->orWhere('expires_at','>',now())); } } diff --git a/Modules/Content/Models/ContentType.php b/Modules/Content/Models/ContentType.php index 868f129..6b0ffd7 100644 --- a/Modules/Content/Models/ContentType.php +++ b/Modules/Content/Models/ContentType.php @@ -1,7 +1,4 @@ 'array']; - + protected $fillable = ['name','key','description','schema','renderer','allowed_regions','is_system','is_active']; + protected function casts(): array { return ['schema'=>'array','allowed_regions'=>'array','is_system'=>'boolean','is_active'=>'boolean']; } public function documents(): HasMany { return $this->hasMany(ContentDocument::class); } } diff --git a/Modules/Content/Models/PageContent.php b/Modules/Content/Models/PageContent.php index 7a0e352..cb204f3 100644 --- a/Modules/Content/Models/PageContent.php +++ b/Modules/Content/Models/PageContent.php @@ -1,17 +1,15 @@ 'boolean','published_at'=>'datetime','expires_at'=>'datetime']; - + protected $fillable = ['page_id','page_key','content_document_id','region','sort_order','is_enabled','visibility_rules','presentation','starts_at','ends_at']; + protected function casts(): array { return ['is_enabled'=>'boolean','visibility_rules'=>'array','presentation'=>'array','starts_at'=>'datetime','ends_at'=>'datetime']; } public function document(): BelongsTo { return $this->belongsTo(ContentDocument::class, 'content_document_id'); } + public function page(): BelongsTo { return $this->belongsTo(Page::class); } } diff --git a/Modules/Content/Providers/ContentServiceProvider.php b/Modules/Content/Providers/ContentServiceProvider.php index a47bf41..0ba0b27 100644 --- a/Modules/Content/Providers/ContentServiceProvider.php +++ b/Modules/Content/Providers/ContentServiceProvider.php @@ -1,7 +1,4 @@ mergeConfigFrom(__DIR__.'/../Config/config.php', 'content'); + $this->mergeConfigFrom(module_path('Content','Config/config.php'),'content'); $this->app->singleton(PageRenderer::class); } - public function boot(): void { - $this->loadRoutesFrom(__DIR__.'/../Routes/web.php'); - $this->loadViewsFrom(__DIR__.'/../Resources/views', 'content'); - $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations'); - - if ($this->app->runningInConsole()) { - $this->commands([InstallSaaslandContent::class]); - } + $this->loadMigrationsFrom(module_path('Content','Database/Migrations')); + $this->loadViewsFrom(module_path('Content','Resources/views'),'content'); + $this->loadRoutesFrom(module_path('Content','Routes/web.php')); + if ($this->app->runningInConsole()) $this->commands([InstallSaaslandContent::class]); } } diff --git a/Modules/Content/README.md b/Modules/Content/README.md new file mode 100644 index 0000000..b1a800d --- /dev/null +++ b/Modules/Content/README.md @@ -0,0 +1,37 @@ +# Content Composer + +The Content module provides reusable typed documents and ordered page regions. + +## Phase 2 features + +- Header, body and footer composition editor. +- Drag-and-drop ordering with server-side ownership validation. +- Add existing published or draft documents to a page. +- Remove a placement without deleting the reusable document. +- Create an independent page-specific draft copy. +- Shared-use warnings and deletion protection. +- Placement enable/disable and date scheduling. +- Content document creation, duplication, filtering and publishing controls. +- Authenticated Saasland homepage preview, including draft labels. +- Content Composer entry in the administration sidebar. + +## Setup + +```bash +php artisan migrate +php artisan content:install-saasland +``` + +Reset the seeded homepage composition when required: + +```bash +php artisan content:install-saasland --force +``` + +Administration: + +- `/admin/content` +- `/admin/content/pages/home` +- `/admin/content/pages/home/preview` + +The public theme remains backward-compatible: when no composition exists, the legacy Saasland homepage, header and footer are rendered. diff --git a/Modules/Content/Resources/views/admin/composition.blade.php b/Modules/Content/Resources/views/admin/composition.blade.php new file mode 100644 index 0000000..de53bf2 --- /dev/null +++ b/Modules/Content/Resources/views/admin/composition.blade.php @@ -0,0 +1,146 @@ + +
+
+
+
Content documents/Page composition
+

{{ ucfirst($pageKey) }} page

+

Drag documents to reorder each region. Reusing a document keeps content synchronised across pages.

+
+
+ Create document + Open preview +
+
+ + @if(session('status'))
{{ session('status') }}
@endif + @if($errors->any())
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
@endif + +
+
+ @foreach(['header', 'body', 'footer'] as $region) +
+
+

{{ ucfirst($region) }}

{{ $region === 'body' ? 'Ordered content sections' : 'Page-level '.$region.' document' }}

+ {{ $placements->get($region, collect())->count() }} document{{ $placements->get($region, collect())->count() === 1 ? '' : 's' }} +
+
+ @forelse($placements->get($region, collect()) as $placement) +
+
+ +
+
+

{{ $placement->document?->title }}

+ {{ $placement->document?->type?->name }} + @if(($placement->document?->placements()->count() ?? 0) > 1)Shared@endif + @unless($placement->is_enabled)Disabled@endunless +
+

Order {{ $placement->sort_order }} · {{ $placement->document?->status === 'published' ? 'Published' : 'Draft — hidden from public rendering' }}

+
+ Edit +
+ +
+ Placement settings +
+
+ @csrf @method('PUT') + + +
+ + Save +
+ +
+
+
@csrfCreate page-specific copy
+
@csrf @method('DELETE')Remove from page
+
+
+
+ @empty +
No documents assigned to this region.
+ @endforelse +
+
+ @endforeach +
+ + +
+
+ +@push('scripts') + +@endpush +
diff --git a/Modules/Content/Resources/views/admin/form.blade.php b/Modules/Content/Resources/views/admin/form.blade.php index 80c8e0a..ebaa180 100644 --- a/Modules/Content/Resources/views/admin/form.blade.php +++ b/Modules/Content/Resources/views/admin/form.blade.php @@ -1,640 +1,120 @@ -@php - $builderSchema = $selectedType?->schema ?: []; - $builderData = old('data_json', json_encode($document->data ?: [], JSON_UNESCAPED_SLASHES)); -@endphp -
+
Content documents / - {{ $document->exists ? 'Visual editor' : 'Create' }} + {{ $document->exists ? 'Edit' : 'Create' }}

{{ $document->exists ? $document->title : 'Create content document' }}

-

Build and edit structured content visually. The stored document format remains fully compatible with the existing renderer.

+

Structured, reusable content that can be placed into one or more page regions.

@if($document->exists)
-
@csrfCreate independent copy
-
@csrf @method('DELETE')Delete
+
+ @csrf + Create independent copy +
+
+ @csrf @method('DELETE') + Delete +
@endif
- @if(session('status'))
{{ session('status') }}
@endif - @if($errors->any()) -
The document could not be saved.
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
+ @if(session('status')) +
{{ session('status') }}
@endif - @if($document->exists && $document->placements->count() > 1) -
Shared document. This document is used in {{ $document->placements->count() }} page placements. Changes appear everywhere it is published.
+ @if($errors->any()) +
+ The document could not be saved. +
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
+
@endif -
+ @if($document->exists && $document->placements->count() > 1) +
+ Shared document. This document is used in {{ $document->placements->count() }} page placements. Changes will appear everywhere it is published. Use “Create independent copy” for page-specific content. +
+ @endif + + @csrf @if($document->exists) @method('PUT') @endif - -
- - - - @foreach($types as $type)@endforeach - - -
+
+ +

Document

+ + + + @foreach($types as $type) + + @endforeach + +
-
- - -
-
-
Saved
-

Canvas

{{ $selectedType?->name ?? 'Content document' }} · live structured preview

-
-
- - - - -
-
+ +
+
+

Structured data

+

This phase retains JSON editing while adding validation, copies, placement controls and preview. Schema-generated field controls follow in the next checkpoint.

+
-
- -
-
-
- -
- - + + +
+ +
@push('scripts') @endpush diff --git a/Modules/Content/Resources/views/admin/index.blade.php b/Modules/Content/Resources/views/admin/index.blade.php index 4217183..c219cb4 100644 --- a/Modules/Content/Resources/views/admin/index.blade.php +++ b/Modules/Content/Resources/views/admin/index.blade.php @@ -1,6 +1,53 @@ - -

Content documents

Reusable structured content used by page compositions.

Create document
-@if(session('status'))
{{ session('status') }}
@endif -
@foreach($types as $type)@endforeach@foreach(['draft','published','archived'] as $status)@endforeachFilter -
@forelse($documents as $document)@empty@endforelse
TitleTypeStatusUsage
{{ $document->title }}
{{ $document->slug }}
{{ $document->type?->name }}{{ ucfirst($document->status) }}{{ $document->placements->count() }}
No documents found.
{{ $documents->links() }}
+ +
+
+
+

Content documents

+

Create and reuse typed content across page regions.

+
+
+ Compose homepage + Create document +
+
+ + @if(session('status')) +
{{ session('status') }}
+ @endif + @error('document')
{{ $message }}
@enderror + +
+ + + + @foreach($types as $type)@endforeach + + + + + + +
FilterReset
+ + +
+ + + + @forelse($documents as $document) + + + + + + + + @empty + + @endforelse + +
DocumentTypeStatusUsesActions
{{ $document->title }}
{{ $document->slug }}
{{ $document->type?->name }}{{ ucfirst($document->status) }}{{ $document->placements_count }}Edit
No content documents match the current filters.
+
+ {{ $documents->links() }} +
diff --git a/Modules/Content/Resources/views/admin/pages/edit.blade.php b/Modules/Content/Resources/views/admin/pages/edit.blade.php deleted file mode 100644 index 1c2513c..0000000 --- a/Modules/Content/Resources/views/admin/pages/edit.blade.php +++ /dev/null @@ -1,95 +0,0 @@ - -
-
- - Content documents - - -

- {{ ucfirst($pageKey) }} composition -

-
- - @if (session('status')) -
- {{ session('status') }} -
- @endif - -
- @csrf - - - @foreach ($documents as $document) - - @endforeach - - - - @foreach (['header', 'body', 'footer'] as $region) - - @endforeach - - -
- - Add placement - -
-
- - @foreach (['header', 'body', 'footer'] as $region) -
-

- {{ ucfirst($region) }} -

- -
- @forelse ($placements->get($region, collect()) as $placement) -
-
- - {{ $placement->document->title }} - - -
- {{ $placement->is_enabled ? 'Enabled' : 'Disabled' }} -
-
- -
- @csrf - @method('DELETE') - - - Remove - -
-
- @empty -

- No placements. -

- @endforelse -
-
- @endforeach -
-
diff --git a/Modules/Content/Resources/views/documents/unsupported.blade.php b/Modules/Content/Resources/views/documents/unsupported.blade.php new file mode 100644 index 0000000..b80545d --- /dev/null +++ b/Modules/Content/Resources/views/documents/unsupported.blade.php @@ -0,0 +1,3 @@ +@if(app()->hasDebugModeEnabled()) + +@endif diff --git a/Modules/Content/Routes/web.php b/Modules/Content/Routes/web.php index 021afb5..803e350 100644 --- a/Modules/Content/Routes/web.php +++ b/Modules/Content/Routes/web.php @@ -2,22 +2,22 @@ use Illuminate\Support\Facades\Route; use Modules\Content\Http\Controllers\ContentDocumentController; -use Modules\Content\Http\Controllers\PageComposerController; +use Modules\Content\Http\Controllers\PageCompositionController; -Route::middleware(['web', 'auth'])->prefix('admin/content')->name('content.')->group(function (): void { +Route::middleware(['web', 'auth', 'verified'])->prefix('admin/content')->name('content.')->group(function (): void { Route::get('/', [ContentDocumentController::class, 'index'])->name('index'); Route::get('/documents/create', [ContentDocumentController::class, 'create'])->name('documents.create'); Route::post('/documents', [ContentDocumentController::class, 'store'])->name('documents.store'); Route::get('/documents/{document}/edit', [ContentDocumentController::class, 'edit'])->name('documents.edit'); Route::put('/documents/{document}', [ContentDocumentController::class, 'update'])->name('documents.update'); - Route::put('/documents/{document}/autosave', [ContentDocumentController::class, 'autosave'])->name('documents.autosave'); - Route::post('/documents/preview', [ContentDocumentController::class, 'preview'])->name('documents.preview'); Route::post('/documents/{document}/duplicate', [ContentDocumentController::class, 'duplicate'])->name('documents.duplicate'); Route::delete('/documents/{document}', [ContentDocumentController::class, 'destroy'])->name('documents.destroy'); - Route::get('/pages/{pageKey}', [PageComposerController::class, 'edit'])->name('pages.edit'); - Route::post('/pages/{pageKey}/placements', [PageComposerController::class, 'store'])->name('pages.placements.store'); - Route::patch('/pages/{pageKey}/placements/{placement}', [PageComposerController::class, 'update'])->name('pages.placements.update'); - Route::delete('/pages/{pageKey}/placements/{placement}', [PageComposerController::class, 'destroy'])->name('pages.placements.destroy'); - Route::post('/pages/{pageKey}/reorder', [PageComposerController::class, 'reorder'])->name('pages.reorder'); + Route::get('/pages/{pageKey}', [PageCompositionController::class, 'edit'])->name('pages.edit'); + Route::get('/pages/{pageKey}/preview', [PageCompositionController::class, 'preview'])->name('pages.preview'); + Route::post('/pages/{pageKey}/placements', [PageCompositionController::class, 'store'])->name('placements.store'); + Route::put('/placements/{placement}', [PageCompositionController::class, 'update'])->name('placements.update'); + Route::delete('/placements/{placement}', [PageCompositionController::class, 'destroy'])->name('placements.destroy'); + Route::post('/placements/{placement}/duplicate', [PageCompositionController::class, 'duplicate'])->name('placements.duplicate'); + Route::post('/pages/{pageKey}/reorder', [PageCompositionController::class, 'reorder'])->name('placements.reorder'); }); diff --git a/Modules/Content/Services/PageRenderer.php b/Modules/Content/Services/PageRenderer.php index b145c91..d02c898 100644 --- a/Modules/Content/Services/PageRenderer.php +++ b/Modules/Content/Services/PageRenderer.php @@ -1,11 +1,11 @@ exists(); + try { + return Schema::hasTable('page_content') + && PageContent::query()->where('page_key', $pageKey)->where('is_enabled', true)->exists(); + } catch (Throwable) { + return false; + } } - public function renderRegion(string $pageKey, string $region, bool $preview=false): string + public function renderRegion(string $pageKey, string $region): string { - if(!Schema::hasTable('page_content')) return ''; - return PageContent::query()->with('document.type')->where('page_key',$pageKey)->where('region',$region)->where('is_enabled',true)->orderBy('sort_order')->get() - ->filter(fn(PageContent $p)=>$preview || ($this->active($p) && $p->document && $this->active($p->document) && $p->document->status==='published')) - ->map(function(PageContent $p) use($preview){ - try { $view='stylist-theme-saasland::documents.'.$p->document->type->key; if(!View::exists($view)) return ''; $data=$p->document->data ?? []; return View::make($view, array_merge($data,['data'=>$data,'document'=>$p->document,'placement'=>$p,'preview'=>$preview]))->render(); } - catch(Throwable $e){ report($e); return ''; } - })->implode(''); + if (! $this->hasComposition($pageKey)) { + return ''; + } + + $preview = Route::currentRouteNamed('content.pages.preview'); + $placements = PageContent::query() + ->with('document.type') + ->where('page_key', $pageKey) + ->where('region', $region) + ->where('is_enabled', true) + ->where(fn ($query) => $query->whereNull('starts_at')->orWhere('starts_at', '<=', now())) + ->where(fn ($query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', now())) + ->orderBy('sort_order') + ->get(); + + return $placements->map(function (PageContent $placement) use ($preview): string { + $document = $placement->document; + if (! $document || ! $document->type || ! $this->isRenderable($document, $preview)) { + return ''; + } + + $view = 'stylist-theme-saasland::documents.'.$document->type->renderer; + if (! View::exists($view)) { + $view = 'content::documents.unsupported'; + } + + $rendered = View::make($view, [ + 'document' => $document, + 'data' => $document->data ?? [], + 'placement' => $placement, + 'isCompositionPreview' => $preview, + ])->render(); + + if ($preview && $document->status !== 'published') { + return '
Draft preview
'.$rendered.'
'; + } + + return $rendered; + })->implode("\n"); } - private function active(object $model): bool + private function isRenderable(ContentDocument $document, bool $preview): bool { - return (!$model->published_at || $model->published_at->lte(now())) && (!$model->expires_at || $model->expires_at->gt(now())); + if ($preview) { + return true; + } + + if ($document->status !== 'published') { + return false; + } + + if ($document->published_at?->isFuture()) { + return false; + } + + return ! $document->expires_at?->isPast(); } } diff --git a/Modules/Content/Tests/Feature/ContentModuleTest.php b/Modules/Content/Tests/Feature/ContentModuleTest.php deleted file mode 100644 index 792815e..0000000 --- a/Modules/Content/Tests/Feature/ContentModuleTest.php +++ /dev/null @@ -1,11 +0,0 @@ -toBeInstanceOf(PageRenderer::class); }); -it('stores structured document data', function(){ $type=ContentType::create(['key'=>'test','name'=>'Test','schema'=>[]]); $doc=ContentDocument::create(['content_type_id'=>$type->id,'title'=>'Test','slug'=>'test','status'=>'draft','data'=>['title'=>'Hello']]); expect($doc->fresh()->data)->toBe(['title'=>'Hello']); }); diff --git a/Modules/Content/composer.json b/Modules/Content/composer.json deleted file mode 100644 index b689bbc..0000000 --- a/Modules/Content/composer.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "modules/content", - "description": "Composable content module", - "type": "laravel-module", - "autoload": {"psr-4": {"Modules\\Content\\": ""}}, - "extra": {"laravel": {"providers": ["Modules\\Content\\Providers\\ContentServiceProvider"]}} -} diff --git a/Modules/Content/module.json b/Modules/Content/module.json index 96a6d8b..e34671c 100644 --- a/Modules/Content/module.json +++ b/Modules/Content/module.json @@ -1,9 +1,12 @@ { "name": "Content", "alias": "content", - "description": "Composable content documents and page regions.", - "keywords": ["content", "cms", "composer"], - "priority": 0, + "version": "1.0.0", + "description": "Composable typed content documents and page regions.", + "keywords": ["cms", "content", "regions", "composition"], + "priority": 22, "providers": ["Modules\\Content\\Providers\\ContentServiceProvider"], - "files": [] + "aliases": {}, + "files": [], + "requires": ["Core", "Pages", "Stylist"] }