diff --git a/composer.json b/composer.json index 292a41d..6403ba5 100644 --- a/composer.json +++ b/composer.json @@ -61,10 +61,7 @@ "laravel": { "providers": [ "Plank\\Contentable\\ContentableServiceProvider" - ], - "aliases": { - "Contentable": "Plank\\Contentable\\Facades\\Contentable" - } + ] } }, "minimum-stability": "dev", diff --git a/config/contentable.php b/config/contentable.php index fce35c3..786baae 100644 --- a/config/contentable.php +++ b/config/contentable.php @@ -1,12 +1,15 @@ \Plank\Contentable\Models\Content::class, - 'cache' => [ - 'ttl' => 10800, + 'content' => [ + 'model' => \Plank\Contentable\Models\Content::class, ], 'layouts' => [ + 'folder' => 'layouts', + 'mode' => \Plank\Contentable\Enums\LayoutMode::Blade, 'model' => \Plank\Contentable\Models\Layout::class, ], + 'cache' => [ + 'ttl' => 10800, + ], ]; diff --git a/database/migrations/create_layouts_table.php b/database/migrations/create_layouts_table.php index 7ebb81e..a527c0c 100644 --- a/database/migrations/create_layouts_table.php +++ b/database/migrations/create_layouts_table.php @@ -9,8 +9,11 @@ public function up() { Schema::create('layouts', function (Blueprint $table) { - $table->id(); - $table->string('identifier')->unique()->nullable(); + $table->id()->primary(); + $table->string('key')->unique(); + $table->string('name')->unique()->nullable(); + $table->string('layoutable')->nullable(); + $table->string('type'); $table->timestamps(); }); } diff --git a/src/Commands/SyncLayouts.php b/src/Commands/SyncLayouts.php new file mode 100644 index 0000000..1e48476 --- /dev/null +++ b/src/Commands/SyncLayouts.php @@ -0,0 +1,167 @@ +globalKeys() as $key) { + $this->ensureGlobalLayoutExists($key); + } + + foreach ($this->layoutableKeys() as $key) { + $this->ensureModelLayoutExists($key); + } + } + + /** + * Get the Layout keys available to all Layoutables + */ + protected function globalKeys(): array + { + $layoutModel = static::layoutModel(); + + return array_map(function (SplFileInfo $layout) use ($layoutModel): string { + return $layout->getBasename($layoutModel::extension()); + }, File::files($layoutModel::folder())); + } + + /** + * Get the class specific Layout keys + */ + protected function layoutableKeys(): array + { + $layoutModel = static::layoutModel(); + + $keys = []; + + foreach (File::directories($layoutModel::folder()) as $path) { + $keys = array_merge($keys, $this->keysForPath($path)); + } + + return $keys; + } + + protected function keysForPath(string $path): array + { + $layoutModel = static::layoutModel(); + + $key = (string) str($path)->afterLast(DIRECTORY_SEPARATOR); + + return array_map(function (SplFileInfo $layout) use ($layoutModel, $key): string { + return str($key) + ->append($layoutModel::separator()) + ->append($layout->getBasename($layoutModel::extension())); + }, File::files($path)); + } + + protected function ensureGlobalLayoutExists(string $key): void + { + $layoutModel = static::layoutModel(); + + $layout = $layoutModel::query() + ->where($layoutModel::getLayoutKeyColumn(), $key) + ->first(); + + if ($layout !== null) { + return; + } + + $layoutModel::query()->create([ + $layoutModel::getLayoutKeyColumn() => $key, + $layoutModel::getNameColumn() => $this->globalKeyToName($key), + $layoutModel::getTypeColumn() => LayoutType::Global, + ]); + } + + protected function ensureModelLayoutExists(string $key): void + { + $layoutModel = static::layoutModel(); + + $layout = $layoutModel::query() + ->where($layoutModel::getLayoutKeyColumn(), $key) + ->first(); + + if ($layout !== null) { + return; + } + + [$layoutableKey, $layoutKey] = explode($layoutModel::separator(), $key); + + $type = match (strtolower($layoutKey)) { + 'index' => LayoutType::Index, + 'show' => LayoutType::Show, + default => LayoutType::Custom, + }; + + $layoutModel::query()->create([ + $layoutModel::getLayoutKeyColumn() => $key, + $layoutModel::getNameColumn() => $this->layoutableKeyToName($layoutableKey, $layoutKey, $type), + $layoutModel::getTypeColumn() => $type, + $layoutModel::getLayoutableColumn() => $layoutableKey, + ]); + } + + /** + * Create a layout name for the given key + */ + protected function globalKeyToName(string $key): string + { + $layoutModel = static::layoutModel(); + + return str($key) + ->replace($layoutModel::separator(), '_') + ->snake() + ->replace('_', ' ') + ->title(); + } + + /** + * Create a layout name for the given key + * + * @param class-string $layoutable + */ + protected function layoutableKeyToName(string $layoutableKey, string $layoutKey, LayoutType $type): string + { + $layoutModel = static::layoutModel(); + + $modelName = str($layoutableKey) + ->singular() + ->snake() + ->replace('_', ' ') + ->title(); + + $name = str($layoutKey) + ->replace($layoutModel::separator(), '_') + ->snake() + ->replace('_', ' ') + ->title(); + + return match ($type) { + LayoutType::Index => "$modelName Index", + LayoutType::Show => "$modelName Details", + default => "$name $modelName", + }; + } + + /** + * @return class-string + */ + protected static function layoutModel(): string + { + return config()->get('contentable.layouts.model'); + } +} diff --git a/src/Concerns/CanRender.php b/src/Concerns/CanRender.php index e09588e..b646fa5 100644 --- a/src/Concerns/CanRender.php +++ b/src/Concerns/CanRender.php @@ -28,7 +28,7 @@ public static function bootCanRender() public function content(): MorphOne { - $contentModel = config('contentable.model'); + $contentModel = config('contentable.content.model'); return $this->morphOne($contentModel, 'renderable'); } diff --git a/src/Concerns/HasContent.php b/src/Concerns/HasContent.php index 42e01b5..7e39b93 100644 --- a/src/Concerns/HasContent.php +++ b/src/Concerns/HasContent.php @@ -9,7 +9,7 @@ trait HasContent { public function contents(): MorphMany { - $contentModel = config('contentable.model'); + $contentModel = config('contentable.content.model'); return $this->morphMany($contentModel, 'contentable'); } diff --git a/src/Concerns/HasLayouts.php b/src/Concerns/HasLayouts.php index a54b849..cba034a 100644 --- a/src/Concerns/HasLayouts.php +++ b/src/Concerns/HasLayouts.php @@ -2,9 +2,15 @@ namespace Plank\Contentable\Concerns; +use Illuminate\Contracts\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; -use Plank\Contentable\Contracts\Layout as LayoutContract; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Plank\Contentable\Contracts\Layout; use Plank\Contentable\Contracts\Layoutable; +use Plank\Contentable\Enums\LayoutMode; +use Plank\Contentable\Enums\LayoutType; +use Plank\Contentable\Exceptions\MissingLayoutException; /** * @mixin Model @@ -12,48 +18,152 @@ */ trait HasLayouts { + public function layout(): Layout + { + if ($this->relatedLayout === null) { + throw MissingLayoutException::show(static::class, $this->getKey()); + } + + return $this->relatedLayout; + } + + public static function indexLayout(): Layout + { + $layoutModel = static::layoutModel(); + + $layout = $layoutModel::query() + ->where($layoutModel::getLayoutKeyColumn(), static::indexLayoutKey()) + ->first(); + + if ($layout === null) { + throw MissingLayoutException::index(static::class); + } + + return $layout; + } + + public function layouts(): Collection + { + $layoutModel = static::layoutModel(); + + return $layoutModel::query() + ->where($layoutModel::getLayoutableColumn(), static::layoutKey()) + ->when($this->globalLayouts(), function (Builder $query) use ($layoutModel) { + $query->orWhere(function (Builder $query) use ($layoutModel) { + $query->where($layoutModel::getTypeColumn(), LayoutType::Global) + ->whereNotIn($layoutModel::getLayoutKeyColumn(), $this->excludedLayouts()); + }); + }) + ->get() + ->sortBy($layoutModel::getNameColumn()); + } + /** - * {@inheritDoc} + * The default implementation ships as a parent relationship + * + * @return BelongsTo */ - public function layout(): LayoutContract + public function relatedLayout() { - /** @var class-string $model */ + /** @var class-string $layoutModel */ $layoutModel = config('contentable.layouts.model'); - return $layoutModel::query() - ->where($layoutModel::getLayoutKeyName(), $this->layoutKey()) - ->firstOrFail(); + return $this->belongsTo( + $layoutModel, + $this->layoutForeignKey(), + $this->layoutOwnerKey(), + ); } /** - * {@inheritDoc} + * The foreign key for the layout relationship */ - public function layoutKey(): string + protected function layoutForeignKey(): string { - return str(class_basename($this)) - ->plural() - ->lower() - ->append('.show'); + return 'layout_id'; } /** - * {@inheritDoc} + * The primary key on the layouts table */ - public static function indexLayout(): LayoutContract + protected function layoutOwnerKey(): string { - /** @var class-string $layoutModel */ - $layoutModel = config('contentable.layouts.model'); + $layoutModel = static::layoutModel(); - return $layoutModel::query() - ->where($layoutModel::getLayoutKeyName(), static::indexLayoutKey()) - ->first(); + return (new $layoutModel)->getKeyName(); } /** - * {@inheritDoc} + * The key of the layout files for this Layoutable + */ + public static function layoutKey(): string + { + $layoutModel = static::layoutModel(); + + $name = str(class_basename(static::class)) + ->plural() + ->snake(); + + return match ($layoutModel::mode()) { + LayoutMode::Blade => $name->lower(), + default => $name->studly(), + }; + } + + /** + * The default index layout key of the Model */ public static function indexLayoutKey(): string { - return str(class_basename(static::class))->plural()->lower()->append('.index'); + $layoutModel = static::layoutModel(); + + return str(static::layoutKey()) + ->append($layoutModel::separator()) + ->append(static::indexKey()); + } + + /** + * The key we will use for the default index layouts + */ + protected static function indexKey(): string + { + $layoutModel = static::layoutModel(); + + return match ($layoutModel::mode()) { + LayoutMode::Blade => 'index', + default => 'Index', + }; + } + + /** + * @return class-string + */ + protected static function layoutModel(): string + { + return config()->get('contentable.layouts.model'); + } + + /** + * Determine if the Layoutable uses global layouts + */ + protected function globalLayouts(): bool + { + if (property_exists($this, 'globalLayouts')) { + return $this->globalLayouts; + } + + return true; + } + + /** + * Exclude specific layouts by their keys + */ + protected function excludedLayouts(): array + { + if (property_exists($this, 'excludedLayouts')) { + return $this->excludedLayouts; + } + + return []; } } diff --git a/src/ContentableServiceProvider.php b/src/ContentableServiceProvider.php index 5fa725a..1650c43 100644 --- a/src/ContentableServiceProvider.php +++ b/src/ContentableServiceProvider.php @@ -2,6 +2,7 @@ namespace Plank\Contentable; +use Plank\Contentable\Commands\SyncLayouts; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -11,6 +12,9 @@ public function configurePackage(Package $package): void { $package ->name('contentable') + ->hasCommands([ + SyncLayouts::class, + ]) ->hasConfigFile() ->hasMigrations([ 'create_contents_table', diff --git a/src/Contracts/Content.php b/src/Contracts/Content.php index cddab52..5cdfcbc 100644 --- a/src/Contracts/Content.php +++ b/src/Contracts/Content.php @@ -4,6 +4,10 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; +/** + * @property-read Contentable|null $contentable + * @property-read Renderable|null $renderable + */ interface Content { public function renderable(): MorphTo; diff --git a/src/Contracts/Layout.php b/src/Contracts/Layout.php index 67b026e..9f5c2ab 100644 --- a/src/Contracts/Layout.php +++ b/src/Contracts/Layout.php @@ -2,25 +2,49 @@ namespace Plank\Contentable\Contracts; +use Plank\Contentable\Enums\LayoutMode; + interface Layout { /** - * Get the Layout key + * The flavor the application's FE is using (blade or inertia) + */ + public static function mode(): LayoutMode; + + /** + * The folder in the filesystem where the layout files are stored + */ + public static function folder(): string; + + /** + * The extension for the layout files + */ + public static function extension(): string; + + /** + * The separator for the layout keys: + * . for Blade + * / for all Inertia flavors + */ + public static function separator(): string; + + /** + * Get the Layout key column */ - public function layoutKey(): string; + public static function getLayoutKeyColumn(): string; /** - * Get the name of the Layout key attribute + * Get the Layout name column */ - public static function getLayoutKeyName(): string; + public static function getNameColumn(): string; /** - * Retrieve the Layout's Blade template name + * Get the Layout type column */ - public function bladeTemplate(): string; + public static function getTypeColumn(): string; /** - * Retrieve the Layout's Inertia component name + * Get the Layoutable column */ - public function inertiaComponent(): string; + public static function getLayoutableColumn(): string; } diff --git a/src/Contracts/Layoutable.php b/src/Contracts/Layoutable.php index 01ae0c3..ca1fd16 100644 --- a/src/Contracts/Layoutable.php +++ b/src/Contracts/Layoutable.php @@ -2,25 +2,37 @@ namespace Plank\Contentable\Contracts; +use Illuminate\Database\Eloquent\Collection; + +/** + * @property string $layout + */ interface Layoutable { /** - * Retrieve a related Layout if one exists + * Retrieve the layout for the model */ public function layout(): Layout; /** - * Determine the layout key for the model + * Allow the class to define a layout for its index */ - public function layoutKey(): string; + public static function indexLayout(): Layout; /** - * Allow the model's class to define a layout for its index + * Get the Layout options as Key Value pairs + * + * @return Collection */ - public static function indexLayout(): Layout; + public function layouts(): Collection; /** - * Determine the layout key for the model + * Define the key which is used to identify the Index layout for the Layoutable */ public static function indexLayoutKey(): string; + + /** + * Get the layout key for the class + */ + public static function layoutKey(): string; } diff --git a/src/Enums/LayoutMode.php b/src/Enums/LayoutMode.php new file mode 100644 index 0000000..a4a05bb --- /dev/null +++ b/src/Enums/LayoutMode.php @@ -0,0 +1,13 @@ +mapWithKeys(fn (LayoutType $type) => [$type->value => $type->name]); + } +} diff --git a/src/Exceptions/ContentableException.php b/src/Exceptions/ContentableException.php new file mode 100644 index 0000000..53e7ac5 --- /dev/null +++ b/src/Exceptions/ContentableException.php @@ -0,0 +1,9 @@ + $layoutable + */ + public static function show(string $layoutable, string|int|null $key): self + { + return new self("No Detail layout for `{$layoutable}` with key `{$key}`."); + } + + /** + * @param class-string $layoutable + */ + public static function index(string $layoutable): self + { + return new self("No Index layout defined for `{$layoutable}`."); + } +} diff --git a/src/Models/Layout.php b/src/Models/Layout.php index bcf3081..791a373 100644 --- a/src/Models/Layout.php +++ b/src/Models/Layout.php @@ -4,57 +4,86 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Stringable; use Plank\Contentable\Contracts\Layout as LayoutContract; +use Plank\Contentable\Enums\LayoutMode; +use Plank\Contentable\Enums\LayoutType; class Layout extends Model implements LayoutContract { use HasFactory; - protected $guarded = ['id']; + protected $guarded = []; - /** - * {@inheritDoc} - */ - public function layoutKey(): string + protected $casts = [ + 'type' => LayoutType::class, + ]; + + protected $attributes = [ + 'type' => LayoutType::Custom, + ]; + + public static function getLayoutKeyColumn(): string + { + return 'key'; + } + + public static function getNameColumn(): string { - return $this->getAttribute(static::getLayoutKeyName()); + return 'name'; } - /** - * {@inheritDoc} - */ - public static function getLayoutKeyName(): string + public static function getTypeColumn(): string { - return 'identifier'; + return 'type'; } - /** - * {@inheritDoc} - */ - public function bladeTemplate(): string + public static function getLayoutableColumn(): string { - $key = str($this->layoutKey()) - ->prepend('layouts.') - ->replace('/', '.') - ->explode('.') - ->map(fn ($part) => (string) str($part)->snake()) - ->implode('.'); + return 'layoutable'; + } - return $key; + public static function mode(): LayoutMode + { + return config()->get('contentable.layouts.mode'); } - /** - * {@inheritDoc} - */ - public function inertiaComponent(): string + public static function extension(): string { - $key = str($this->layoutKey()) - ->trim('/') - ->replace('/', '.') - ->explode('.') - ->map(fn ($part) => (string) str($part)->studly()) - ->implode('/'); + return static::mode()->value; + } - return $key; + public static function separator(): string + { + return match (static::mode()) { + LayoutMode::Blade => '.', + default => '/', + }; + } + + public static function folder(): string + { + $folder = str(config()->get('contentable.layouts.folder')); + + return match (static::mode()) { + LayoutMode::Blade => static::bladeLayoutsFolder($folder), + default => static::inertiaLayoutsFolder($folder), + }; + } + + protected static function bladeLayoutsFolder(Stringable $folder): string + { + return str(config()->get('view.paths')[0]) + ->rtrim(DIRECTORY_SEPARATOR) + ->append(DIRECTORY_SEPARATOR) + ->append($folder->lower()); + } + + protected static function inertiaLayoutsFolder(Stringable $folder): string + { + return str(resource_path('js'.DIRECTORY_SEPARATOR.'Pages')) + ->rtrim(DIRECTORY_SEPARATOR) + ->append(DIRECTORY_SEPARATOR) + ->append($folder->studly()); } } diff --git a/tests/Feature/LayoutSyncTest.php b/tests/Feature/LayoutSyncTest.php new file mode 100644 index 0000000..a5e5a3b --- /dev/null +++ b/tests/Feature/LayoutSyncTest.php @@ -0,0 +1,86 @@ +assertExitCode(0); + + expect($layout = Layout::where('key', 'default')->first())->not->toBeNull(); + expect($layout->name)->toBe('Default'); + expect($layout->type)->toBe(LayoutType::Global); + expect($layout->layoutable)->toBeNull(); + + expect($layout = Layout::where('key', 'holidays')->first())->not->toBeNull(); + expect($layout->name)->toBe('Holidays'); + expect($layout->type)->toBe(LayoutType::Global); + expect($layout->layoutable)->toBeNull(); + + expect($layout = Layout::where('key', 'pages.index')->first())->not->toBeNull(); + expect($layout->name)->toBe('Page Index'); + expect($layout->type)->toBe(LayoutType::Index); + expect($layout->layoutable)->toBe(Page::layoutKey()); + + expect($layout = Layout::where('key', 'pages.show')->first())->not->toBeNull(); + expect($layout->name)->toBe('Page Details'); + expect($layout->type)->toBe(LayoutType::Show); + expect($layout->layoutable)->toBe(Page::layoutKey()); + + expect($layout = Layout::where('key', 'pages.landing')->first())->not->toBeNull(); + expect($layout->name)->toBe('Landing Page'); + expect($layout->type)->toBe(LayoutType::Custom); + expect($layout->layoutable)->toBe(Page::layoutKey()); + + expect($layout = Layout::where('key', 'products.index')->first())->not->toBeNull(); + expect($layout->name)->toBe('Product Index'); + expect($layout->type)->toBe(LayoutType::Index); + expect($layout->layoutable)->toBe(Product::layoutKey()); + + expect($layout = Layout::where('key', 'products.show')->first())->not->toBeNull(); + expect($layout->name)->toBe('Product Details'); + expect($layout->type)->toBe(LayoutType::Show); + expect($layout->layoutable)->toBe(Product::layoutKey()); + + expect($layout = Layout::where('key', 'products.promo')->first())->not->toBeNull(); + expect($layout->name)->toBe('Promo Product'); + expect($layout->type)->toBe(LayoutType::Custom); + expect($layout->layoutable)->toBe(Product::layoutKey()); + }); + + it('does not delete missing global layouts on sync', function () { + Layout::factory()->create([ + 'key' => 'custom.global', + 'name' => 'Custom Global', + 'type' => LayoutType::Custom, + ]); + + artisan('contentable:sync') + ->assertExitCode(0); + + expect(Layout::where('key', 'custom.global')->exists())->toBeTrue(); + }); + + it('does not delete missing layoutable layouts on sync', function () { + Layout::factory()->create([ + 'key' => 'pages.y2k', + 'name' => 'Y2K Page', + 'type' => LayoutType::Custom, + 'layoutable' => Page::layoutKey(), + ]); + + artisan('contentable:sync') + ->assertExitCode(0); + + expect(Layout::where('key', 'pages.y2k')->exists())->toBeTrue(); + }); +}); diff --git a/tests/Feature/LayoutsTest.php b/tests/Feature/LayoutsTest.php index a887be8..299bcb2 100644 --- a/tests/Feature/LayoutsTest.php +++ b/tests/Feature/LayoutsTest.php @@ -1,45 +1,201 @@ create([ - 'identifier' => 'pages.show', - ]); +use function Pest\Laravel\artisan; - Layout::factory()->create([ - 'identifier' => 'pages.index', - ]); +describe('It throws errors when layouts do not exist', function () { + it('throws an error when the Detail layout doesnt exist', function () { + Product::factory()->create()->layout(); + })->throws(MissingLayoutException::class); - Layout::factory()->create([ - 'identifier' => 'promotions', - ]); + it('throws an error when the Index layout doesnt exist', function () { + Product::indexLayout(); + })->throws(MissingLayoutException::class); }); -it('finds the show layout using the default key', function () { - $page = Page::factory()->create(); +describe('It returns Blade Layouts for Layoutables', function () { + beforeEach(function () { + setBladePath('sync'); + artisan('contentable:sync')->assertExitCode(0); + }); - expect($layout = $page->layout())->toBeInstanceOf(Layout::class); - expect($layout->layoutKey())->toBe('pages.show'); - expect($layout->bladeTemplate())->toBe('layouts.pages.show'); - expect($layout->inertiaComponent())->toBe('Pages/Show'); + it('can return a global Blade Layout', function () { + $layout = Layout::query() + ->where('key', 'default') + ->first(); + + $page = Page::factory()->create([ + 'layout_id' => $layout->id, + ]); + + expect($layout = $page->layout())->not->toBeNull(); + expect($layout->name)->toBe('Default'); + expect($layout->type)->toBe(LayoutType::Global); + expect($layout->layoutable)->toBeNull(); + }); + + it('can return a layoutable Blade Layout', function () { + $layout = Layout::query() + ->where('key', 'pages.landing') + ->first(); + + $page = Page::factory()->create([ + 'layout_id' => $layout->id, + ]); + + expect($layout = $page->layout())->not->toBeNull(); + expect($layout->name)->toBe('Landing Page'); + expect($layout->type)->toBe(LayoutType::Custom); + expect($layout->layoutable)->toBe(Page::layoutKey()); + }); + + it('can return an index Blade layout', function () { + expect($layout = Product::indexLayout())->not->toBeNull(); + expect($layout->name)->toBe('Product Index'); + expect($layout->type)->toBe(LayoutType::Index); + expect($layout->layoutable)->toBe(Product::layoutKey()); + }); }); -it('finds the index layout using the default key', function () { - expect($layout = Page::indexLayout())->toBeInstanceOf(Layout::class); - expect($layout->layoutKey())->toBe('pages.index'); - expect($layout->bladeTemplate())->toBe('layouts.pages.index'); - expect($layout->inertiaComponent())->toBe('Pages/Index'); +describe('It returns Inertia Layouts for Layoutables', function () { + beforeEach(function () { + config()->set('contentable.layouts.mode', LayoutMode::InertiaJsx); + setInertiaPath('Sync'); + + artisan('contentable:sync')->assertExitCode(0); + }); + + it('can return a global Inertia Layout', function () { + $layout = Layout::query() + ->where('key', 'Default') + ->first(); + + $page = Page::factory()->create([ + 'layout_id' => $layout->id, + ]); + + expect($layout = $page->layout())->not->toBeNull(); + expect($layout->name)->toBe('Default'); + expect($layout->type)->toBe(LayoutType::Global); + expect($layout->layoutable)->toBeNull(); + }); + + it('can return a layoutable Inertia Layout', function () { + $layout = Layout::query() + ->where('key', 'Pages/Landing') + ->first(); + + $page = Page::factory()->create([ + 'layout_id' => $layout->id, + ]); + + expect($layout = $page->layout())->not->toBeNull(); + expect($layout->name)->toBe('Landing Page'); + expect($layout->type)->toBe(LayoutType::Custom); + expect($layout->layoutable)->toBe(Page::layoutKey()); + }); + + it('can return an index Inertia layout', function () { + expect($layout = Product::indexLayout())->not->toBeNull(); + expect($layout->name)->toBe('Product Index'); + expect($layout->type)->toBe(LayoutType::Index); + expect($layout->layoutable)->toBe(Product::layoutKey()); + }); }); -it('finds the show layout using a custom key', function () { - $page = Page::factory()->create([ - 'title' => 'Promotions', - ]); +describe('It returns layout options for blade', function () { + beforeEach(function () { + setBladePath('sync'); + artisan('contentable:sync')->assertExitCode(0); + }); + + it('returns all but excluded layouts for models that include global layouts', function () { + $page = Page::factory()->create(); + + expect($layouts = $page->layouts())->toHaveCount(4); + + expect($layout = $layouts->where('key', 'holidays')->first())->toBeNull(); + + expect($layout = $layouts->where('key', 'default')->first())->not->toBeNull(); + expect($layout->name)->toBe('Default'); + expect($layout->type)->toBe(LayoutType::Global); + expect($layout->layoutable)->toBeNull(); + + expect($layout = $layouts->where('key', 'pages.index')->first())->not->toBeNull(); + expect($layout->name)->toBe('Page Index'); + expect($layout->type)->toBe(LayoutType::Index); + expect($layout->layoutable)->toBe(Page::layoutKey()); + + expect($layout = $layouts->where('key', 'pages.show')->first())->not->toBeNull(); + expect($layout->name)->toBe('Page Details'); + expect($layout->type)->toBe(LayoutType::Show); + expect($layout->layoutable)->toBe(Page::layoutKey()); + + expect($layout = $layouts->where('key', 'pages.landing')->first())->not->toBeNull(); + expect($layout->name)->toBe('Landing Page'); + expect($layout->type)->toBe(LayoutType::Custom); + expect($layout->layoutable)->toBe(Page::layoutKey()); + }); + + it('excludes all global layouts for layoutables which should not use them', function () { + $post = Post::factory()->create(); + + expect($layouts = $post->layouts())->toHaveCount(3); + + expect($layout = $layouts->where('key', 'holidays')->first())->toBeNull(); + expect($layout = $layouts->where('key', 'default')->first())->toBeNull(); + + expect($layout = $layouts->where('key', 'posts.index')->first())->not->toBeNull(); + expect($layout->name)->toBe('Post Index'); + expect($layout->type)->toBe(LayoutType::Index); + expect($layout->layoutable)->toBe(Post::layoutKey()); + + expect($layout = $layouts->where('key', 'posts.show')->first())->not->toBeNull(); + expect($layout->name)->toBe('Post Details'); + expect($layout->type)->toBe(LayoutType::Show); + expect($layout->layoutable)->toBe(Post::layoutKey()); + + expect($layout = $layouts->where('key', 'posts.featured')->first())->not->toBeNull(); + expect($layout->name)->toBe('Featured Post'); + expect($layout->type)->toBe(LayoutType::Custom); + expect($layout->layoutable)->toBe(Post::layoutKey()); + }); + + it('shows all layouts when global layouts are available and non are excluded', function () { + $product = Product::factory()->create(); + + expect($layouts = $product->layouts())->toHaveCount(5); + + expect($layout = $layouts->where('key', 'holidays')->first())->not->toBeNull(); + expect($layout->name)->toBe('Holidays'); + expect($layout->type)->toBe(LayoutType::Global); + expect($layout->layoutable)->toBeNull(); + + expect($layout = $layouts->where('key', 'default')->first())->not->toBeNull(); + expect($layout->name)->toBe('Default'); + expect($layout->type)->toBe(LayoutType::Global); + expect($layout->layoutable)->toBeNull(); + + expect($layout = $layouts->where('key', 'products.index')->first())->not->toBeNull(); + expect($layout->name)->toBe('Product Index'); + expect($layout->type)->toBe(LayoutType::Index); + expect($layout->layoutable)->toBe(Product::layoutKey()); + + expect($layout = $layouts->where('key', 'products.show')->first())->not->toBeNull(); + expect($layout->name)->toBe('Product Details'); + expect($layout->type)->toBe(LayoutType::Show); + expect($layout->layoutable)->toBe(Product::layoutKey()); - expect($layout = $page->layout())->toBeInstanceOf(Layout::class); - expect($layout->layoutKey())->toBe('promotions'); - expect($layout->bladeTemplate())->toBe('layouts.promotions'); - expect($layout->inertiaComponent())->toBe('Promotions'); + expect($layout = $layouts->where('key', 'products.promo')->first())->not->toBeNull(); + expect($layout->name)->toBe('Promo Product'); + expect($layout->type)->toBe(LayoutType::Custom); + expect($layout->layoutable)->toBe(Product::layoutKey()); + }); }); diff --git a/tests/Helper/Blade/sync/default.blade.php b/tests/Helper/Blade/sync/default.blade.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Blade/sync/holidays.blade.php b/tests/Helper/Blade/sync/holidays.blade.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Blade/sync/pages/index.blade.php b/tests/Helper/Blade/sync/pages/index.blade.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Blade/sync/pages/landing.blade.php b/tests/Helper/Blade/sync/pages/landing.blade.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Blade/sync/pages/show.blade.php b/tests/Helper/Blade/sync/pages/show.blade.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Blade/sync/posts/featured.blade.php b/tests/Helper/Blade/sync/posts/featured.blade.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Blade/sync/posts/index.blade.php b/tests/Helper/Blade/sync/posts/index.blade.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Blade/sync/posts/show.blade.php b/tests/Helper/Blade/sync/posts/show.blade.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Blade/sync/products/index.blade.php b/tests/Helper/Blade/sync/products/index.blade.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Blade/sync/products/promo.blade.php b/tests/Helper/Blade/sync/products/promo.blade.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Blade/sync/products/show.blade.php b/tests/Helper/Blade/sync/products/show.blade.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Database/Factories/LayoutFactory.php b/tests/Helper/Database/Factories/LayoutFactory.php index 151f8a1..062f4ae 100644 --- a/tests/Helper/Database/Factories/LayoutFactory.php +++ b/tests/Helper/Database/Factories/LayoutFactory.php @@ -18,7 +18,8 @@ public function definition() } return [ - 'identifier' => implode('.', $this->faker->words(2)), + 'key' => $key = implode('.', $this->faker->words(2)), + 'name' => (string) str($key)->replace('.', ' ')->title(), 'meta' => $meta, ]; } diff --git a/tests/Helper/Database/Factories/PostFactory.php b/tests/Helper/Database/Factories/PostFactory.php new file mode 100644 index 0000000..9fe52ff --- /dev/null +++ b/tests/Helper/Database/Factories/PostFactory.php @@ -0,0 +1,26 @@ + $title = $this->faker->sentence, + 'slug' => str($title)->slug(), + ]; + } + + public function configure() + { + return $this->afterMaking(function (Post $post) { + $post->slug = (string) str($post->title)->slug(); + }); + } +} diff --git a/tests/Helper/Database/Factories/ProductFactory.php b/tests/Helper/Database/Factories/ProductFactory.php new file mode 100644 index 0000000..c5cad5b --- /dev/null +++ b/tests/Helper/Database/Factories/ProductFactory.php @@ -0,0 +1,20 @@ + $this->faker->words(3, true), + 'code' => $this->faker->unique()->numberBetween(10000, 99999), + 'price_in_cents' => $this->faker->numberBetween(99, 99999), + ]; + } +} diff --git a/tests/Helper/Database/Migrations/create_layouts_table.php b/tests/Helper/Database/Migrations/create_layouts_table.php index c5f8b94..d9723bd 100644 --- a/tests/Helper/Database/Migrations/create_layouts_table.php +++ b/tests/Helper/Database/Migrations/create_layouts_table.php @@ -10,7 +10,10 @@ public function up() { Schema::create('layouts', function (Blueprint $table) { $table->id(); - $table->string('identifier')->unique()->nullable(); + $table->string('key')->unique(); + $table->string('name')->unique(); + $table->string('layoutable')->nullable(); + $table->string('type'); $table->json('meta')->nullable(); $table->timestamps(); }); diff --git a/tests/Helper/Database/Migrations/create_pages_table.php b/tests/Helper/Database/Migrations/create_pages_table.php index 93b4937..07c812d 100644 --- a/tests/Helper/Database/Migrations/create_pages_table.php +++ b/tests/Helper/Database/Migrations/create_pages_table.php @@ -12,7 +12,7 @@ public function up() $table->id(); $table->string('title'); $table->string('slug'); - $table->boolean('paywall')->default(false); + $table->foreignId('layout_id')->nullable()->constrained(); $table->timestamps(); }); } diff --git a/tests/Helper/Database/Migrations/create_posts_table.php b/tests/Helper/Database/Migrations/create_posts_table.php new file mode 100644 index 0000000..69ca608 --- /dev/null +++ b/tests/Helper/Database/Migrations/create_posts_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('title'); + $table->string('slug'); + $table->foreignId('layout_id')->nullable()->constrained(); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('posts'); + } +}; diff --git a/tests/Helper/Database/Migrations/create_products_table.php b/tests/Helper/Database/Migrations/create_products_table.php new file mode 100644 index 0000000..eda13d7 --- /dev/null +++ b/tests/Helper/Database/Migrations/create_products_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('title'); + $table->string('code'); + $table->integer('price_in_cents'); + $table->foreignId('layout_id')->nullable()->constrained(); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('products'); + } +}; diff --git a/tests/Helper/Inertia/Sync/Default.jsx b/tests/Helper/Inertia/Sync/Default.jsx new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Inertia/Sync/Holidays.jsx b/tests/Helper/Inertia/Sync/Holidays.jsx new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Inertia/Sync/Pages/Index.jsx b/tests/Helper/Inertia/Sync/Pages/Index.jsx new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Inertia/Sync/Pages/Landing.jsx b/tests/Helper/Inertia/Sync/Pages/Landing.jsx new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Inertia/Sync/Pages/Show.jsx b/tests/Helper/Inertia/Sync/Pages/Show.jsx new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Inertia/Sync/Posts/Featured.jsx b/tests/Helper/Inertia/Sync/Posts/Featured.jsx new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Inertia/Sync/Posts/Index.jsx b/tests/Helper/Inertia/Sync/Posts/Index.jsx new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Inertia/Sync/Posts/Show.jsx b/tests/Helper/Inertia/Sync/Posts/Show.jsx new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Inertia/Sync/Products/Index.jsx b/tests/Helper/Inertia/Sync/Products/Index.jsx new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Inertia/Sync/Products/Promo.jsx b/tests/Helper/Inertia/Sync/Products/Promo.jsx new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Inertia/Sync/Products/Show.jsx b/tests/Helper/Inertia/Sync/Products/Show.jsx new file mode 100644 index 0000000..e69de29 diff --git a/tests/Helper/Models/Layout.php b/tests/Helper/Models/Layout.php index dcec15b..eda72bf 100644 --- a/tests/Helper/Models/Layout.php +++ b/tests/Helper/Models/Layout.php @@ -3,6 +3,7 @@ namespace Plank\Contentable\Tests\Helper\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Plank\Contentable\Enums\LayoutType; use Plank\Contentable\Models\Layout as PackageLayout; class Layout extends PackageLayout @@ -14,6 +15,7 @@ class Layout extends PackageLayout protected $guarded = ['id']; protected $casts = [ + 'type' => LayoutType::class, 'meta' => 'json', ]; } diff --git a/tests/Helper/Models/Page.php b/tests/Helper/Models/Page.php index 2147809..d36fa47 100644 --- a/tests/Helper/Models/Page.php +++ b/tests/Helper/Models/Page.php @@ -13,18 +13,11 @@ class Page extends Model implements Contentable, Layoutable { use HasContent; use HasFactory; - use HasLayouts { - layoutKey as traitLayoutKey; - } + use HasLayouts; protected $guarded = ['id']; - public function layoutKey(): string - { - if ($this->title === 'Promotions') { - return 'promotions'; - } - - return $this->traitLayoutKey(); - } + protected array $excludedLayouts = [ + 'holidays', + ]; } diff --git a/tests/Helper/Models/Post.php b/tests/Helper/Models/Post.php new file mode 100644 index 0000000..4d1aaf7 --- /dev/null +++ b/tests/Helper/Models/Post.php @@ -0,0 +1,21 @@ +in(__DIR__); + +function setBladePath(string $path = ''): void +{ + $dir = realpath(__DIR__) + .DIRECTORY_SEPARATOR + .'Helper' + .DIRECTORY_SEPARATOR + .'Blade' + .DIRECTORY_SEPARATOR; + + $fixtures = str($dir) + ->append($path) + ->rtrim(DIRECTORY_SEPARATOR) + ->explode(DIRECTORY_SEPARATOR); + + $folder = $fixtures->pop(); + $path = $fixtures->implode(DIRECTORY_SEPARATOR); + + config()->set('view.paths', [$path]); + config()->set('contentable.layouts.folder', $folder); +} + +function setInertiaPath(string $path = ''): void +{ + $dir = realpath(__DIR__) + .DIRECTORY_SEPARATOR + .'Helper' + .DIRECTORY_SEPARATOR + .'Inertia' + .DIRECTORY_SEPARATOR; + + $fixtures = str($dir) + ->append($path) + ->rtrim(DIRECTORY_SEPARATOR) + ->explode(DIRECTORY_SEPARATOR); + + $folder = $fixtures->pop(); + $path = $fixtures->implode(DIRECTORY_SEPARATOR); + + $targetPath = resource_path('js'); + $targetFolder = $targetPath.DIRECTORY_SEPARATOR.'Pages'; + + if (! file_exists($targetPath)) { + mkdir($targetPath, 0755, true); + } + + if (file_exists($targetFolder)) { + osSafeUnlink($targetFolder); + } + + symlink( + $path, + $targetFolder + ); + + config()->set('contentable.layouts.folder', $folder); +} + +function clearInertiaPath(): void +{ + $target = resource_path('js'.DIRECTORY_SEPARATOR.'Pages'); + + if (file_exists($target)) { + osSafeUnlink($target); + } +} + +function osSafeUnlink(string $path): bool +{ + if (! is_link($path)) { + return false; // Not a symlink, handle error or do nothing + } + + // Check if the symlink points to a directory + if (is_dir(readlink($path))) { + // On Windows, use rmdir for directories + if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + return rmdir($path); + } else { + // On Unix/Linux/Mac, unlink works for directory symlinks + return unlink($path); + } + } else { + // For files, just use unlink + return unlink($path); + } +}