*/ protected $fillable = [ 'id_key', 'title', 'slug', 'content', 'page_id', 'config_id', 'status', 'isLocked', 'isRoot', ]; /** * The attributes that should be cast. * * @var array */ protected $casts = [ 'content' => 'array', 'status' => PageStatusEnum::class, 'isLocked' => 'boolean', 'isRoot' => 'boolean', ]; /** * The translatable columns. * * @var list */ public $translatable = [ 'slug', 'title', 'content', ]; /** * Relations should be included in the cache. * * @var array */ protected static $cacheRelations = [ 'config', 'parentPage', 'childPages', ]; /** * Perform any actions required after the model boots. * * @return void */ protected static function booted(): void { static::creating(function (self $model) { self::manageFields($model); // Set id_key from title. $model->id_key = Str::slug($model->title); }); static::updating(function (self $model) { self::manageFields($model); self::checkParentsStatus($model); }); static::updated(function (self $model) { self::setAllChildPageStatus($model); // TODO: update this after recursive deleting images. // DynamicPageHelper::removeImgs($model); }); static::restoring(function (self $model) { if ($model->config_id && $model->config()->onlyTrashed()->exists()) { throw ValidationException::withMessages([ 'error' => trans('crud.messages.cannot_be_restored', [ 'model' => Str::ucfirst(trans_choice('models.classes.dynamic-page', 1)) ]), ]); } $model->status = PageStatusEnum::draft; $model->isRoot = false; }); static::forceDeleted(function (self $model) { // TODO: update this after recursive deleting images. // DynamicPageHelper::removeImgs($model, true); }); } // * METHODS /** * Store recursively all uploaded files in array. * Overwrite trait method to filter fields with image_ prefix. * * @return array */ private function processContentFiles(array $content): array { return collect($content) ->map(function ($value, $key) { // Check the pattern of the key and the type of value. if ( ( Str::startsWith($key, DynamicFieldTypes::image->name . '_') || Str::startsWith($key, DynamicFieldTypes::file->name . '_') ) && $value instanceof UploadedFile ) { return FileHelper::storeFile($this, $value); } // Check if it's an array. if (is_array($value)) { return $this->processContentFiles($value); } return $value; }) ->all(); } /** * Unpublished all children pages if change status published from draft/unpublished. * * @param self $model * @return boolean */ private static function setAllChildPageStatus(self $model): void { if ( !$model->status->equals(PageStatusEnum::published) && !$model->getOriginal('status')->equals($model->status) ) { $model->childPages->map(function (DynamicPage $page) use ($model) { $page->status = $model->status; $page->saveOrFail(); }); } } /** * Publish current page if all parents are published. * * @param self $model * @return boolean * @throws \Illuminate\Validation\ValidationException Parent must be published. */ private static function checkParentsStatus(self $model): void { if ($model->status->equals(PageStatusEnum::published) && !self::getParentsStatus($model)) { throw ValidationException::withMessages([ 'error' => trans('validation.parent_page'), ]); } } /** * Check if all parent of all associated page are published. * * @param self $model * @return boolean */ private static function getParentsStatus(self $model): bool { $parent = $model->parentPage; while ($parent) { if (!$parent->status->equals(PageStatusEnum::published)) { return false; } $parent = $parent->parentPage; } return true; } /** * Manage model fields. * * @param self $model * @return void */ private static function manageFields(self $model): void { // Check unique fields. if($model->isRoot) { self::assertRootIsUnique($model); } else { $slug = $model->slug ?? Str::slug($model->title); TranslationHelper::assertTranslationFieldIsUnique($model, $model->title, 'title'); TranslationHelper::assertTranslationFieldIsUnique($model, $slug, 'slug'); $model->setAttribute('slug', $slug); } // Check translations. self::validateFallbackTranslations($model); } /** * Assert there is only one root page. * * @param self $model * @return void */ public static function assertRootIsUnique(self $model): void { $oldDynamicPageRoot = self::getModelsCached()->where('id', '!=', $model->getKey())->where('isRoot', true)->first(); if ($oldDynamicPageRoot) { $oldDynamicPageRoot->status = PageStatusEnum::unpublished; $oldDynamicPageRoot->isRoot = false; $oldDynamicPageRoot->saveQuietly(); } } /** * Assert fallback locale translation is present. * * @param self $model * @return void * @throws \Illuminate\Validation\ValidationException Translations missing. */ public static function validateFallbackTranslations(self $model): void { if (!$model->status->equals(PageStatusEnum::published)) { return; } TranslationHelper::validateFallbackTranslations($model); } /** * Get the view of a specific dynamic page. * * @return string */ public function getView(): string { /** @var string $view The view used for the route. */ $view = PageConfig::$defaultView; // Check if the view in config is defined and can be used. if (!empty($this->config->view)) { $candidate = sprintf('front.pages.%s', $this->config->view); if (view()->exists($candidate)) { $view = $candidate; } } // Check if a view with the slug exists. if ($view === PageConfig::$defaultView && !empty($this->slug)) { $candidate = sprintf('front.pages.%s', $this->slug); if (view()->exists($candidate)) { $view = $candidate; } } return $view; } /** * Get the url to access a specific resource. * * @param integer $id Id field of the target model. * @return string|null */ public static function getAccessModel(int $id): string|null { $targetModel = self::getModelsCached()->firstWhere('id', $id); if (!$targetModel) { return null; } $url = RouteHelper::routeNameChecker("front.dynamic-pages.{$targetModel->id_key}"); return parse_url($url, PHP_URL_PATH) ?? "/"; } /** * Get localized path from the locale. * * @param string $locale * @return string|null */ public function getLocalizedPath(string $locale): ?string { $slug = $this->getTranslation('slug', $locale); if (! $slug) { return null; } $pages = static::getModelsCached() ->where('status', PageStatusEnum::published) ->load('parentPage') ->keyBy('id'); $segments = DynamicPageHelper::getAllDynamicPageSegments($this, $pages, $locale); $segmentsPath = implode('/', $segments); return $segmentsPath ? "{$segmentsPath}/{$slug}" : $slug; } // * MEILISEARCH /** * Get model's setting to apply on scout search (meilisearch). * * @var array */ public static $searchSettings = [ 'filterableAttributes' => ['status'], 'sortableAttributes' => ['status'] ]; /** * Get fields that we want to keep in the current locale. * * @return array. */ public static function searchTransFields(): array { $currentLocale = app()->getLocale(); return [ "title_$currentLocale", "slug_$currentLocale", "content_$currentLocale", ]; } /** * Get enum status of the model. * * @return integer */ public static function searchStatusEnum(): int { return PageStatusEnum::published->value; } /** * Get front route. * * @param string|null $idKey * @return string */ public static function searchRouteName(string|null $idKey): string { $currentLocale = app()->getLocale(); return "front.dynamic-pages.$idKey.$currentLocale"; } /** * Get model identificable field. * * @return string */ public static function searchMainField(): string { return 'title'; } /** * Get fields that we want to keep in the current locale. * * @param string $currentLocale * @return array */ public static function searchableTransFields(string $currentLocale): array { return [ "title_$currentLocale", "slug_$currentLocale", "content_$currentLocale", ]; } /** * Get the indexable data array for the model. * * @return array */ public function toSearchableArray(): array { $locales = config('laravellocalization.supportedLocales'); $data = [ 'id' => $this->id, 'id_key' => $this->id_key, 'status' => $this->status->value, ]; foreach ($locales as $locale => $_) { $data["title_$locale"] = $this->hasTranslation('title', $locale) ? $this->getTranslation('title', $locale) : null; $data["slug_$locale"] = $this->hasTranslation('slug', $locale) ? $this->getTranslation('slug', $locale) : null; $content = $this->hasTranslation('content', $locale) ? strip_tags(collect($this->getTranslation('content', $locale)) ->flatten() ->filter(fn($value, $key) => is_string($value) && !empty($value) && !is_numeric($value) && !in_array($key, ['order', 'id']) && !str_starts_with($key, 'image_') && !preg_match('/storage.*\.(?:png|jpe?g|gif|pdf|webp)/i', $value) ) ->values() ->implode(' ')) : null; $data["content_$locale"] = $content; } return $data; } // * RELATIONSHIPS /** * Get children pages for this page. * * @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\DynamicPage,$this> */ public function childPages(): \Illuminate\Database\Eloquent\Relations\HasMany { return $this->hasMany(DynamicPage::class, 'page_id'); } /** * Get parent page for this page. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\App\Models\DynamicPage,$this> */ public function parentPage(): \Illuminate\Database\Eloquent\Relations\BelongsTo { return $this->belongsTo(DynamicPage::class, 'page_id'); } /** * Get the config for this page. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\App\Models\PageConfig,$this> */ public function config(): \Illuminate\Database\Eloquent\Relations\BelongsTo { return $this->belongsTo(PageConfig::class, 'config_id'); } }