diff --git a/docs/content/2_guides/1_page-builder-with-blade/5_configuring-the-page-module.md b/docs/content/2_guides/1_page-builder-with-blade/5_configuring-the-page-module.md index 4ef2cc896..29c3f6383 100644 --- a/docs/content/2_guides/1_page-builder-with-blade/5_configuring-the-page-module.md +++ b/docs/content/2_guides/1_page-builder-with-blade/5_configuring-the-page-module.md @@ -168,7 +168,7 @@ class Page extends Model 'description', ]; - public $slugAttributes = [ + protected $slugFields = [ 'title', ]; diff --git a/docs/content/2_guides/building_a_multilingual_site_with_twill_and_laravel_localization/Article.php b/docs/content/2_guides/building_a_multilingual_site_with_twill_and_laravel_localization/Article.php index fcb0df381..a783bb314 100644 --- a/docs/content/2_guides/building_a_multilingual_site_with_twill_and_laravel_localization/Article.php +++ b/docs/content/2_guides/building_a_multilingual_site_with_twill_and_laravel_localization/Article.php @@ -24,7 +24,7 @@ class Article extends Model implements LocalizedUrlRoutable 'description', ]; - public $slugAttributes = [ + protected $slugFields = [ 'title', ]; diff --git a/docs/content/2_guides/prefill-block-editor-from-template/Article.php b/docs/content/2_guides/prefill-block-editor-from-template/Article.php index 1b4332174..48a65f3b4 100644 --- a/docs/content/2_guides/prefill-block-editor-from-template/Article.php +++ b/docs/content/2_guides/prefill-block-editor-from-template/Article.php @@ -57,7 +57,7 @@ class Article extends Model implements Sortable // #endregion fillable - public $slugAttributes = [ + private $slugFields = [ 'title', ]; diff --git a/examples/basic-page-builder/app/Models/Page.php b/examples/basic-page-builder/app/Models/Page.php index b3d871f86..d3f3d766a 100644 --- a/examples/basic-page-builder/app/Models/Page.php +++ b/examples/basic-page-builder/app/Models/Page.php @@ -24,7 +24,7 @@ class Page extends Model 'description', ]; - public $slugAttributes = [ + protected $slugFields = [ 'title', ]; } diff --git a/examples/portfolio/app/Models/Partner.php b/examples/portfolio/app/Models/Partner.php index 5e551a386..2a1e3e022 100644 --- a/examples/portfolio/app/Models/Partner.php +++ b/examples/portfolio/app/Models/Partner.php @@ -24,7 +24,7 @@ class Partner extends Model 'description', ]; - public $slugAttributes = [ + protected $slugFields = [ 'title', ]; diff --git a/examples/portfolio/app/Models/Project.php b/examples/portfolio/app/Models/Project.php index dcd58e92e..067d4a937 100644 --- a/examples/portfolio/app/Models/Project.php +++ b/examples/portfolio/app/Models/Project.php @@ -27,7 +27,7 @@ class Project extends Model 'description', ]; - public $slugAttributes = [ + protected $slugFields = [ 'title', ]; diff --git a/examples/tests-capsules/app/Twill/Capsules/Posts/Models/Post.php b/examples/tests-capsules/app/Twill/Capsules/Posts/Models/Post.php index 8e2aa4967..f21cf0bd5 100644 --- a/examples/tests-capsules/app/Twill/Capsules/Posts/Models/Post.php +++ b/examples/tests-capsules/app/Twill/Capsules/Posts/Models/Post.php @@ -26,7 +26,7 @@ class Post extends Model implements Sortable public $translatedAttributes = ['title', 'description']; - public $slugAttributes = ['title']; + public $slugFields = ['title']; public $mediasParams = [ 'cover' => [ diff --git a/examples/tests-modules/app/Models/Author.php b/examples/tests-modules/app/Models/Author.php index c37b445e1..4cd8fc24d 100644 --- a/examples/tests-modules/app/Models/Author.php +++ b/examples/tests-modules/app/Models/Author.php @@ -56,7 +56,7 @@ class Author extends Model implements Sortable public $translatedAttributes = ['name', 'description', 'bio']; // uncomment and modify this as needed if you use the HasSlug trait - public $slugAttributes = ['name']; + protected $slugFields = ['name']; // add checkbox fields names here (published toggle is itself a checkbox) public $checkboxes = ['published']; diff --git a/examples/tests-modules/app/Models/Category.php b/examples/tests-modules/app/Models/Category.php index 1343a1f33..66a91b942 100644 --- a/examples/tests-modules/app/Models/Category.php +++ b/examples/tests-modules/app/Models/Category.php @@ -25,7 +25,7 @@ class Category extends Model public $translatedAttributes = ['title']; // uncomment and modify this as needed if you use the HasSlug trait - public $slugAttributes = ['title']; + protected $slugFields = ['title']; // add checkbox fields names here (published toggle is itself a checkbox) public $checkboxes = ['published']; diff --git a/examples/tests-singleton/app/Models/ContactPage.php b/examples/tests-singleton/app/Models/ContactPage.php index 92c3c336c..5ba02e218 100644 --- a/examples/tests-singleton/app/Models/ContactPage.php +++ b/examples/tests-singleton/app/Models/ContactPage.php @@ -25,7 +25,7 @@ class ContactPage extends Model 'description', ]; - public $slugAttributes = [ + protected $slugFields = [ 'title', ]; diff --git a/examples/tests-subdomain-routing/app/Models/Page.php b/examples/tests-subdomain-routing/app/Models/Page.php index f36252e78..e81d399cb 100644 --- a/examples/tests-subdomain-routing/app/Models/Page.php +++ b/examples/tests-subdomain-routing/app/Models/Page.php @@ -28,7 +28,7 @@ class Page extends Model implements Sortable 'description', ]; - public $slugAttributes = [ + public $slugFields = [ 'title', ]; diff --git a/src/Commands/stubs/model.stub b/src/Commands/stubs/model.stub index 07946e0b2..18cee9c8c 100644 --- a/src/Commands/stubs/model.stub +++ b/src/Commands/stubs/model.stub @@ -21,8 +21,9 @@ class {{modelClassName}} extends Model {{modelImplements}} 'description', ]; {{/hasTranslation}}{{hasSlug}} - public $slugAttributes = [ + protected $slugFields = [ 'title', ]; + protected $slugDeps = []; {{/hasSlug}} } diff --git a/src/Models/Behaviors/HasSlug.php b/src/Models/Behaviors/HasSlug.php index 64cb7e860..f85444314 100644 --- a/src/Models/Behaviors/HasSlug.php +++ b/src/Models/Behaviors/HasSlug.php @@ -5,14 +5,16 @@ use A17\Twill\Facades\TwillCapsules; use A17\Twill\Models\Model; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Arr; use Illuminate\Support\Str; +/** @property Collection $slugs */ trait HasSlug { private int $nb_variation_slug = 3; - public array $twillSlugData = []; + public ?array $twillSlugData = null; private bool $twill_restoring = false; @@ -23,9 +25,17 @@ protected static function bootHasSlug(): void $model->twill_restoring = true; }); + static::saving(function (self $model) { + if (!$model->twill_restoring && !isset($model->twillSlugData)) { + // Run this before saving because we need to know which fields are dirty + $model->twillSlugData = $model->getSlugParams(null, true); + } + }); + static::saved(function (self $model) { if (!$model->twill_restoring) { - $model->handleSlugsOnSave(); + $model->handleSlugsSaving(); + $model->twillSlugData = null; } $model->twill_restoring = false; }); @@ -119,28 +129,28 @@ public function restoreSlugs(): void } /** - * When a new model is created there is more than one language, we generate the slugs where there is no locale - * variant yet based on the source. + * @deprecated This method should not be used directly and will be removed in 4.x use $model->save() instead */ public function handleSlugsOnSave(): void { - $this->disableLocaleSlugs(); + $this->handleSlugsSaving(); + } - $slugParams = $this->twillSlugData !== [] ? $this->twillSlugData : $this->getSlugParams(); + private function handleSlugsSaving(): void + { + if (!isset($this->twillSlugData)) { + $slugParams = $this->getSlugParams(null, true); + } else { + $slugParams = $this->twillSlugData; - foreach ($slugParams as $params) { - if (in_array($params['locale'], config('twill.slug_utf8_languages', []))) { - $params['slug'] = $this->getUtf8Slug($params['slug']); - } else { - $params['slug'] = Str::slug($params['slug']); + foreach ($slugParams as $locale => $params) { + $slugParams[$locale] = array_merge($this->getSlugParams($locale, empty($params['slug'])) ?? [], $params); } + $slugParams = array_filter($slugParams, fn($p) => !empty($p['slug'])); + } - if (empty($params['slug'])) { - continue; - } - if ($this->slugs()->where('locale', $params['locale'])->where('slug', $params['slug'])->where('active', true)->doesntExist()) { - $this->updateOrNewSlug($params); - } + foreach ($slugParams as $params) { + $this->updateOrNewSlug($params); } } @@ -152,28 +162,34 @@ public function updateOrNewSlug(array $slugParams): void $slugParams['slug'] = Str::slug($slugParams['slug']); } + if (empty($slugParams['slug'])) { + return; + } + $slugParams['slug'] = $this->suffixSlugIfExisting($slugParams); + $oldMatchingSlug = $this->getExistingSlug($slugParams, true); + // Active old slug if already existing or create a new one. - // The first attempt is to find one without a suffix, a second attempt is done with the suffix. - // If both matches none, we will go to the regular creation flow. - if ( - (($oldSlug = $this->getExistingSlug($slugParams, true)) !== null) - && ($slugParams['slug'] === $this->suffixSlugIfExisting($slugParams)) - ) { - if (!$oldSlug->active && ($slugParams['active'] ?? false)) { - $this->getSlugModelClass()::where('id', $oldSlug->id)->update(['active' => 1]); - $this->disableLocaleSlugs($oldSlug->locale, $oldSlug->id); - } - } elseif ( - $this->slugNeedsSuffix($slugParams) && - (($oldSlug = $this->getExistingSlug($slugParams)) !== null) && - ($slugParams['slug'] === $this->suffixSlugIfExisting($slugParams)) - ) { - if (!$oldSlug->active && ($slugParams['active'] ?? false)) { - $this->getSlugModelClass()::where('id', $oldSlug->id)->update(['active' => 1]); - $this->disableLocaleSlugs($oldSlug->locale, $oldSlug->id); + if ($oldMatchingSlug) { + $isNowActive = (bool)($slugParams['active'] ?? false); + if ($oldMatchingSlug->active != $isNowActive) { + $this->slugs()->whereKey($oldMatchingSlug->getKey())->update(['active' => $isNowActive]); + if ($this->relationLoaded('slugs')) { + // Report update to slugs so that getSlug() returns the correct value + $slug = $this->slugs->whereKey($oldMatchingSlug->getKey()); + if ($slug) { + $slug->active = $isNowActive; + $slug->syncOriginalAttribute('active'); + } else { + // The relation was loaded before the old slug even existed, unload it and let it lazy reload as needed + $this->unsetRelation('slugs'); + } + } + if ($isNowActive) { + $this->disableLocaleSlugs($oldMatchingSlug->locale, $oldMatchingSlug->getKey()); + } } } else { - $this->addOneSlug($slugParams); + $this->addOneSlug($slugParams, true); } } @@ -204,44 +220,58 @@ public function getExistingSlug(array $slugParams, bool $forRecreate = false): ? return $query->first(); } - protected function addOneSlug(array $slugParams): void + protected function addOneSlug(array $slugParams, $alreadySuffixed = false): void { - $datas = []; - foreach ($slugParams as $key => $value) { - $datas[$key] = $value; + if (!$alreadySuffixed) { + $slugParams['slug'] = $this->suffixSlugIfExisting($slugParams); } - $datas['slug'] = $this->suffixSlugIfExisting($slugParams); - - $datas[$this->getForeignKey()] = $this->id; + $slugModel = \Illuminate\Database\Eloquent\Model::unguarded(fn() => $this->slugs()->create($slugParams)); - $slugModel = \Illuminate\Database\Eloquent\Model::unguarded(fn () => $this->getSlugModelClass()::create($datas)); - - $this->disableLocaleSlugs($slugParams['locale'], $slugModel->getKey()); + if ($this->relationLoaded('slugs')) { + // Report update to slugs so that getSlug() returns the correct value + $this->slugs->add($slugModel); + } + if (!$this->wasRecentlyCreated) { + // There will not be any old slug if the model was just created + $this->disableLocaleSlugs($slugParams['locale'], $slugModel->getKey()); + } } - public function disableLocaleSlugs(string|array $locale = null, int $except_slug_id = 0): void + public function disableLocaleSlugs(string|array $locale = null, int|array $except_slug_id = 0): void { - $query = $this->getSlugModelClass()::where($this->getForeignKey(), $this->id) - ->where('id', '<>', $except_slug_id); + $query = $this->slugs() + ->where('active', true) + ->whereNotIn('id', Arr::wrap($except_slug_id)); if ($locale !== null) { $query->whereIn('locale', Arr::wrap($locale)); } $query->update(['active' => 0]); + if ($this->relationLoaded('slugs')) { + // Report update to slugs so that getSlug() returns the correct value after an update without needing a refresh + $this->slugs->where('active', true) + ->whereNotIn('id', Arr::wrap($except_slug_id)) + ->whereIn('locale', Arr::wrap($locale)) + ->each(function (Model $slug) { + $slug->active = false; + $slug->syncOriginalAttribute('active'); + }); + } } private function suffixSlugIfExisting(array $slugParams): string { - $idsToExclude = $this->slugs()->withTrashed()->get('id')->pluck('id', 'id')->all(); - $slugBackup = $slugParams['slug']; unset($slugParams['active']); + for ($i = 2; $i <= $this->nb_variation_slug + 1; ++$i) { + /** @var Builder $qCheck */ $qCheck = $this->getSlugModelClass()::query(); $qCheck->whereNull($this->getDeletedAtColumn()); - $qCheck->whereNotIn('id', $idsToExclude); + $qCheck->whereNot($this->getForeignKey(), $this->getKey()); + foreach ($slugParams as $key => $value) { $qCheck->where($key, '=', $value); } @@ -259,10 +289,13 @@ private function suffixSlugIfExisting(array $slugParams): string } /** + * @deprecated use suffixSlugIfExisting instead * Checks if a slug needs a suffix due to a conflict with another model. */ private function slugNeedsSuffix(array $slugParams): bool { + trigger_deprecation('area17/twill', '3.5', 'The slugNeedsSuffix method is deprecated and will be removed in 4.x use suffixSlugIfExisting instead'); + unset($slugParams['active']); $hasExisting = false; @@ -325,58 +358,88 @@ public function getSlugAttribute(): string return $this->getSlug(); } - public function getSlugParams(?string $locale = null): ?array + public function getSlugDeps(): array { - if (!isset($this->translations) || count(getLocales()) === 1 || $this->translations->isEmpty()) { - $slugParams = $this->getSingleSlugParams($locale); - if ($slugParams !== null && !empty($slugParams)) { - return $slugParams; - } + return $this->slugDeps ?? array_slice($this->slugAttributes ?? [], 1); + } + public function getSlugFields(): array + { + if (!isset($this->slugFields) && isset($this->slugAttributes)) { + trigger_deprecation('area17/twill', '3.5', 'The slugAttributes property has been deprecated instead define slug fields with the slugFields property and additional columns with the slugDeps property'); } + return $this->slugFields ?? array_slice($this->slugAttributes ?? [], 0, 1); + } + public function getSlugParams(?string $locale = null, bool $skipUnchanged = false): ?array + { + $translatedAttributes = $this->getTranslatedAttributes(); $slugParams = []; - foreach ($this->translations as $translation) { - if ($translation->locale === $locale || $locale === null) { - $attributes = $this->slugAttributes; - - if (!$attributes) { - continue; - } - - $slugAttribute = array_shift($attributes); + $deps = $this->getSlugDeps(); + $fields = $this->getSlugFields(); + if (empty($fields)) { + return $locale === null ? [] : null; + } + foreach ($locale ? [$locale] : getLocales() as $appLocale) { + if ($appLocale === $locale || $locale === null) { + $wasChanged = $this->wasRecentlyCreated; - $slugDependenciesAttributes = []; - foreach ($attributes as $attribute) { - if (!isset($this->$attribute)) { - throw new \Exception("You must define the field {$attribute} in your model"); + $translation = $this->translate($appLocale, $this->usePropertyFallback()); + $getAttributeValue = function ($attribute) use ($translatedAttributes, $appLocale, $translation, &$wasChanged) { + if (in_array($attribute, $translatedAttributes)) { + if (!$wasChanged && $translation?->isDirty($attribute)) { + $wasChanged = true; + } + if (!isset($translation->$attribute) && config('translatable.use_property_fallback', false)) { + $fallback = $this->getFallbackLocale($appLocale); + $fallbackTranslation = $this->translate($fallback); + if (!$wasChanged && $fallbackTranslation?->isDirty($attribute)) { + $wasChanged = true; + } + return $fallbackTranslation?->$attribute; + } + return $translation?->$attribute; } + if (!$wasChanged && $this->isDirty($attribute)) { + $wasChanged = true; + } + return $this->$attribute; + }; - $slugDependenciesAttributes[$attribute] = $this->$attribute; - } + $slugFields = array_filter(array_map($getAttributeValue, $fields)); - if (!isset($translation->$slugAttribute) && !isset($this->$slugAttribute)) { - throw new \Exception("You must define the field {$slugAttribute} in your model"); + if (empty($slugFields)) { + // Skip empty slugs + continue; + } + $slugDependencies = array_filter(array_combine($deps, array_map($getAttributeValue, $deps))); + if (count($slugDependencies) !== count($deps)) { + $missing = array_keys(array_diff_key(array_flip($deps), $slugDependencies)); + throw new \Exception('The slug dependencies ' . (Arr::join($missing, ', ', ' and ') . ' are missing')); } - $slugParam = [ - 'active' => $translation->active ?? true, - 'slug' => $translation->$slugAttribute ?? $this->$slugAttribute, - 'locale' => $translation->locale, - ] + $slugDependenciesAttributes; + 'active' => $translation?->active ?? true, + 'slug' => Arr::join($slugFields, '-'), + 'locale' => $appLocale, + ] + $slugDependencies; if ($locale != null) { - return $slugParam; + return !$skipUnchanged || $wasChanged ? $slugParam : null; } - $slugParams[] = $slugParam; + if (!$skipUnchanged || $wasChanged) { + $slugParams[$appLocale] = $slugParam; + } } } return $locale === null ? $slugParams : null; } + /** @deprecated */ public function getSingleSlugParams(?string $locale = null): ?array { + trigger_deprecation('area17/twill', '3.5', 'The getSingleSlugParams method is deprecated and will be removed in 4.x as it is not used, use getSlugParams instead'); + $slugParams = []; foreach (getLocales() as $appLocale) { if ($appLocale === $locale || $locale === null) { @@ -433,7 +496,7 @@ public function getForeignKey(): string protected function getSuffixSlug(): string|int { - return $this->id; + return $this->getKey(); } /** diff --git a/src/Models/Model.php b/src/Models/Model.php index a999840f2..a49d521c1 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -28,6 +28,17 @@ abstract class Model extends BaseModel implements TaggableInterface, TwillModelC public $timestamps = true; + public static function boot(): void + { + static::saving(function (self $model) { + // When saving a model multiple times in a row without refresh, then the model should not be recently created anymore + if ($model->wasRecentlyCreated) { + $model->wasRecentlyCreated = false; + } + }); + parent::boot(); + } + protected function isTranslationModel(): bool { return Str::endsWith(get_class($this), 'Translation'); diff --git a/src/Repositories/Behaviors/HandleBlocks.php b/src/Repositories/Behaviors/HandleBlocks.php index d453bde92..0901e56b6 100644 --- a/src/Repositories/Behaviors/HandleBlocks.php +++ b/src/Repositories/Behaviors/HandleBlocks.php @@ -109,7 +109,7 @@ public function afterSaveHandleBlocks(Model $object, array $fields): void $this->validateBlockArray($block, $blockInstance, $handleTranslations); } - $existingBlockIds = $object->blocks()->pluck('id')->toArray(); + $existingBlockIds = $object->wasRecentlyCreated ? [] : $object->blocks()->pluck('id')->toArray(); $usedBlockIds = []; diff --git a/src/Repositories/Behaviors/HandleSlugs.php b/src/Repositories/Behaviors/HandleSlugs.php index 6cec464bb..dea901fd2 100644 --- a/src/Repositories/Behaviors/HandleSlugs.php +++ b/src/Repositories/Behaviors/HandleSlugs.php @@ -11,38 +11,32 @@ trait HandleSlugs { public function beforeSaveHandleSlugs(TwillModelContract $object, array $fields): void { - if (property_exists($this->model, 'slugAttributes')) { + if (method_exists($this->model, 'getSlugFields')) { $object->twillSlugData = []; $submittedLanguages = Collection::make($fields['languages'] ?? []); - foreach (getLocales() as $locale) { - $submittedLanguage = Arr::first($submittedLanguages->filter(function ($lang) use ($locale) { + $atLeastOneLanguageIsPublished = $submittedLanguages->contains(function ($language) { + return $language['published']; + }); + foreach (getLocales() as $index => $locale) { + $submittedLanguage = $submittedLanguages->first(function ($lang) use ($locale) { return $lang['value'] === $locale; - })); + }); - if (isset($fields['slug'][$locale]) && !empty($fields['slug'][$locale])) { - $currentSlug = []; + $shouldPublishFirstLanguage = ($index === 0 && !$atLeastOneLanguageIsPublished); + + $fallBack = $fields[$locale]['active'] ?? false; + + // Copy active fallback behavior from HandleTranslations + $activeField = $shouldPublishFirstLanguage || ($submittedLanguage['published'] ?? $fallBack); + + $currentSlug = []; + $currentSlug['locale'] = $locale; + $currentSlug['active'] = $activeField; + if (!empty($fields['slug'][$locale])) { $currentSlug['slug'] = $fields['slug'][$locale]; - $currentSlug['locale'] = $locale; - $currentSlug['active'] = $submittedLanguage['published'] ?? true; - $currentSlug = $this->getSlugParameters($object, $fields, $currentSlug); - $object->twillSlugData[] = $currentSlug; - } else { - $slugParams = $this->model->slugAttributes; - $slugData = []; - - foreach ($slugParams as $param) { - $slugData[] = $fields[$param][$locale] ?? ''; - } - - if (!empty(Arr::join($slugData, '-'))) { - $object->twillSlugData[] = [ - 'slug' => Str::slug(Arr::join($slugData, '-')), - 'active' => $submittedLanguage['published'] ?? 1, - 'locale' => $locale - ]; - } } + $object->twillSlugData[$locale] = $currentSlug; } } } @@ -62,8 +56,11 @@ public function getFormFieldsHandleSlugs(TwillModelContract $model, array $field return $fields; } - public function getSlugParameters(TwillModelContract $object, array $fields, array $slug): array + /** @deprecated We merge twillSlugData with getSlugParams on save, to avoid getting outdated data */ + public function getSlugParameters(TwillModelContract $object, array $fields, array $slug): ?array { + trigger_deprecation('area17/twill', '3.5', 'The getSlugParameters method is deprecated as it returns data before fields are applied and will be removed in 4.x'); + $slugParams = $object->getSlugParams($slug['locale']); foreach ($object->slugAttributes as $param) { @@ -73,7 +70,6 @@ public function getSlugParameters(TwillModelContract $object, array $fields, arr $slug[$param] = $slugParams[$param]; } } - return $slug; } diff --git a/src/Repositories/Behaviors/HandleTranslations.php b/src/Repositories/Behaviors/HandleTranslations.php index a4f54fd2a..c16e1ff00 100644 --- a/src/Repositories/Behaviors/HandleTranslations.php +++ b/src/Repositories/Behaviors/HandleTranslations.php @@ -28,9 +28,9 @@ public function prepareFieldsBeforeSaveHandleTranslations(?TwillModelContract $o }); foreach ($locales as $index => $locale) { - $submittedLanguage = Arr::first($submittedLanguages->filter(function ($lang) use ($locale) { + $submittedLanguage = $submittedLanguages->first(function ($lang) use ($locale) { return $lang['value'] === $locale; - })); + }); $shouldPublishFirstLanguage = ($index === 0 && !$atLeastOneLanguageIsPublished); @@ -52,7 +52,7 @@ public function prepareFieldsBeforeSaveHandleTranslations(?TwillModelContract $o return [ $attribute => ($attributeValue[$locale] ?? $fields[$locale][$attribute] ?? null), ]; - })->toArray(); + })->all(); } unset($fields['languages']); diff --git a/tests/integration/Anonymous/AnonymousModule.php b/tests/integration/Anonymous/AnonymousModule.php index 0b7323428..f66132e76 100644 --- a/tests/integration/Anonymous/AnonymousModule.php +++ b/tests/integration/Anonymous/AnonymousModule.php @@ -94,6 +94,12 @@ class AnonymousModule /** @var string[] */ private array $slugAttributes = []; + /** @var string[] */ + private array $slugFields = []; + + /** @var string[] */ + private array $slugDeps = []; + protected function __construct(public string $namePlural, public Application $app) { $this->classPrinter = new PsrPrinter(); @@ -203,6 +209,22 @@ public function withSlugAttributes(array $fields): self return $this; } + + public function withSlugFields(array $fields): self + { + $this->slugFields = $fields; + + return $this; + } + + public function withSlugDeps(array $fields): self + { + $this->slugDeps = $fields; + + return $this; + } + + /** * Boots the anonymous module and returns the model class. */ @@ -626,6 +648,18 @@ private function getModelClass(string $classNameWithNamespace): PhpNamespace $this->slugAttributes ); } + if ($this->slugDeps !== []) { + $class->addProperty( + 'slugDeps', + $this->slugDeps + ); + } + if ($this->slugFields !== []) { + $class->addProperty( + 'slugFields', + $this->slugFields + ); + } foreach ($this->belongsToMany as $name => $target) { $method = $class->addMethod($name); diff --git a/tests/integration/Models/StandaloneSlugTest.php b/tests/integration/Models/StandaloneSlugTest.php new file mode 100644 index 000000000..ced0b4899 --- /dev/null +++ b/tests/integration/Models/StandaloneSlugTest.php @@ -0,0 +1,85 @@ + true, 'value' => 'en'], ['published' => true, 'value' => 'fr'], ['published' => true, 'value' => 'pt-BR']]; + $createAuthor = fn (): Author => $this->app->get(AuthorRepository::class)->create(['name' => ['en' => 'Test author', 'fr' => 'Test auteur', 'pt-BR' => 'Test author'], 'languages' => $allPublished]); + + $author = $createAuthor(); + $this->assertCount(3, $author->getSlugParams(null, true)); + $this->assertEquals(3, $author->slugs()->count()); + $this->assertEquals('test-author', $author->slug); + $this->assertEquals('test-auteur', $author->getSlug('fr')); + $this->assertEquals('test-author', $author->getSlug('pt-BR')); + + app(AuthorRepository::class) + ->create(['name' => ['en' => 'Random author to change id']]); + + DB::enableQueryLog(); + $author->save(); + $log = DB::getRawQueryLog(); + // Nothing changed, there should be no queries + $this->assertCount(0, $log); + + DB::enableQueryLog(); + $author->name = 'New test author'; + $author->translate('fr')->name = 'Nouveau test auteur'; + $author->save(); + $log = DB::getRawQueryLog(); + DB::flushQueryLog(); + + // There should be 2 select and 2 updates for slugs and 1 update for the locale per locale changed + $this->assertEquals(10, count($log)); + $this->assertEquals('new-test-author', $author->getSlug('en')); + $this->assertEquals('nouveau-test-auteur', $author->getSlug('fr')); + // All queries combined should take less than 40ms (they usually take a total of 5ms, allow big range to avoid flaky test) + $this->assertLessThan(40, array_sum(Arr::pluck($log, 'time'))); + + $author2 = $createAuthor(); + $this->assertEquals('test-author-2', $author2->slug); + $this->assertEquals('test-auteur-2', $author2->getSlug('fr')); + $author3 = $createAuthor(); + $this->assertEquals('test-author-3', $author3->slug); + $this->assertEquals('test-auteur-3', $author3->getSlug('fr')); + $author4 = $createAuthor(); + $this->assertEquals('test-author-'.$author4->id, $author4->slug); + $this->assertEquals('test-auteur-'.$author4->id, $author4->getSlug('fr')); + $author4 = $author4->fresh(); + $author4->name = 'New author slug'; + DB::flushQueryLog(); + $author4->save(); + $log2 = DB::getRawQueryLog(); + // 2 slug existence check, 1 slug insert, 1 slug update, 1 translation update, + $this->assertCount(5, $log2); + $this->assertEquals(3, $author4->slugs()->whereActive(true)->count()); + $this->assertEquals(1, $author4->slugs()->whereActive(false)->count()); + $this->assertEquals('new-author-slug', $author4->slug); + $author2->delete(); + DB::flushQueryLog(); + $author4->name = 'Test author'; + $author4->save(); + $log3 = DB::getRawQueryLog(); + $this->assertEquals(3, $author4->slugs()->whereActive(true)->count()); + $this->assertEquals(2, $author4->slugs()->whereActive(false)->count()); + // 3 selects, 1 insert, 2 updates + $this->assertCount(6, $log3); + $this->assertEquals('test-author-2', $author4->slug); + DB::disableQueryLog(); + + $this->assertEquals(3, $author->slugs()->whereActive(true)->count()); + $this->assertEquals(5, $author->slugs()->count()); + } + +} diff --git a/tests/integration/Repositories/TagsHandlerTest.php b/tests/integration/Repositories/TagsHandlerTest.php index 7387af0d2..5105a6c1a 100644 --- a/tests/integration/Repositories/TagsHandlerTest.php +++ b/tests/integration/Repositories/TagsHandlerTest.php @@ -1,6 +1,6 @@ boot(); } + public function testMultipleSlugAttributes() + { + $module = AnonymousModule::make('usernames', $this->app) + ->withFields([ + 'first_name' => [], + 'last_name' => [], + ]) + ->withSlugFields([ + 'last_name', 'first_name' + ]) + ->boot(); + + $model = $module->getRepository()->create([ + 'first_name' => 'John', + 'last_name' => 'Doe', + ]); + + $this->assertEquals('doe-john', $model->getSlug()); + } + public function testBasicSlugModel(): void { $model = $this->module->getRepository()->create([ @@ -36,17 +57,21 @@ public function testBasicSlugModel(): void public function testBasicSlugModelDuplicate(): void { + $this->module->getRepository()->create([ + 'title' => 'Id increment', + 'slug' => ['en' => 'Id increment'], + ]); for ($i = 0; $i < 10; $i++) { $model = $this->module->getRepository()->create([ 'title' => 'My title', 'slug' => ['en' => 'my-title'], ]); - $this->assertEquals($i === 0 ? 'my-title' : 'my-title-' . $i + 1, $model->getSlug()); + $this->assertEquals($i === 0 ? 'my-title' : 'my-title-' . ($i > 2 ? $model->id : $i + 1), $model->getSlug()); } } - public function testSlugLooping(): void + public function testReactivateSlug(): void { $model = $this->module->getRepository()->create([ 'title' => 'My title', @@ -55,16 +80,27 @@ public function testSlugLooping(): void $this->assertEquals('my-title', $model->getSlug()); - $this->module->getRepository()->update($model->id, ['title' => 'My title 2']); + $this->module->getRepository()->update($model->id, ['title' => 'My title updated']); - $this->assertEquals('my-title-2', $model->fresh()->getSlug()); + $this->assertEquals('my-title-updated', $model->fresh()->getSlug()); - $this->assertCount(2, $model->slugs()->get()); + $activeSlug = $model->slugs()->where('active', true)->get(); + $inactiveSlug = $model->slugs()->where('active', false)->get(); + $this->assertEquals('my-title-updated', $activeSlug->first()->slug); + $this->assertEquals('my-title', $inactiveSlug->first()->slug); + $this->assertCount(1, $activeSlug); + $this->assertCount(1, $inactiveSlug); $this->module->getRepository()->update($model->id, ['title' => 'My title']); $this->assertEquals('my-title', $model->fresh()->getSlug()); - $this->assertCount(2, $model->slugs()->get()); + + $activeSlug = $model->slugs()->where('active', true)->get(); + $inactiveSlug = $model->slugs()->where('active', false)->get(); + $this->assertEquals('my-title', $activeSlug->first()->slug); + $this->assertEquals('my-title-updated', $inactiveSlug->first()->slug); + $this->assertCount(1, $activeSlug); + $this->assertCount(1, $inactiveSlug); } public function testCanReuseSoftDeletedSlug(): void @@ -79,6 +115,9 @@ public function testCanReuseSoftDeletedSlug(): void $this->module->getRepository()->delete($model->id); + $this->assertEquals(1, $model->slugs()->onlyTrashed()->count()); + $this->assertEquals(0, $model->slugs()->count()); + // Create a new model after the delete. $newModel = $this->module->getRepository()->create([ 'title' => 'My title', @@ -94,7 +133,7 @@ public function testCanReuseSoftDeletedSlug(): void // Restore the deleted model. $this->assertTrue($this->module->getRepository()->restore($model->id)); - $model = $this->module->getModelClassName()::find($model->id); + $model = $model->fresh(); $this->assertCount(1, $model->slugs()->get()); $this->assertEquals('my-title-2', $model->getSlug()); @@ -117,49 +156,45 @@ public function testCanReuseSoftDeletedSlugWithHistory(): void $this->module->getRepository()->delete($model->id); // Create a new model after the delete. - $newModel = $this->module->getRepository()->create([ + $this->module->getRepository()->create([ 'title' => 'My title', 'slug' => ['en' => 'slug-update'], ]); // Total slugs should be 3. - $this->assertEquals('my-title', $this->module->getSlugModelClassName()::withTrashed()->get()[0]->slug); - $this->assertEquals('slug-update', $this->module->getSlugModelClassName()::withTrashed()->get()[1]->slug); - $this->assertEquals('slug-update', $this->module->getSlugModelClassName()::withTrashed()->get()[2]->slug); + $slugs = $this->module->getSlugModelClassName()::withTrashed()->get(); + $this->assertEquals('my-title', $slugs[0]->slug); + $this->assertEquals('slug-update', $slugs[1]->slug); + $this->assertEquals('slug-update', $slugs[2]->slug); - $this->assertCount(3, $this->module->getSlugModelClassName()::withTrashed()->get()); + $this->assertCount(3, $slugs); // Restore the deleted model. $this->assertTrue($this->module->getRepository()->restore($model->id)); - $model = $this->module->getModelClassName()::find($model->id); + $model = $model->fresh(); $this->assertCount(2, $model->slugs()->get()); $this->assertEquals('slug-update-2', $model->getSlug()); } - public function testReactivateSlug(): void + public function testCustomSlugDoesntChangeOnUpdate(): void { + /** @var Model|HasSlug $model */ $model = $this->module->getRepository()->create([ 'title' => 'My title', - 'slug' => ['en' => 'my-title'], + 'slug' => ['en' => 'my-custom-slug'], ]); - $this->assertEquals('my-title', $model->getSlug()); - $this->assertCount(1, $model->slugs()->get()); - - $model = $this->module->getRepository()->update($model->id, [ - 'slug' => ['en' => 'slug-update'], - ]); + $this->assertEquals('my-custom-slug', $model->getSlug()); + $this->assertEquals(1, $model->slugs()->count()); - $this->assertEquals('slug-update', $model->getSlug()); - $this->assertCount(2, $model->slugs()->get()); + $model = $this->module->getRepository()->update($model->id, ['position' => 1]); - $model = $this->module->getRepository()->update($model->id, [ - 'slug' => ['en' => 'my-title'], - ]); + $this->assertEquals(1, $model->slugs()->count()); + $this->assertEquals('my-custom-slug', $model->getSlug()); - $this->assertEquals('my-title', $model->getSlug()); - $this->assertCount(2, $model->slugs()->get()); + $model = $this->module->getRepository()->update($model->id, ['title' => 'My new title']); + $this->assertEquals('my-new-title', $model->getSlug()); } }