diff --git a/.editorconfig b/.editorconfig index cd8eb86..df8e24c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,3 +13,6 @@ trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 \ No newline at end of file diff --git a/.github/coverage.svg b/.github/coverage.svg new file mode 100644 index 0000000..1db4533 --- /dev/null +++ b/.github/coverage.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + coverage + + 100 % + + \ No newline at end of file diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 7918815..2860d21 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,60 +1,70 @@ name: tests on: - pull_request: - branches: - - main - push: - branches: - - main + pull_request: + branches: + - main + push: + branches: + - main + - dev jobs: - tests: - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: true - matrix: - os: [ ubuntu-latest ] - php: [ 8.3, 8.2, 8.1 ] - laravel: [ 11.*, 10.*, 9.* ] - stability: [ prefer-lowest, prefer-stable ] - include: - - laravel: 11.* - testbench: 9.* - carbon: ^2.63 - - laravel: 10.* - testbench: 8.* - carbon: ^2.63 - - laravel: 9.* - testbench: 7.* - carbon: ^2.63 - exclude: - - laravel: 11.* - php: 8.1 - - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install SQLite 3 - run: | - sudo apt-get update - sudo apt-get install sqlite3 -y - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv - coverage: none\ - - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update - composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - - name: Execute tests - run: vendor/bin/phpunit + tests: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: true + matrix: + os: [ ubuntu-latest ] + php: [ 8.3, 8.2, 8.1 ] + laravel: [ 11.*, 10.*, 9.* ] + stability: [ prefer-lowest, prefer-stable ] + include: + - laravel: 11.* + testbench: 9.* + carbon: ^2.63 + - laravel: 10.* + testbench: 8.* + carbon: ^2.63 + - laravel: 9.* + testbench: 7.* + carbon: ^2.63 + exclude: + - laravel: 11.* + php: 8.1 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install SQLite 3 + run: | + sudo apt-get update + sudo apt-get install sqlite3 -y + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv + coverage: ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.3' && matrix.laravel == '11.*' && matrix.stability == 'prefer-stable' && 'xdebug' || 'none' }} + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: Execute tests + run: | + vendor/bin/phpunit ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.3' && matrix.laravel == '11.*' && matrix.stability == 'prefer-stable' && '--coverage-clover=clover.xml' || '' }} + + - name: Make code coverage badge + if: startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.3' && matrix.laravel == '11.*' && matrix.stability == 'prefer-stable' + uses: timkrase/phpunit-coverage-badge@v1.2.1 + with: + coverage_badge_path: .github/coverage.svg + push_badge: true + repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 1ea42a8..6eeba53 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,59 @@ [![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/translatable.svg?style=flat-square)](https://packagist.org/packages/javaabu/translatable) [![Test Status](../../actions/workflows/run-tests.yml/badge.svg)](../../actions/workflows/run-tests.yml) [![Total Downloads](https://img.shields.io/packagist/dt/javaabu/translatable.svg?style=flat-square)](https://packagist.org/packages/javaabu/translatable) - +![Code Coverage](./.github/coverage.svg) ## Introduction + Adds multi-lingual to Laravel models +To get started with this package, you can simply add `DbTranslatableSchema::columns($table);` or `JsonTranslatableSchema::columns($table);` to your migration `up` function. + +```php +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Javaabu\Translatable\DbTranslatable\DbTranslatableSchema; + +return new class extends Migration { + public function up(): void + { + Schema::create('posts', function (Blueprint $table) { + $table->id(); + + ... + + DbTranslatableSchema::columns($table); + }); + } + + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; +``` + +And then all you need to do is add the `Translatable` implementation using the `IsDbTranslatable` or `IsJsonTranslatable` trait. + +```php +... + +class Post extends Model implements Translatable +{ + use IsDbTranslatable; + +... +``` + +Now, your models will automatically be translated according to the current `app()->getLocale()`. To add different translations, all you need to do is + +```php +// to add title for dv language +$post->title_dv = "Mee Dhivehi title eh"; +``` + ## Documentation You'll find the documentation on [https://docs.javaabu.com/docs/translatable](https://docs.javaabu.com/docs/translatable). @@ -43,6 +90,7 @@ If you discover any security related issues, please email [info@javaabu.com](mai - [Javaabu Pvt. Ltd.](https://github.com/javaabu) - [Arushad Ahmed (@dash8x)](http://arushad.com) - [Xylam (@Xylam)](https://github.com/Xylam) +- [FlameXode (@WovenCoast)](https://github.com/WovenCoast) - [All Contributors](../../contributors) ## License diff --git a/clover.xml b/clover.xml new file mode 100644 index 0000000..b77f5c4 --- /dev/null +++ b/clover.xml @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composer.json b/composer.json index e7861b3..4883b78 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,11 @@ "name": "Xylam", "email": "jailam@javaabu.com", "role": "Developer" + }, + { + "name": "FlameXode", + "email": "hi@flamexo.de", + "role": "Developer" } ], "require": { diff --git a/config/translatable.php b/config/translatable.php index ea4a711..bf10973 100644 --- a/config/translatable.php +++ b/config/translatable.php @@ -10,5 +10,17 @@ | */ - // TODO + 'fields_ignored_for_translation' => [ + 'id', + 'lang', + 'created_at', + 'updated_at', + 'deleted_at', + ], + 'allowed_translation_locales' => [ + 'en' => 'English', + 'dv' => 'Dhivehi', + 'jp' => 'Japanese', + ], + 'lang_suffix_should_fallback' => false, ]; diff --git a/docs/about-us.md b/docs/about-us.md index a5951ce..75dfd0e 100644 --- a/docs/about-us.md +++ b/docs/about-us.md @@ -1,6 +1,6 @@ --- title: About Us -sidebar_position: 1.6 +sidebar_position: 1.7 --- [Javaabu](https://javaabu.com) is a web design agency based in the Maldives. diff --git a/docs/advanced-usage/_category_.json b/docs/advanced-usage/_category_.json new file mode 100644 index 0000000..074bf15 --- /dev/null +++ b/docs/advanced-usage/_category_.json @@ -0,0 +1,11 @@ +{ + "position": 1.4, + "label": "Advanced Usage", + "collapsible": true, + "collapsed": false, + "link": { + "type": "generated-index", + "slug": "/translatable/_categories/advanced-usage", + "description": "Additional features available in this package." + } +} diff --git a/docs/basic-usage/10-setting-up-your-migration-and-model.md b/docs/basic-usage/10-setting-up-your-migration-and-model.md new file mode 100644 index 0000000..a3bb271 --- /dev/null +++ b/docs/basic-usage/10-setting-up-your-migration-and-model.md @@ -0,0 +1,83 @@ +--- +title: Set up your Migration and Model +sidebar_position: 10 +--- + +:::info + +Translatables currently provides **two** different types of translatables, `Db` and `Json`. Check out [Advanced Usage > Difference between DB and JSON translatable](./70-difference-isdbtranslatable-isjsontranslatable) to learn the differences and design considerations for both + +::: + +## Setting up your migrations + +If you are setting up a new model, you can simply add either `DbTranslatableSchema::columns($table);` or `JsonTranslatableSchema::columns($table);` into your migration schema create function. + +```php +use Javaabu\Translatable\DbTranslatable\DbTranslatableSchema; + +return new class extends Migration { + public function up(): void + { + Schema::create('posts', function (Blueprint $table) { + $table->id(); + + // ... + + DbTranslatableSchema::columns($table); + }); + } + + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; +``` + +Or if you have already made the model, you can write a migration to add the columns to the existing table. + +```php +use Javaabu\Translatable\DbTranslatable\DbTranslatableSchema; + +return new class extends Migration { + public function up(): void + { + Schema::create('posts', function (Blueprint $table) { + DbTranslatableSchema::columns($table); + }); + } + + public function down(): void + { + Schema::table('posts', function (Blueprint $table) { + DbTranslatableSchema::revert($table); + }); + } +}; +``` + +:::danger + +The revert function is **unstable** as the testing suite for this has not been written yet. Use it with caution. + +::: + +## Setting up your models + + +All you need to do is add the `Translatable` implementation using the `IsDbTranslatable` or `IsJsonTranslatable` trait. + +```php +... +use Javaabu\Translatable\DbTranslatable\IsDbTranslatable; +use Javaabu\Translatable\Translatable; + +class Post extends Model implements Translatable +{ + use IsDbTranslatable; + +... +``` + +Once this is setup, you are good to go! diff --git a/docs/basic-usage/20-fetching-translations.md b/docs/basic-usage/20-fetching-translations.md new file mode 100644 index 0000000..f095fe8 --- /dev/null +++ b/docs/basic-usage/20-fetching-translations.md @@ -0,0 +1,30 @@ +--- +title: Fetching Translations +sidebar_position: 20 +--- + +There are multiple ways to use locale translated attributes within your code. + +## Using application locale + +If you use `app()->setLocale('dv');` then translatables will automatically detect this and give you the translated attribute if available. + +```php +app()->setLocale('dv'); + +// ... + +// assuming you have added the translation +$post->title // Mee dhivehi title eh +``` + +## Using language code suffixes + +If you need a specific locale, you can use the language code suffix to always get that locale. + +```php +$post->title_dv // Mee dhivehi title eh +$post->title_ar // null +``` + +By default, the language code suffix does not fallback to default locale. To change this behaviour, change the `lang_suffix_should_fallback` to true in the [config](../installation-and-setup.md#publishing-the-config-file) file. diff --git a/docs/basic-usage/30-adding-translations.md b/docs/basic-usage/30-adding-translations.md new file mode 100644 index 0000000..058ba10 --- /dev/null +++ b/docs/basic-usage/30-adding-translations.md @@ -0,0 +1,22 @@ +--- +title: Adding Translations +sidebar_position: 30 +--- + +Translations can be added using the `addTranslation` / `addTranslations` methods available on models with Translatables. + +```php +// add translation for one attribute +$post->addTranslation('dv', 'title', 'Mee dhivehi title eh'); +$post->addTranslation('dv', 'body', 'Mee dhivehi liyumeh'); + +// add translations for multiple attributes at once +$post->addTranslations('dv', [ + 'title' => 'Mee dhivehi title eh', + 'body' => 'Mee dhivehi liyumeh' +]); + +$post->title_dv // Mee dhivehi title eh +$post->body_dv // Mee dhivehi liyumeh +``` + diff --git a/docs/basic-usage/70-difference-isdbtranslatable-isjsontranslatable.md b/docs/basic-usage/70-difference-isdbtranslatable-isjsontranslatable.md new file mode 100644 index 0000000..bca0354 --- /dev/null +++ b/docs/basic-usage/70-difference-isdbtranslatable-isjsontranslatable.md @@ -0,0 +1,14 @@ +--- +title: Difference between DB and JSON translatable +sidebar_position: 70 +--- + +Functionally, both `IsDbTranslatable` and `IsJsonTranslatable` both implement all functions of `Translatable`, there are no differences in syntax when using either of these. + +But there are a few notable differences in how each type implements `Translatables`. Depending on the use case and scale of the application, one may make more sense than the other. + +| IsDbTranslatable | IsJsonTranslatable | +|---------------------------------------------------------|-------------------------------------------------------------| +| - Works by creating additional rows for each language | - Works by adding a `translatables` JSON field to every row | +| - Additional queries required to check if locale exists | - No additional queries needed for translating | +| - Indexable by default | - Needs to search through JSON field | diff --git a/docs/basic-usage/how-to-use-feature.md b/docs/basic-usage/how-to-use-feature.md deleted file mode 100644 index eb46af9..0000000 --- a/docs/basic-usage/how-to-use-feature.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: How to use some feature ---- - -Write the name of the feature as the title and give instructions on how to use it diff --git a/docs/changelog.md b/docs/changelog.md index 9f959f6..8bc8a46 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,6 @@ --- title: Changelog -sidebar_position: 1.5 +sidebar_position: 1.6 --- All notable changes to this package are documented on [GitHub](https://github.com/Javaabu/translatable/blob/main/CHANGELOG.md) diff --git a/docs/installation-and-setup.md b/docs/installation-and-setup.md index 8eb1cd2..de38306 100644 --- a/docs/installation-and-setup.md +++ b/docs/installation-and-setup.md @@ -20,5 +20,30 @@ php artisan vendor:publish --provider="Javaabu\Translatable\TranslatableServiceP This is the default content of the config file: ```php -// TODO +return [ + /* + |-------------------------------------------------------------------------- + | Some config option + |-------------------------------------------------------------------------- + | + | Give a description of what each config option is like this + | + */ + + 'fields_ignored_for_translation' => [ + 'id', + 'lang', + 'created_at', + 'updated_at', + 'deleted_at', + ], + 'allowed_translation_locales' => [ + 'en' => 'English', + 'dv' => 'Dhivehi', + 'jp' => 'Japanese', + ], + 'lang_suffix_should_fallback' => false, +]; + + ``` diff --git a/docs/intro.md b/docs/intro.md index 25e0bf7..05531db 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -11,4 +11,62 @@ This package is currently under development. If anything works, that's a surpris ::: -[Translatable](https://github.com/Javaabu/translatable) Adds multi-lingual to Laravel models. +[Translatable](https://github.com/Javaabu/translatable) adds multi-lingual to Laravel models. + +To get started with this package, you can simply add `DbTranslatableSchema::columns($table);` to your migration `up` function. + +```php +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; +use Javaabu\Translatable\DbTranslatable\DbTranslatableSchema; + +return new class extends Migration { + public function up(): void + { + Schema::create('posts', function (Blueprint $table) { + $table->id(); + + ... + + DbTranslatableSchema::columns($table); + }); + } + + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; +``` + +:::danger + +This function is not currently implemented but is in the plans at this moment. + +> We also provide a `DBTranslatableSchema::revert($table)` function to put in the `down` function, this isn't necessary but it's good to have. + +::: + +And then all you need to do is add the `Translatable` implementation using the `IsDbTranslatable` or `IsJsonTranslatable` trait. + +```php +... + +class Post extends Model implements Translatable +{ + use IsDbTranslatable; + +... +``` + +> Differences between `IsDbTranslatable` and `IsJsonTranslatable` are listed in [] + +Now, your models will automatically be translated according to the current `app()->getLocale()`. To add different translations, all you need to do is + +```php +// to add title for dv language +$post->title_dv = "Mee Dhivehi title eh"; +``` + +> If adding translations give an error, make sure the locale is allowed in `allowed_translation_locales` in `config/translatable.php`. Check out [Installation and Setup > Publishing the config file](./installation-and-setup.md#publishing-the-config-file) for information on how to setup your config file. diff --git a/docs/questions-and-security.md b/docs/questions-and-security.md index 375937c..c48c95f 100644 --- a/docs/questions-and-security.md +++ b/docs/questions-and-security.md @@ -1,6 +1,6 @@ --- title: Questions and Security -sidebar_position: 1.4 +sidebar_position: 1.5 --- Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving this package? Feel free to create an [issue](../../issues) on GitHub, we'll try to address it as soon as possible. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 899b1ed..edef407 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,6 +10,11 @@ tests + + + src + + diff --git a/src/Abstract/IsTranslatable.php b/src/Abstract/IsTranslatable.php new file mode 100644 index 0000000..0e25151 --- /dev/null +++ b/src/Abstract/IsTranslatable.php @@ -0,0 +1,179 @@ +getTable()); + } + + /** + * Get non translatable fields without pivots and fields ignored for translation + * + * Use getAllNonTranslatables to get non translatables including pivots and fields ignored for translation + * + * @return array + */ + public function getNonTranslatables(): array + { + $all_fields = $this->getAllAttributes(); + + $hide = array_merge($this->getTranslatables(), $this->getFieldsIgnoredForTranslation(), $this->getNonTranslatablePivots()); + + return array_values(array_diff($all_fields, $hide)); + } + + /** + * Get all non translatable fields, including pivots and fields ignored for translation + * + * @return array + */ + public function getAllNonTranslatables(): array + { + return array_merge($this->getNonTranslatables(), $this->getFieldsIgnoredForTranslation(), $this->getNonTranslatablePivots()); + } + + /** + * Check if relation is a non translatable pivot + * + * @param string $relation + * @return bool + */ + public function isNonTranslatablePivot(string $relation): bool + { + return in_array($relation, $this->getNonTranslatablePivots()); + } + + /** + * Check if a given locale is the current default locale + * + * @param string $locale + * @return bool + */ + public function isDefaultTranslationLocale(string $locale): bool + { + return $this->getDefaultTranslationLocale() == $locale; + } + + /** + * Check if a given locale is allowed to translate to + * + * @param string $locale + * @return bool + */ + public function isAllowedTranslationLocale(string $locale): bool + { + return in_array($locale, $this->getAllowedTranslationLocales()); + } + + /** + * Return all the translation locales allowed in the config file + * + * @return array + */ + public function getAllowedTranslationLocales(): array + { + return array_keys(config('translatable.allowed_translation_locales')); + } + + /** + * Check if a given field is translatable + * + * @param string $field + * @return bool + */ + public function isTranslatable(string $field): bool + { + return in_array($field, $this->getTranslatables()); + } + + /** + * Bulk add translatable fields + * + * @param string $locale + * @param array $fields + * @return $this + * @throws LanguageNotAllowedException + */ + public function addTranslations(string $locale, array $fields): static + { + if (! $this->isAllowedTranslationLocale($locale)) { + throw LanguageNotAllowedException::create($locale); + } + + foreach ($fields as $field => $value) { + $this->addTranslation($locale, $field, $value); + } + + return $this; + } + + /** + * Get the field and locale for a given attribute if possible + * + * 'title_en' would return ['title', 'en'] + * + * @param string $key + * @return array + */ + public function getFieldAndLocale(string $key): array + { + $locale = Str::afterLast($key, '_'); + + if (empty($locale) || (! $this->isAllowedTranslationLocale($locale))) { + return [$key, null]; + } + + $field = Str::beforeLast($key, '_'); + return [$field, $locale]; + } + + public function getAttribute($key): mixed + { + // Add support for compoships + if (is_array($key)) { //Check for multi-columns relationship + return array_map(function ($k) { + // recursive call with a string + return self::getAttribute($k); + }, $key); + } + + // translate using current app locale if possible + if ($this->isTranslatable($key)) { + return $this->translate($key, app()->currentLocale()); + } + + // check if is a suffixed attribute + [$field, $locale] = $this->getFieldAndLocale($key); + if ($locale && $this->isTranslatable($field)) { + return $this->translate($field, $locale, config('translatable.lang_suffix_should_fallback', false)); + } + + // fallback to parent + return parent::getAttribute($key); + } + + /** + * @throws LanguageNotAllowedException + */ + public function setAttribute($key, $value): mixed + { + [$field, $locale] = $this->getFieldAndLocale($key); + + if ($locale && $this->isTranslatable($field)) { + return $this->addTranslation($locale, $field, $value); + } + + return parent::setAttribute($key, $value); + } +} diff --git a/src/DbTranslatable/DbTranslatableSchema.php b/src/DbTranslatable/DbTranslatableSchema.php new file mode 100644 index 0000000..b3aeadd --- /dev/null +++ b/src/DbTranslatable/DbTranslatableSchema.php @@ -0,0 +1,24 @@ +foreignId('translatable_parent_id')->nullable(); + + $table->string('lang')->index(); + } + + public static function revert(Blueprint $table): void + { + $table->dropForeign('translatable_parent_id'); + $table->dropColumn('translatable_parent_id'); + + $table->dropIndex('lang'); + $table->dropColumn('lang'); + } +} diff --git a/src/DbTranslatable/IsDbTranslatable.php b/src/DbTranslatable/IsDbTranslatable.php new file mode 100644 index 0000000..fcec085 --- /dev/null +++ b/src/DbTranslatable/IsDbTranslatable.php @@ -0,0 +1,170 @@ +fields_ignored_for_translation, + config('translatable.fields_ignored_for_translation') + ))); + } + + public function translations() + { + return $this->hasMany(self::class, 'translatable_parent_id', 'id'); + } + + public function defaultTranslation() + { + return $this->belongsTo(self::class, 'translatable_parent_id', 'id'); + } + + public function isDefaultTranslation(): bool + { + return $this->getAttributeValue('translatable_parent_id') == null; + } + + public function translate(string $field, ?string $locale = null, bool $fallback = true): mixed + { + // Use current app locale whenever locale isn't provided + if (is_null($locale)) { + $locale = app()->getLocale(); + } + + // If default lang just return the field normally + if ($this->isDefaultTranslationLocale($locale)) { + return $this->getAttributeValue($field); + } + + // If the locale is not allowed then return null + if (!$this->isAllowedTranslationLocale($locale)) { + return $fallback ? $this->getAttributeValue($field) : null; + } + + // If the field is not in the translatable fields list then return null + if (!$this->isTranslatable($field)) { + return $fallback ? $this->getAttributeValue($field) : null; + } + + // get default translation to check its language first + $defaultTranslation = $this->isDefaultTranslation() ? $this : $this->defaultTranslation; + if ($defaultTranslation->lang == $locale) { + return $defaultTranslation->getAttributeValue($field); + } + // attempt to fetch the first translation within translatable rows + $translation = $defaultTranslation->translations()->where('lang', $locale)->first(); + + // fallback if the translation doesn't exist in any of the translated rows + if (!$translation) { + return $fallback ? $this->getAttributeValue($field) : null; + } + + return $translation->getAttributeValue($field); + } + + public function getDefaultTranslationLocale(): string + { + // yes this may not be the correct default translation locale + // but we do this check to see if we need to fetch another row + // from the database. + return $this->getAttributeValue('lang'); + } + + public function clearTranslations(?string $locale = null): void + { + if (! $locale) { + // nuke all except the main one + $parent_id = $this->isDefaultTranslation() ? $this->id : $this->translatable_parent_id; + self::query()->where('translatable_parent_id', $parent_id)->withTrashed()->forceDelete(); + } else { + // check the current one lang, if it's correct delete it + if ($this->lang == $locale) { + $this->delete(); + } else { + $defaultTranslation = $this->isDefaultTranslation() ? $this : $this->defaultTranslation; + if ($defaultTranslation->lang == $locale) { + $defaultTranslation->delete(); + } + $defaultTranslation->translations()->where('lang', $locale)->delete(); + } + } + } + + public function hasTranslation(?string $locale = null): bool + { + if (is_null($locale)) { + $locale = app()->getLocale(); + } + + // if the current one is the correct lang no need to fetch from database + if ($this->lang == $locale) { + return true; + } + + if ($this->isDefaultTranslation()) { + $translation = $this->translations()->where('lang', $locale); + return $translation->exists(); + } else { + $translation = self::query()->where([ + 'translatable_parent_id' => $this->translatable_parent_id, + 'lang' => $locale, + ])->orWhere([ + 'id' => $this->translatable_parent_id, + 'lang' => $locale, + ]); + return $translation->exists(); + } + } + + /** + * @throws LanguageNotAllowedException + */ + public function addTranslation(string $locale, string $field, string $value): static + { + if (! $this->isAllowedTranslationLocale($locale)) { + throw LanguageNotAllowedException::create($locale); + } + + $defaultTranslation = $this->isDefaultTranslation() ? $this : $this->defaultTranslation; + + // check if the default translation is already the correct locale + if ($defaultTranslation->lang == $locale) { + $defaultTranslation->setAttribute($field, $value); + $defaultTranslation->save(); + return $defaultTranslation; + } + + // check if a translated object exists for this locale + $newTranslation = $defaultTranslation->translations()->where('lang', $locale)->first(); + + // if there is none, make a new blank translation of this object + if (! $newTranslation) { + $newTranslation = new self(); + + // copy all the attributes of the current object to the new translation + // this ensures no columns are left null + foreach ($this->getAllAttributes() as $attribute) { + // TODO: make this primary key rely on some sort of config available per model + if ($attribute == "id") continue; + $newTranslation->setAttribute($attribute, $defaultTranslation->getAttributeValue($attribute)); + } + $newTranslation->setAttribute('translatable_parent_id', $defaultTranslation->id); + } + + $newTranslation->setAttribute($field, $value); + $newTranslation->setAttribute('lang', $locale); + $newTranslation->save(); + + return $newTranslation; + } +} diff --git a/src/Exceptions/LanguageNotAllowedException.php b/src/Exceptions/LanguageNotAllowedException.php new file mode 100644 index 0000000..fc7a47a --- /dev/null +++ b/src/Exceptions/LanguageNotAllowedException.php @@ -0,0 +1,18 @@ +fields_ignored_for_translation, + config('translatable.fields_ignored_for_translation') + ))); + } + + /** + * Translate a given field to the given locale + * + * If fallback is true, fallback to default locale if given locale is unavailable + * + * @param string $field + * @param string|null $locale + * @param bool $fallback + * @return mixed + */ + public function translate(string $field, ?string $locale = null, bool $fallback = true): mixed + { + // Use current app locale whenever locale isn't provided + if (is_null($locale)) { + $locale = app()->getLocale(); + } + + // If default lang just return the field normally + if ($this->isDefaultTranslationLocale($locale)) { + return $this->getAttributeValue($field); + } + + // If the locale is not allowed then return null + if (! $this->isAllowedTranslationLocale($locale)) { + return $fallback ? $this->getAttributeValue($field) : null; + } + + // If the field is not in the translatable fields list then return null + if (! $this->isTranslatable($field)) { + return $fallback ? $this->getAttributeValue($field) : null; + } + + $translations = $this->getAttributeValue('translations'); + + // Check if translations for that locale exists + if (! isset($translations[$locale])) { + return $fallback ? $this->getAttributeValue($field) : null; + } + + return $translations[$locale][$field]; + } + + /** + * Gets default translation locale + * + * @return string + */ + public function getDefaultTranslationLocale(): string + { + return $this->getAttributeValue('lang'); + } + + /** + * Clear translations for a given language or for all languages if none is given + * + * @param string|null $locale + * @return void + */ + public function clearTranslations(?string $locale = null): void + { + // clear all translations if none is provided + if (is_null($locale)) { + $this->translations = null; + $this->save(); + return; + } + + $translations = $this->getAttributeValue('translations'); + + if (! isset($translations[$locale])) { + return; + } + + unset($translations[$locale]); + + $this->translations = $translations; + $this->save(); + } + + /** + * Ensures that translations exist for a given locale + * + * @param string|null $locale + * @return bool + */ + public function hasTranslation(?string $locale = null): bool + { + if (is_null($locale)) { + $locale = app()->getLocale(); + } + + return isset($this->translations[$locale]); + } + + + /** + * Add a new locale to this object + * + * @param string $locale + * @param string $field + * @param string $value + * @return $this + */ + public function addTranslation(string $locale, string $field, string $value): static + { + if (! $this->isAllowedTranslationLocale($locale)) { + throw LanguageNotAllowedException::create($locale); + } + + /** @var array $translations */ + $translations = $this->translations ?? []; + $translations[$locale] = array_merge( + array_key_exists($locale, $translations) ? $translations[$locale] : [], + [$field => $value], + ['lang' => $locale] + ); + $this->translations = $translations; + $this->save(); + + return $this; + } +} diff --git a/src/JsonTranslatable/JsonTranslatableSchema.php b/src/JsonTranslatable/JsonTranslatableSchema.php new file mode 100644 index 0000000..6cc808d --- /dev/null +++ b/src/JsonTranslatable/JsonTranslatableSchema.php @@ -0,0 +1,31 @@ +runningUnitTests()) { +// $table->text('translations')->nullable(); +// } else { + $table->json('translations')->nullable(); +// } + + $table->string('lang')->index(); + } + + public static function revert(Blueprint $table): void + { + $table->dropIndex('lang'); + $table->dropColumn(['translations', 'lang']); + } +} diff --git a/src/Translatable.php b/src/Translatable.php new file mode 100644 index 0000000..58d9af6 --- /dev/null +++ b/src/Translatable.php @@ -0,0 +1,140 @@ + + */ + public function getAllowedTranslationLocales(): array; + + /** + * Check if given locale is allowed + * + * @param string $locale + * @return boolean + */ + public function isAllowedTranslationLocale(string $locale): bool; + + /** + * Add an attribute with a new locale to this object + * + * @param string $locale + * @param string $field + * @param string $value + * @return $this + * @throws LanguageNotAllowedException + */ + public function addTranslation(string $locale, string $field, string $value): static; + + /** + * Add a new locale to this object + * + * @param string $locale + * @param array $fields + * @return $this + */ + public function addTranslations(string $locale, array $fields): static; +} diff --git a/src/TranslatableServiceProvider.php b/src/TranslatableServiceProvider.php index 50b588a..b23062f 100644 --- a/src/TranslatableServiceProvider.php +++ b/src/TranslatableServiceProvider.php @@ -9,9 +9,12 @@ class TranslatableServiceProvider extends ServiceProvider /** * Bootstrap the application services. */ - public function boot() + public function boot(): void { // declare publishes +// $this->commands([ +// \Javaabu\Translatable\Commands\ImplementTranslatablesForModel::class, +// ]); if ($this->app->runningInConsole()) { $this->publishes([ __DIR__ . '/../config/translatable.php' => config_path('translatable.php'), @@ -22,7 +25,7 @@ public function boot() /** * Register the application services. */ - public function register() + public function register(): void { // merge package config with user defined config $this->mergeConfigFrom(__DIR__ . '/../config/translatable.php', 'translatable'); diff --git a/tests/TestSupport/Factories/ArticleFactory.php b/tests/TestSupport/Factories/ArticleFactory.php new file mode 100644 index 0000000..a4d7832 --- /dev/null +++ b/tests/TestSupport/Factories/ArticleFactory.php @@ -0,0 +1,34 @@ + $this->faker->title(), + 'slug' => $this->faker->slug(), + 'body' => $this->faker->paragraph(5), + 'lang' => $this->faker->locale(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + } + + public function withAuthor(): self + { + return $this->state(function (array $attributes) { + return [ + 'author_id' => Author::factory(), + ]; + }); + } +} diff --git a/tests/TestSupport/Factories/AuthorFactory.php b/tests/TestSupport/Factories/AuthorFactory.php new file mode 100644 index 0000000..a6b9c69 --- /dev/null +++ b/tests/TestSupport/Factories/AuthorFactory.php @@ -0,0 +1,21 @@ + $this->faker->name(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + } +} diff --git a/tests/TestSupport/Factories/PostFactory.php b/tests/TestSupport/Factories/PostFactory.php new file mode 100644 index 0000000..69c0f59 --- /dev/null +++ b/tests/TestSupport/Factories/PostFactory.php @@ -0,0 +1,34 @@ + $this->faker->sentence(), + 'slug' => $this->faker->slug(), + 'body' => $this->faker->paragraph(5), + 'lang' => $this->faker->locale(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + } + + public function withAuthor(): self + { + return $this->state(function (array $attributes) { + return [ + 'author_id' => Author::factory(), + ]; + }); + } +} diff --git a/tests/TestSupport/Models/Article.php b/tests/TestSupport/Models/Article.php new file mode 100644 index 0000000..26e424d --- /dev/null +++ b/tests/TestSupport/Models/Article.php @@ -0,0 +1,45 @@ + 'array', + ]; + + protected static function newFactory(): ArticleFactory + { + return new ArticleFactory(); + } + + public function getTranslatables(): array + { + return [ + 'title', + 'body', + ]; + } + + public function getNonTranslatablePivots(): array + { + return [ + 'author_id' + ]; + } +} diff --git a/tests/TestSupport/Models/Author.php b/tests/TestSupport/Models/Author.php new file mode 100644 index 0000000..9098266 --- /dev/null +++ b/tests/TestSupport/Models/Author.php @@ -0,0 +1,17 @@ +belongsTo(Author::class); + } + + public function getTranslatables(): array + { + return [ + 'title', + 'body' + ]; + } + + public function getNonTranslatablePivots(): array + { + return [ + 'author_id' + ]; + } +} diff --git a/tests/TestSupport/database/2025_02_16_140456_create_articles_table.php b/tests/TestSupport/database/2025_02_16_140456_create_articles_table.php new file mode 100644 index 0000000..c544f84 --- /dev/null +++ b/tests/TestSupport/database/2025_02_16_140456_create_articles_table.php @@ -0,0 +1,29 @@ +id(); + + $table->string('title'); + $table->string('slug'); + $table->text('body'); + + JsonTranslatableSchema::columns($table); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down() + { + Schema::dropIfExists('articles'); + } +} diff --git a/tests/TestSupport/database/2025_02_19_084806_create_authors_table.php b/tests/TestSupport/database/2025_02_19_084806_create_authors_table.php new file mode 100644 index 0000000..61e9a72 --- /dev/null +++ b/tests/TestSupport/database/2025_02_19_084806_create_authors_table.php @@ -0,0 +1,23 @@ +id(); + + $table->string('name'); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('authors'); + } +}; diff --git a/tests/TestSupport/database/2025_02_19_084900_add_author_id_to_articles_table.php b/tests/TestSupport/database/2025_02_19_084900_add_author_id_to_articles_table.php new file mode 100644 index 0000000..06dc0c4 --- /dev/null +++ b/tests/TestSupport/database/2025_02_19_084900_add_author_id_to_articles_table.php @@ -0,0 +1,22 @@ +foreignIdFor(Author::class); + }); + } + + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->dropForeign('articles_author_id_foreign'); + }); + } +}; diff --git a/tests/TestSupport/database/2025_02_20_061338_create_posts_table.php b/tests/TestSupport/database/2025_02_20_061338_create_posts_table.php new file mode 100644 index 0000000..9e2636a --- /dev/null +++ b/tests/TestSupport/database/2025_02_20_061338_create_posts_table.php @@ -0,0 +1,31 @@ +id(); + + $table->string('title'); + $table->string('slug'); + $table->text('body'); + $table->foreignIdFor(Author::class); + + DbTranslatableSchema::columns($table); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; diff --git a/tests/Unit/DbTranslatable/IsDbTranslatableTest.php b/tests/Unit/DbTranslatable/IsDbTranslatableTest.php new file mode 100644 index 0000000..4dd816f --- /dev/null +++ b/tests/Unit/DbTranslatable/IsDbTranslatableTest.php @@ -0,0 +1,571 @@ +assertEquals([ + 'id', + 'translatable_parent_id', + 'lang', + 'created_at', + 'updated_at', + 'deleted_at', + ], $post->getFieldsIgnoredForTranslation()); + } + + /** @test */ + public function it_can_get_translatable_fields() + { + $post = new Post(); + + $this->assertEquals([ + 'title', + 'body', + ], $post->getTranslatables()); + } + + /** @test */ + public function it_can_get_non_translatable_fields() + { + $post = new Post(); + + $this->assertEquals([ + 'slug', + ], $post->getNonTranslatables()); + } + + /** @test */ + public function it_can_get_non_translatable_pivots() + { + $post = new Post(); + + $this->assertEquals([ + 'author_id', + ], $post->getNonTranslatablePivots()); + } + + /** @test */ + public function it_can_get_all_non_translatable_fields() + { + $post = new Post(); + + $this->assertEquals([ + 'slug', + 'id', + 'translatable_parent_id', + 'lang', + 'created_at', + 'updated_at', + 'deleted_at', + 'author_id', + ], $post->getAllNonTranslatables()); + } + + /** @test */ + public function it_can_check_if_is_a_non_translatable_pivot() + { + $post = new Post(); + + $this->assertFalse($post->isNonTranslatablePivot('id')); + $this->assertTrue($post->isNonTranslatablePivot('author_id')); + } + + /** @test */ + public function it_can_get_the_default_translation_locale() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + + $this->assertFalse($post->isDefaultTranslationLocale('fr')); + $this->assertTrue($post->isDefaultTranslationLocale('en')); + } + + /** @test */ + public function it_can_get_allowed_translation_locales() + { + $post = Post::factory()->withAuthor()->create(); + + $this->assertEquals(['en', 'dv', 'jp'], $post->getAllowedTranslationLocales()); + } + + /** @test */ + public function it_can_check_if_given_locale_is_allowed() + { + $post = Post::factory()->withAuthor()->create(); + + $this->assertTrue($post->isAllowedTranslationLocale('en')); + $this->assertTrue($post->isAllowedTranslationLocale('dv')); + $this->assertFalse($post->isAllowedTranslationLocale('fr')); + } + + /** @test */ + public function it_can_translate_field_via_translate_function() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en', + 'title' => 'This is an English title', + 'slug' => 'this-is-an-english-slug', + 'body' => 'This is an English body', + ]); + $post_dv = Post::factory()->withAuthor()->create([ + 'lang' => 'dv', + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + 'translatable_parent_id' => $post->id, + ]); + $post_jp = Post::factory()->withAuthor()->create([ + 'lang' => 'jp', + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + 'translatable_parent_id' => $post->id, + ]); + + // it can fetch translations from the default post + $this->assertEquals('Mee dhivehi title eh', $post->translate('title', 'dv')); + $this->assertEquals('Mee dhivehi liyumeh', $post->translate('body', 'dv')); + $this->assertNull($post->translate('slug', 'dv', false)); + + // it can fetch translations from a translatable post row as well + $this->assertEquals('Mee dhivehi title eh', $post_jp->translate('title', 'dv')); + $this->assertEquals('Mee dhivehi liyumeh', $post_jp->translate('body', 'dv')); + + // it can fetch translation from default translation using a translatable post row as well + $this->assertEquals('This is an English title', $post_jp->translate('title', 'en')); + $this->assertEquals('This is an English body', $post_jp->translate('body', 'en')); + + $tmp = app()->getLocale(); + app()->setLocale('en'); + $this->assertEquals('This is an English title', $post->translate('title')); + $this->assertEquals('This is an English body', $post->translate('body')); + $this->assertEquals('this-is-an-english-slug', $post->translate('slug')); + + app()->setLocale('dv'); + $this->assertEquals('Mee dhivehi title eh', $post->translate('title')); + $this->assertEquals('Mee dhivehi liyumeh', $post->translate('body')); + $this->assertNull($post->translate('slug', fallback: false)); + app()->setLocale($tmp); + + $this->assertEquals('Kore wa taitorudesu', $post->translate('title', 'jp')); + $this->assertEquals('Kore wa kijidesu', $post->translate('body', 'jp')); + $this->assertNull($post->translate('slug', 'jp', false)); + + $this->assertNull($post->translate('slug', 'fr', false)); + } + + /** @test */ + public function it_can_translate_field_via_translate_function_without_fallback() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en', + 'title' => 'This is an English title', + 'slug' => 'this-is-an-english-slug', + 'body' => 'This is an English body', + ]); + $post_dv = Post::factory()->withAuthor()->create([ + 'lang' => 'dv', + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + 'translatable_parent_id' => $post->id, + ]); + + $this->assertEquals('This is an English title', $post->translate('title', 'jp')); + $this->assertEquals('this-is-an-english-slug', $post->translate('slug', 'jp')); + $this->assertEquals('This is an English body', $post->translate('body', 'jp')); + + $this->assertNull($post->translate('title', 'jp', false)); + $this->assertNull($post->translate('slug', 'jp', false)); + $this->assertNull($post->translate('body', 'jp', false)); + } + + /** @test */ + public function it_can_translate_field_via_magic_method() + { + $post = Post::factory()->withAuthor()->create(); + $post_dv = Post::factory()->withAuthor()->create([ + 'lang' => 'dv', + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + 'translatable_parent_id' => $post->id, + ]); + $post_jp = Post::factory()->withAuthor()->create([ + 'lang' => 'jp', + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + 'translatable_parent_id' => $post->id, + ]); + + $this->assertEquals('Mee dhivehi title eh', $post->title_dv); + $this->assertEquals('Mee dhivehi liyumeh', $post->body_dv); + + // slugs should not be translated + $this->assertNull($post->slug_dv); + } + + /** @test */ + public function it_can_translate_field_via_locale_change() + { + $post = Post::factory()->withAuthor()->create(); + $post_dv = Post::factory()->withAuthor()->create([ + 'lang' => 'dv', + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + 'translatable_parent_id' => $post->id, + ]); + $post_jp = Post::factory()->withAuthor()->create([ + 'lang' => 'jp', + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + 'translatable_parent_id' => $post->id, + ]); + + $tmp = app()->getLocale(); + app()->setLocale('dv'); + $this->assertEquals('Mee dhivehi title eh', $post->title); + $this->assertEquals('Mee dhivehi liyumeh', $post->body); + app()->setLocale($tmp); + } + + /** @test */ + public function it_can_translate_fields_via_compoships() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + $post_dv = Post::factory()->withAuthor()->create([ + 'lang' => 'dv', + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + 'translatable_parent_id' => $post->id, + ]); + $post_jp = Post::factory()->withAuthor()->create([ + 'lang' => 'jp', + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + 'translatable_parent_id' => $post->id, + ]); + + $tmp = app()->getLocale(); + app()->setLocale('dv'); + [$title, $body] = $post->getAttribute(['title', 'body']); + $this->assertEquals('Mee dhivehi title eh', $title); + $this->assertEquals('Mee dhivehi liyumeh', $body); + app()->setLocale($tmp); + } + + /** @test */ + public function it_can_check_if_given_field_is_translatable() + { + $post = Post::factory()->withAuthor()->create(); + $post_dv = Post::factory()->withAuthor()->create([ + 'lang' => 'dv', + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + 'translatable_parent_id' => $post->id, + ]); + $post_jp = Post::factory()->withAuthor()->create([ + 'lang' => 'jp', + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + 'translatable_parent_id' => $post->id, + ]); + + $this->assertTrue($post->isTranslatable('title')); + } + + /** @test */ + public function it_can_clear_translations_for_one_locale() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en' + ]); + $post_dv = Post::factory()->withAuthor()->create([ + 'lang' => 'dv', + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + 'translatable_parent_id' => $post->id, + ]); + $post_jp = Post::factory()->withAuthor()->create([ + 'lang' => 'jp', + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + 'translatable_parent_id' => $post->id, + ]); + + $post->clearTranslations('dv'); + + // Ensure that dv is gone while jp is still there + $this->assertEmpty($post?->title_dv); + $this->assertEquals('Kore wa taitorudesu', $post->title_jp); + } + + /** @test */ + public function it_can_clear_translations_for_default_locale() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en' + ]); + $post_dv = Post::factory()->withAuthor()->create([ + 'lang' => 'dv', + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + 'translatable_parent_id' => $post->id, + ]); + $post_jp = Post::factory()->withAuthor()->create([ + 'lang' => 'jp', + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + 'translatable_parent_id' => $post->id, + ]); + + $post->clearTranslations('en'); + + // refresh post on memory + $post = Post::find($post->id); + + // Ensure that everything is gone because it's the main language + $this->assertEmpty($post?->title_en); + $this->assertEmpty($post?->title_jp); + } + + /** @test */ + public function it_can_clear_translations_for_locale_via_translatable_parent() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en' + ]); + $post_dv = Post::factory()->withAuthor()->create([ + 'lang' => 'dv', + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + 'translatable_parent_id' => $post->id, + ]); + $post_jp = Post::factory()->withAuthor()->create([ + 'lang' => 'jp', + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + 'translatable_parent_id' => $post->id, + ]); + + $post_jp->clearTranslations('en'); + + // refresh post on memory + $post = Post::find($post->id); + + // Ensure that everything is gone because it's the main language + $this->assertEmpty($post?->title_en); + $this->assertEmpty($post?->title_jp); + } + + /** @test */ + public function it_can_clear_translations_for_all_locales() + { + $post = Post::factory()->withAuthor()->create(); + $post_dv = Post::factory()->withAuthor()->create([ + 'lang' => 'dv', + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + 'translatable_parent_id' => $post->id, + ]); + $post_jp = Post::factory()->withAuthor()->create([ + 'lang' => 'jp', + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + 'translatable_parent_id' => $post->id, + ]); + + $post->clearTranslations(); + + $this->assertEmpty($post->title_dv); + $this->assertEmpty($post->title_jp); + } + + /** @test */ + public function it_can_check_if_any_translation_for_a_specific_locale() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en' + ]); + $post_dv = Post::factory()->withAuthor()->create([ + 'lang' => 'dv', + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + 'translatable_parent_id' => $post->id, + ]); + $post_jp = Post::factory()->withAuthor()->create([ + 'lang' => 'jp', + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + 'translatable_parent_id' => $post->id, + ]); + + $this->assertFalse($post->hasTranslation('fr')); + $this->assertTrue($post_jp->hasTranslation('dv')); + $this->assertTrue($post->hasTranslation('en')); + $tmp = app()->getLocale(); + app()->setLocale('dv'); + $this->assertTrue($post->hasTranslation()); + app()->setLocale($tmp); + } + + /** @test */ + public function it_can_check_if_is_default_translation_locale() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + $post_dv = Post::factory()->withAuthor()->create([ + 'lang' => 'dv', + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + 'translatable_parent_id' => $post->id, + ]); + $post_jp = Post::factory()->withAuthor()->create([ + 'lang' => 'jp', + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + 'translatable_parent_id' => $post->id, + ]); + + $this->assertFalse($post->isDefaultTranslationLocale('fr')); + $this->assertTrue($post->isDefaultTranslationLocale('en')); + } + + /** @test + * @throws LanguageNotAllowedException + */ + public function it_can_add_new_translation_locales() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + +// $translation = $article->addTranslation('dv', [ +// 'title' => 'Mee dhivehi title eh', +// 'slug' => 'mee-dhivehi-slug-eh', +// 'body' => 'Mee dhivehi liyumeh', +// ]); +// +// $translation->save(); + + $post->addTranslation('dv', 'title', 'Mee dhivehi title eh'); + + $this->assertEquals('Mee dhivehi title eh', $post->title_dv); + } + + /** @test */ + public function it_can_add_new_translation_locales_via_setter() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + + $post->title_dv = 'Mee dhivehi title eh'; + + // get via locale because assertEquals complains that it'll always be true with title_dv since I just set it + $tmp = app()->getLocale(); + app()->setLocale('dv'); + $this->assertEquals('Mee dhivehi title eh', $post->title); + app()->setLocale($tmp); + } + + /** @test + * @throws LanguageNotAllowedException + */ + public function it_can_add_translations_in_bulk() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + + $post->addTranslations('dv', [ + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + ]); + + $this->assertEquals('Mee dhivehi title eh', $post->title_dv); + $this->assertEquals('Mee dhivehi liyumeh', $post->body_dv); + } + + /** @test + * @throws LanguageNotAllowedException + */ + public function it_cannot_add_translations_in_bulk_for_locales_that_are_not_allowed() + { + $this->expectException(LanguageNotAllowedException::class); + $this->expectExceptionMessage('zh-CN language not allowed'); + + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + + $post->addTranslations('zh-CN', [ + 'title' => '这是一个中文标题', + 'slug' => '这是一只中国蛞蝓', + 'body' => '这是一个中国人的身体', + ]); + } + + /** @test */ + public function it_can_add_new_translation_to_default_translation() + { + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + + $post->title_en = 'This is an English title'; + + app()->setLocale('en'); + $this->assertEquals('This is an English title', $post->title); + } + + /** @test */ + public function it_cannot_add_translation_locales_that_are_not_allowed() + { + $this->expectException(LanguageNotAllowedException::class); + $this->expectExceptionMessage('zh-CN language not allowed'); + + $post = Post::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + + $post->addTranslation('zh-CN', 'title', '这是一个中文标题'); + } +} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index d719cd3..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,14 +0,0 @@ -assertTrue(true); - } -} diff --git a/tests/Unit/JsonTranslatable/IsJsonTranslatableTest.php b/tests/Unit/JsonTranslatable/IsJsonTranslatableTest.php new file mode 100644 index 0000000..0747926 --- /dev/null +++ b/tests/Unit/JsonTranslatable/IsJsonTranslatableTest.php @@ -0,0 +1,446 @@ +assertEquals([ + 'id', + 'translations', + 'lang', + 'created_at', + 'updated_at', + 'deleted_at', + ], $article->getFieldsIgnoredForTranslation()); + } + + /** @test */ + public function it_can_get_translatable_fields() + { + $article = new Article(); + + $this->assertEquals([ + 'title', + 'body', + ], $article->getTranslatables()); + } + + /** @test */ + public function it_can_get_non_translatable_fields() + { + $article = new Article(); + + $this->assertEquals([ + 'slug', + ], $article->getNonTranslatables()); + } + + /** @test */ + public function it_can_get_non_translatable_pivots() + { + $article = new Article(); + + $this->assertEquals([ + 'author_id', + ], $article->getNonTranslatablePivots()); + } + + /** @test */ + public function it_can_get_all_non_translatable_fields() + { + $article = new Article(); + + $this->assertEquals([ + 'slug', + 'id', + 'translations', + 'lang', + 'created_at', + 'updated_at', + 'deleted_at', + 'author_id', + ], $article->getAllNonTranslatables()); + } + + /** @test */ + public function it_can_check_if_is_a_non_translatable_pivot() + { + $article = new Article(); + + $this->assertFalse($article->isNonTranslatablePivot('id')); + $this->assertTrue($article->isNonTranslatablePivot('author_id')); + } + + /** @test */ + public function it_can_get_the_default_translation_locale() + { + $article = Article::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + + $this->assertFalse($article->isDefaultTranslationLocale('fr')); + $this->assertTrue($article->isDefaultTranslationLocale('en')); + } + + /** @test */ + public function it_can_get_allowed_translation_locales() + { + $article = Article::factory()->withAuthor()->create(); + + $this->assertEquals(['en', 'dv', 'jp'], $article->getAllowedTranslationLocales()); + } + + /** @test */ + public function it_can_check_if_given_locale_is_allowed() + { + $article = Article::factory()->withAuthor()->create(); + + $this->assertTrue($article->isAllowedTranslationLocale('en')); + $this->assertTrue($article->isAllowedTranslationLocale('dv')); + $this->assertFalse($article->isAllowedTranslationLocale('fr')); + } + + /** @test */ + public function it_can_translate_field_via_translate_function() + { + $article = Article::factory()->withAuthor()->create([ + 'lang' => 'en', + 'title' => 'This is an English title', + 'slug' => 'this-is-an-english-slug', + 'body' => 'This is an English body', + 'translations' => [ + 'dv' => [ + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + ], + 'jp' => [ + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + ] + ] + ]); + + $this->assertEquals('Mee dhivehi title eh', $article->translate('title', 'dv')); + $this->assertEquals('Mee dhivehi liyumeh', $article->translate('body', 'dv')); + $this->assertNull($article->translate('slug', 'dv', false)); + + $tmp = app()->getLocale(); + app()->setLocale('en'); + $this->assertEquals('This is an English title', $article->translate('title')); + $this->assertEquals('This is an English body', $article->translate('body')); + $this->assertEquals('this-is-an-english-slug', $article->translate('slug')); + + app()->setLocale('dv'); + $this->assertEquals('Mee dhivehi title eh', $article->translate('title')); + $this->assertEquals('Mee dhivehi liyumeh', $article->translate('body')); + $this->assertNull($article->translate('slug', fallback: false)); + app()->setLocale($tmp); + + $this->assertEquals('Kore wa taitorudesu', $article->translate('title', 'jp')); + $this->assertEquals('Kore wa kijidesu', $article->translate('body', 'jp')); + $this->assertNull($article->translate('slug', 'jp', false)); + + $this->assertNull($article->translate('slug', 'fr', false)); + } + + /** @test */ + public function it_can_translate_field_via_translate_function_without_fallback() + { + $article = Article::factory()->withAuthor()->create([ + 'lang' => 'en', + 'title' => 'This is an English title', + 'slug' => 'this-is-an-english-slug', + 'body' => 'This is an English body', + 'translations' => [ + 'dv' => [ + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + ], + ] + ]); + + $this->assertEquals('This is an English title', $article->translate('title', 'jp')); + $this->assertEquals('this-is-an-english-slug', $article->translate('slug', 'jp')); + $this->assertEquals('This is an English body', $article->translate('body', 'jp')); + + $this->assertNull($article->translate('title', 'jp', false)); + $this->assertNull($article->translate('slug', 'jp', false)); + $this->assertNull($article->translate('body', 'jp', false)); + } + + /** @test */ + public function it_can_translate_field_via_magic_method() + { + $article = Article::factory()->withAuthor()->create([ + 'translations' => [ + 'dv' => [ + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + ], + 'jp' => [ + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + ] + ] + ]); + + $this->assertEquals('Mee dhivehi title eh', $article->title_dv); + $this->assertEquals('Mee dhivehi liyumeh', $article->body_dv); + + // slugs should not be translated + $this->assertNull($article->slug_dv); + } + + /** @test */ + public function it_can_translate_field_via_locale_change() + { + $article = Article::factory()->withAuthor()->create([ + 'translations' => [ + 'dv' => [ + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + ], + 'jp' => [ + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + ] + ] + ]); + + $tmp = app()->getLocale(); + app()->setLocale('dv'); + $this->assertEquals('Mee dhivehi title eh', $article->title); + $this->assertEquals('Mee dhivehi liyumeh', $article->body); + app()->setLocale($tmp); + } + + /** @test */ + public function it_can_check_if_given_field_is_translatable() + { + $article = Article::factory()->withAuthor()->create([ + 'translations' => [ + 'dv' => [ + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + ], + 'jp' => [ + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + ] + ] + ]); + + $this->assertTrue($article->isTranslatable('title')); + } + + /** @test */ + public function it_can_clear_translations_for_one_locale() + { + $article = Article::factory()->withAuthor()->create([ + 'translations' => [ + 'dv' => [ + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + ], + 'jp' => [ + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + ] + ] + ]); + + // check if it can clear language that doesn't exist + $article->clearTranslations('fr'); + + $article->clearTranslations('dv'); + + // Ensure that dv is gone while jp is still there + $this->assertEmpty($article->title_dv); + $this->assertEquals('Kore wa taitorudesu', $article->title_jp); + } + + /** @test */ + public function it_can_clear_translations_for_all_locales() + { + $article = Article::factory()->withAuthor()->create([ + 'translations' => [ + 'dv' => [ + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + ], + 'jp' => [ + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + ] + ] + ]); + + $article->clearTranslations(); + + $this->assertEmpty($article->title_dv); + $this->assertEmpty($article->title_jp); + } + + /** @test */ + public function it_can_check_if_any_translation_for_a_specific_locale() + { + $article = Article::factory()->withAuthor()->create([ + 'translations' => [ + 'dv' => [ + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + ], + 'jp' => [ + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + ] + ] + ]); + + $this->assertFalse($article->hasTranslation('fr')); + $this->assertTrue($article->hasTranslation('dv')); + $tmp = app()->getLocale(); + app()->setLocale('dv'); + $this->assertTrue($article->hasTranslation()); + app()->setLocale($tmp); + } + + /** @test */ + public function it_can_check_if_is_default_translation_locale() + { + $article = Article::factory()->withAuthor()->create([ + 'lang' => 'en', + 'translations' => [ + 'dv' => [ + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + ], + 'jp' => [ + 'title' => 'Kore wa taitorudesu', + 'slug' => 'kore-wa-namekujidesu', + 'body' => 'Kore wa kijidesu', + ] + ] + ]); + + $this->assertFalse($article->isDefaultTranslationLocale('fr')); + $this->assertTrue($article->isDefaultTranslationLocale('en')); + } + + /** @test + * @throws LanguageNotAllowedException + */ + public function it_can_add_new_translation_locales() + { + $article = Article::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + +// $translation = $article->addTranslation('dv', [ +// 'title' => 'Mee dhivehi title eh', +// 'slug' => 'mee-dhivehi-slug-eh', +// 'body' => 'Mee dhivehi liyumeh', +// ]); +// +// $translation->save(); + + $article->addTranslation('dv', 'title', 'Mee dhivehi title eh'); + + $this->assertEquals('Mee dhivehi title eh', $article->title_dv); + } + + /** @test */ + public function it_can_add_new_translation_locales_via_setter() + { + $article = Article::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + + $article->title_dv = 'Mee dhivehi title eh'; + + // get via locale because assertEquals complains that it'll always be true with title_dv since I just set it + $tmp = app()->getLocale(); + app()->setLocale('dv'); + $this->assertEquals('Mee dhivehi title eh', $article->title); + app()->setLocale($tmp); + } + + /** @test + * @throws LanguageNotAllowedException + */ + public function it_can_add_translations_in_bulk() + { + $article = Article::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + + $article->addTranslations('dv', [ + 'title' => 'Mee dhivehi title eh', + 'slug' => 'mee-dhivehi-slug-eh', + 'body' => 'Mee dhivehi liyumeh', + ]); + + $this->assertEquals('Mee dhivehi title eh', $article->title_dv); + $this->assertEquals('Mee dhivehi liyumeh', $article->body_dv); + } + + /** @test + * @throws LanguageNotAllowedException + */ + public function it_cannot_add_translations_in_bulk_for_locales_that_are_not_allowed() + { + $this->expectException(LanguageNotAllowedException::class); + $this->expectExceptionMessage('zh-CN language not allowed'); + + $article = Article::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + + $article->addTranslations('zh-CN', [ + 'title' => '这是一个中文标题', + 'slug' => '这是一只中国蛞蝓', + 'body' => '这是一个中国人的身体', + ]); + } + + /** @test */ + public function it_cannot_add_translation_locales_that_are_not_allowed() + { + $this->expectException(LanguageNotAllowedException::class); + $this->expectExceptionMessage('zh-CN language not allowed'); + + $article = Article::factory()->withAuthor()->create([ + 'lang' => 'en', + ]); + + $article->addTranslation('zh-CN', 'title', '这是一个中文标题'); + } +}