diff --git a/.gitattributes b/.gitattributes index b263871..1c2fc12 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,7 +4,7 @@ # Ignore all test and documentation with "export-ignore". /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore /phpunit.xml.dist export-ignore -/.scrutinizer.yml export-ignore /tests export-ignore +/docs export-ignore +php_cs.dist.php export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..5ccc87c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: spatie diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..141e28e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Feature Request + url: https://github.com/spatie/laravel-translatable/discussions/new?category=ideas + about: Share ideas for new features + - name: Ask a Question + url: https://github.com/spatie/laravel-translatable/discussions/new?category=q-a + about: Ask the community for help diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a76dd83 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 + +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..531772b --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,40 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2.4.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + compat-lookup: true + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for Action major versions when compatibility is higher than 90% + if: ${{steps.metadata.outputs.package-ecosystem == 'github_actions' && steps.metadata.outputs.update-type == 'version-update:semver-major' && steps.metadata.outputs.compatibility-score >= 90}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml new file mode 100644 index 0000000..fce0e57 --- /dev/null +++ b/.github/workflows/php-cs-fixer.yml @@ -0,0 +1,28 @@ +name: Fix PHP code style issues + +on: + push: + paths: + - '**.php' + +permissions: + contents: write + +jobs: + php-code-styling: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@2.6 + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v7 + with: + commit_message: Fix styling diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..fe3b7ef --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,53 @@ +name: run-tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest] + php: [8.4, 8.3, 8.2, 8.1, 8.0] + laravel: [12.*, 11.*, 10.*] + stability: [prefer-lowest, prefer-stable] + include: + - laravel: 12.* + testbench: 10.* + - laravel: 11.* + testbench: 9.* + - laravel: 10.* + testbench: 8.* + exclude: + - laravel: 12.* + php: 8.1 + - laravel: 12.* + php: 8.0 + - laravel: 11.* + php: 8.1 + - laravel: 11.* + php: 8.0 # + - laravel: 10.* + php: 8.0 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick + coverage: none + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: Execute tests + run: vendor/bin/pest diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..d9e92e7 --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,28 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v7 + with: + branch: main + commit_message: Update CHANGELOG + file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore index f02a2f8..b06a48e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ build composer.lock -docs vendor +.phpunit.result.cache +.php-cs-fixer.cache +.idea diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results new file mode 100644 index 0000000..96aead2 --- /dev/null +++ b/.phpunit.cache/test-results @@ -0,0 +1 @@ +{"version":"pest_3.7.4","defects":[],"times":{"P\\Tests\\EventTest::__pest_evaluable_it_will_fire_an_event_when_a_translation_has_been_set":0.013,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_return_package_fallback_locale_translation_when_getting_an_unknown_locale":0.008,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_return_default_fallback_locale_translation_when_getting_an_unknown_locale":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_provides_a_flog_to_not_return_fallback_locale_translation_when_getting_an_unknown_locale":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_return_fallback_locale_translation_when_getting_an_unknown_locale_and_fallback_is_true":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_execute_callback_fallback_when_getting_an_unknown_locale_and_fallback_callback_is_enabled":0.005,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_use_callback_fallback_return_value_as_translation":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_wont_use_callback_fallback_return_value_as_translation_if_it_is_not_a_string":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_wont_execute_callback_fallback_when_getting_an_existing_translation":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_wont_fail_if_callback_fallback_throw_exception":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_return_an_empty_string_when_getting_an_unknown_locale_and_fallback_is_not_set":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_return_an_empty_string_when_getting_an_unknown_locale_and_fallback_is_empty":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_save_a_translated_attribute":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_set_translated_values_when_creating_a_model":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_save_multiple_translations":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_return_the_value_of_the_current_locale_when_using_the_property":0.002,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_get_all_translations_in_one_go":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_get_specified_translations_in_one_go":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_get_all_translations_for_all_translatable_attributes_in_one_go":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_get_specified_translations_for_all_translatable_attributes_in_one_go":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_get_the_locales_which_have_a_translation":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_forget_a_translation":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_forget_all_translations_of_field":0.002,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_forget_all_translations_of_field_and_make_field_null":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_forget_a_field_with_mutator_translation":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_forget_all_translations":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_throw_an_exception_when_trying_to_translate_an_untranslatable_attribute":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_is_compatible_with_accessors_on_non_translatable_attributes":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_use_accessors_on_translated_attributes":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_be_converted_to_array_when_using_accessors_on_translated_attributes":0.002,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_use_mutators_on_translated_attributes":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_set_translations_for_default_language":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_set_multiple_translations_at_once":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_check_if_an_attribute_is_translatable":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_check_if_an_attribute_has_translation":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_correctly_set_a_field_when_a_mutator_is_defined":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_set_multiple_translations_when_a_mutator_is_defined":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_set_multiple_translations_on_field_when_a_mutator_is_defined":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_uses_the_attribute_to_mutate_the_translated_value":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_translate_a_field_based_on_the_translations_of_another_one":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_handle_null_value_from_database":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_get_all_translations":0.002,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_return_fallback_locale_translation_when_getting_an_empty_translation_from_the_locale":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_return_correct_translation_value_if_value_is_set_to_zero":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_not_return_fallback_value_if_value_is_set_to_zero":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_not_remove_zero_value_of_other_locale_in_database":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_be_translated_based_on_given_locale":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_set_and_fetch_attributes_based_on_set_locale":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_replace_translations":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_use_any_locale_if_given_locale_not_set":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_return_set_translation_when_fallback_any_set":0.002,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_return_fallback_translation_when_fallback_any_set":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_provides_a_flog_to_not_return_any_translation_when_getting_an_unknown_locale":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_return_default_fallback_locale_translation_when_getting_an_unknown_locale_with_fallback_any":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_will_return_all_locales_when_getting_all_translations":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_queries_the_database_whether_a_locale_exists":0.002,"P\\Tests\\TranslatableTest::__pest_evaluable_it_queries_the_database_for_multiple_locales":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_queries_the_database_whether_a_value_exists_in_a_locale":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_queries_the_database_whether_a_value_exists_in_a_multiple_locales":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_disable_attribute_locale_fallback_on_a_per_model_basis":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_set_fallback_locale_on_model":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_translations_macro_meets_expectations#(['english'], 'en', 'english')":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_translations_macro_meets_expectations#(['english', 'english'], ['en', 'nl'], 'english')":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_translations_macro_meets_expectations#(['english', 'dutch'], ['en', 'nl'], ['english', 'dutch'])":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_should_return_null_when_the_underlying_attribute_in_database_is_null":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_should_return_locales_with_empty_string_translations_when_allowEmptyStringForTranslation_is_true":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_should_not_return_locales_with_empty_string_translations_when_allowEmptyStringForTranslation_is_false":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_should_return_locales_with_null_translations_when_allowNullForTranslation_is_true":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_should_not_return_locales_with_null_translations_when_allowNullForTranslation_is_false":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_set_an_array_list_as_value_for_translation_using__setTranslation_":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_set_an_array_list_as_value_for_translation_using_default_local":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_treat_an_empty_array_as_value_for_clearing_translations":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_can_set_and_retrieve_translations_for_nested_fields":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_uses_mutators_for_setting_and_getting_translated_values_of_nested_fields":0.001,"P\\Tests\\TranslatableTest::__pest_evaluable_it_should_return_null_when_translation_is_null_and_allowNullForTranslation_is_true":0,"P\\Tests\\TranslatableTest::__pest_evaluable_it_should_return_empty_string_when_translation_is_null_and_allowNullForTranslation_is_false":0}} \ No newline at end of file diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index df16b68..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,19 +0,0 @@ -filter: - excluded_paths: [tests/*] - -checks: - php: - remove_extra_empty_lines: true - remove_php_closing_tag: true - remove_trailing_whitespace: true - fix_use_statements: - remove_unused: true - preserve_multiple: false - preserve_blanklines: true - order_alphabetically: true - fix_php_opening_tag: true - fix_linefeed: true - fix_line_ending: true - fix_identation_4spaces: true - fix_doc_comments: true - diff --git a/.styleci.yml b/.styleci.yml deleted file mode 100644 index 0285f17..0000000 --- a/.styleci.yml +++ /dev/null @@ -1 +0,0 @@ -preset: laravel diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index bd1d94e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,20 +0,0 @@ -language: php - -php: - - 7.2 - - 7.3 - -env: - matrix: - - COMPOSER_FLAGS="--prefer-lowest" - - COMPOSER_FLAGS="" - -before_script: - - travis_retry composer self-update - - travis_retry composer update ${COMPOSER_FLAGS} --no-interaction --prefer-source - -script: - - phpunit --coverage-text --coverage-clover=coverage.clover - -after_script: - - php vendor/bin/ocular code-coverage:upload --format=php-clover coverage.clover diff --git a/CHANGELOG.md b/CHANGELOG.md index ec816be..8ebff21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,401 @@ All notable changes to `laravel-translatable` will be documented in this file +## 6.11.4 - 2025-02-20 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.11.3...6.11.4 + +## 6.11.3 - 2025-02-14 + +### What's Changed + +* Allow null value in translations if allowNullForTranslation is true by @dont-know-php in https://github.com/spatie/laravel-translatable/pull/488 + +### New Contributors + +* @dont-know-php made their first contribution in https://github.com/spatie/laravel-translatable/pull/488 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.10.2...6.11.3 + +## 6.10.2 - 2025-02-03 + +### What's Changed + +* Fix casts on initialization of HasTranslation by @thaqebon in https://github.com/spatie/laravel-translatable/pull/486 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.10.1...6.10.2 + +## 6.10.1 - 2025-01-31 + +### What's Changed + +* Handle null database values as null in translations by @alipadron in https://github.com/spatie/laravel-translatable/pull/479 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.10.0...6.10.1 + +## 6.10.0 - 2025-01-31 + +### What's Changed + +* Support clearing translations using an empty array by @alipadron in https://github.com/spatie/laravel-translatable/pull/478 +* Bump dependabot/fetch-metadata from 2.2.0 to 2.3.0 by @dependabot in https://github.com/spatie/laravel-translatable/pull/484 +* Add support for nested key translations by @thaqebon in https://github.com/spatie/laravel-translatable/pull/483 + +### New Contributors + +* @thaqebon made their first contribution in https://github.com/spatie/laravel-translatable/pull/483 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.9.3...6.10.0 + +## 6.9.3 - 2024-12-16 + +### What's Changed + +* Revert return value change when column value is `null` by @vencelkatai in https://github.com/spatie/laravel-translatable/pull/474 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.9.2...6.9.3 + +## 6.9.2 - 2024-12-11 + +### What's Changed + +* Improve `setAttribute` to handle array list as value for translation by @alipadron in https://github.com/spatie/laravel-translatable/pull/469 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.9.1...6.9.2 + +## 6.9.1 - 2024-12-11 + +### What's Changed + +* Fix attribute mutators by @vencelkatai in https://github.com/spatie/laravel-translatable/pull/470 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.9.0...6.9.1 + +## 6.9.0 - 2024-12-09 + +### What's Changed + +* PHP 8.4 deprecates implicitly nullable parameter types. by @selfsimilar in https://github.com/spatie/laravel-translatable/pull/458 +* Add .idea to .gitignore, PHP CS Fixer to dev dependencies, and rename PHP CS Fixer config by @alipadron in https://github.com/spatie/laravel-translatable/pull/466 +* Allow configuration for handling null and empty strings in translations (Fixes #456) by @alipadron in https://github.com/spatie/laravel-translatable/pull/465 + +### New Contributors + +* @selfsimilar made their first contribution in https://github.com/spatie/laravel-translatable/pull/458 +* @alipadron made their first contribution in https://github.com/spatie/laravel-translatable/pull/466 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.8.0...6.9.0 + +## 6.8.0 - 2024-07-24 + +### What's Changed + +* Bump dependabot/fetch-metadata from 2.1.0 to 2.2.0 by @dependabot in https://github.com/spatie/laravel-translatable/pull/453 +* Added operand for json scopes by @rcerljenko in https://github.com/spatie/laravel-translatable/pull/454 + +### New Contributors + +* @rcerljenko made their first contribution in https://github.com/spatie/laravel-translatable/pull/454 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.7.1...6.8.0 + +## 6.7.1 - 2024-05-14 + +### What's Changed + +* fix: PHPDoc block in Translatable facade by @kyryl-bogach in https://github.com/spatie/laravel-translatable/pull/448 +* Bump dependabot/fetch-metadata from 1.6.0 to 2.1.0 by @dependabot in https://github.com/spatie/laravel-translatable/pull/446 + +### New Contributors + +* @kyryl-bogach made their first contribution in https://github.com/spatie/laravel-translatable/pull/448 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.7.0...6.7.1 + +## 6.7.0 - 2024-05-13 + +### What's Changed + +* Add method comment to Facade for IDE autocompletion by @Muetze42 in https://github.com/spatie/laravel-translatable/pull/438 +* Docs: add type declarations `array $translatable` by @fahrim in https://github.com/spatie/laravel-translatable/pull/441 +* [FEAT] add ability for filtering a column's locale or multiple locale… by @AbdelrahmanBl in https://github.com/spatie/laravel-translatable/pull/447 + +### New Contributors + +* @fahrim made their first contribution in https://github.com/spatie/laravel-translatable/pull/441 +* @AbdelrahmanBl made their first contribution in https://github.com/spatie/laravel-translatable/pull/447 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.6.2...6.7.0 + +## 6.6.2 - 2024-03-01 + +### What's Changed + +* Fix toArray when using accessors on translatable attributes by @vencelkatai in https://github.com/spatie/laravel-translatable/pull/437 + +### New Contributors + +* @vencelkatai made their first contribution in https://github.com/spatie/laravel-translatable/pull/437 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.6.1...6.6.2 + +## 6.6.1 - 2024-02-26 + +### What's Changed + +* fix: allow raw searchable umlauts by @Muetze42 in https://github.com/spatie/laravel-translatable/pull/436 + +### New Contributors + +* @Muetze42 made their first contribution in https://github.com/spatie/laravel-translatable/pull/436 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.6.0...6.6.1 + +## 6.6.0 - 2024-02-23 + +### What's Changed + +* Add laravel 11 support by @mokhosh in https://github.com/spatie/laravel-translatable/pull/434 + +### New Contributors + +* @mokhosh made their first contribution in https://github.com/spatie/laravel-translatable/pull/434 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.5.5...6.6.0 + +## 6.5.5 - 2023-12-06 + +### What's Changed + +* Revert "Keep null value" by @mabdullahsari in https://github.com/spatie/laravel-translatable/pull/428 + +### New Contributors + +* @mabdullahsari made their first contribution in https://github.com/spatie/laravel-translatable/pull/428 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.5.4...6.5.5 + +## 6.5.4 - 2023-12-01 + +### What's Changed + +* Bump actions/checkout from 3 to 4 by @dependabot in https://github.com/spatie/laravel-translatable/pull/413 +* Keep the number of translations even with null values by @sdebacker in https://github.com/spatie/laravel-translatable/pull/427 +* Bump stefanzweifel/git-auto-commit-action from 4 to 5 by @dependabot in https://github.com/spatie/laravel-translatable/pull/418 + +### New Contributors + +* @sdebacker made their first contribution in https://github.com/spatie/laravel-translatable/pull/427 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.5.3...6.5.4 + +## 6.5.3 - 2023-07-19 + +### What's Changed + +- Bump dependabot/fetch-metadata from 1.5.1 to 1.6.0 by @dependabot in https://github.com/spatie/laravel-translatable/pull/398 +- handle new attribute mutator :boom: by @messi89 in https://github.com/spatie/laravel-translatable/pull/402 + +### New Contributors + +- @messi89 made their first contribution in https://github.com/spatie/laravel-translatable/pull/402 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.5.2...6.5.3 + +## 6.5.2 - 2023-06-20 + +### What's Changed + +- Bump dependabot/fetch-metadata from 1.4.0 to 1.5.1 by @dependabot in https://github.com/spatie/laravel-translatable/pull/394 +- Convert static methods to scopes by @gdebrauwer in https://github.com/spatie/laravel-translatable/pull/396 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.5.1...6.5.2 + +## 6.5.1 - 2023-05-06 + +### What's Changed + +- Bump dependabot/fetch-metadata from 1.3.6 to 1.4.0 by @dependabot in https://github.com/spatie/laravel-translatable/pull/389 +- Add getFallbackLocale method by @gdebrauwer in https://github.com/spatie/laravel-translatable/pull/391 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.5.0...6.5.1 + +## 6.5.0 - 2023-04-20 + +### What's Changed + +- update customize-the-toarray-method.md by @moham96 in https://github.com/spatie/laravel-translatable/pull/387 +- Add macro for `$this->translations()` in factories by @bram-pkg in https://github.com/spatie/laravel-translatable/pull/382 + +### New Contributors + +- @moham96 made their first contribution in https://github.com/spatie/laravel-translatable/pull/387 +- @bram-pkg made their first contribution in https://github.com/spatie/laravel-translatable/pull/382 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.4.0...6.5.0 + +## 6.4.0 - 2023-03-19 + +### What's Changed + +- Bump dependabot/fetch-metadata from 1.3.5 to 1.3.6 by @dependabot in https://github.com/spatie/laravel-translatable/pull/376 +- Fix badge with `tests` status in `README.md` by @gomzyakov in https://github.com/spatie/laravel-translatable/pull/377 +- Update README.md by @alirezasalehizadeh in https://github.com/spatie/laravel-translatable/pull/381 +- Enable fallback locale on a per model basis by @yoeriboven in https://github.com/spatie/laravel-translatable/pull/380 + +### New Contributors + +- @gomzyakov made their first contribution in https://github.com/spatie/laravel-translatable/pull/377 +- @alirezasalehizadeh made their first contribution in https://github.com/spatie/laravel-translatable/pull/381 +- @yoeriboven made their first contribution in https://github.com/spatie/laravel-translatable/pull/380 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.3.0...6.4.0 + +## 6.3.0 - 2023-01-14 + +### What's Changed + +- Laravel 10.x support by @erikn69 in https://github.com/spatie/laravel-translatable/pull/374 + +### New Contributors + +- @erikn69 made their first contribution in https://github.com/spatie/laravel-translatable/pull/374 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.2.0...6.3.0 + +## 6.2.0 - 2022-12-23 + +### What's Changed + +- Add Dependabot Automation by @patinthehat in https://github.com/spatie/laravel-translatable/pull/366 +- Add PHP 8.2 Support by @patinthehat in https://github.com/spatie/laravel-translatable/pull/367 +- Bump actions/checkout from 2 to 3 by @dependabot in https://github.com/spatie/laravel-translatable/pull/368 +- Added whereLocale and whereLocales methods by @ahmetbarut in https://github.com/spatie/laravel-translatable/pull/370 + +### New Contributors + +- @dependabot made their first contribution in https://github.com/spatie/laravel-translatable/pull/368 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.1.0...6.2.0 + +## 6.1.0 - 2022-10-21 + +### What's Changed + +- PHPUnit to Pest Converter by @freekmurze in https://github.com/spatie/laravel-translatable/pull/335 +- Fix typo in "Getting and setting translations" by @sami-cha in https://github.com/spatie/laravel-translatable/pull/346 +- Fix typo in advanced usage docs directory name by @greatislander in https://github.com/spatie/laravel-translatable/pull/347 +- Fixed example for forgetAllTranslations() method. by @odeland in https://github.com/spatie/laravel-translatable/pull/348 +- added locales method by @ahmetbarut in https://github.com/spatie/laravel-translatable/pull/361 + +### New Contributors + +- @sami-cha made their first contribution in https://github.com/spatie/laravel-translatable/pull/346 +- @greatislander made their first contribution in https://github.com/spatie/laravel-translatable/pull/347 +- @odeland made their first contribution in https://github.com/spatie/laravel-translatable/pull/348 +- @ahmetbarut made their first contribution in https://github.com/spatie/laravel-translatable/pull/361 + +**Full Changelog**: https://github.com/spatie/laravel-translatable/compare/6.0.0...6.1.0 + +## 6.0.0 - 2022-03-07 + +- improved fallback customisations +- modernized code base +- drop support for Laravel 8 + +## 5.2.0 - 2022-01-13 + +- support Laravel 9 + +## 5.0.3 - 2021-10-04 + +- solve the string value issue in filterTranslations method (#300) + +## 5.0.2 - 2021-09-28 + +- specify locales in get translations method (#299) + +## 5.0.1 - 2021-07-15 + +- fix return types of getTranslation (#286) + +## 5.0.0 - 2021-03-26 + +- require PHP 8+ +- convert syntax to PHP 8 +- drop support for PHP 7.x +- drop support for Laravel 6.x +- implement `spatie/laravel-package-tools` + +## 4.6.0 - 2020-11-19 + +- add support for PHP 8.0 (#241) +- drop support for Laravel 5.8 (#241) + +## 4.5.2 - 2020-10-22 + +- revert #235 + +## 4.5.1 - 2020-10-22 + +- use string casting for translatable columns (#235) + +## 4.5.0 2020-10-03 + +- add replaceTranslations method (#231) + +## 4.4.3 - 2020-10-2 + +- rename `withLocale` to `usingLocale` + +## 4.4.2 - 2020-10-02 + +- elegant syntax update (#229) + +## 4.4.1 - 2020-09-06 + +- add support for Laravel 8 (#226) + +## 4.4.0 - 2020-07-09 + +- make possible to set multiple translations on mutator model field with array (#216) + +## 4.3.2 - 2020-04-30 + +- fix `forgetTranslation` & `forgetAllTranslations` on fields with mutator (#205) + +## 4.3.1 - 2020-03-07 + +- Lumen fix (#201) + +## 4.3.0 - 2020-03-02 + +- add support for Laravel 7 + +## 4.2.2 - 2020-01-20 + +- open up for non-model objects (#186) + +## 4.2.1 - 2019-10-03 + +- add third param to translate method (#177) + +## 4.2.0 - 2019-09-04 + +- make compatible with Laravel 6 + +## 4.1.4 - 2019-08-28 + +- re-added the `translatable.fallback_local` config which overrule `app.fallback_local` (see https://github.com/spatie/laravel-translatable/issues/170) + +## 4.1.3 - 2019-06-16 + +- improve dependencies + +## 4.1.2 - 2019-06-06 + +- allow false and true values in translations + ## 4.1.1 - 2019-02-27 - fix service provider error @@ -45,51 +440,67 @@ All notable changes to `laravel-translatable` will be documented in this file - add support for Laravel 5.7 ## 2.2.0 - 2018-03-09 + - made it possible to get all translations in one go ## 2.1.5 - 2018-02-28 + - better handling of `null` values ## 2.1.4 - 2018-02-08 + - add support for L5.6 ## 2.1.3 - 2018-01-24 + - make locale handling more flexible ## 2.1.2 - 2017-12-24 + - fix for using translations within translations ## 2.1.1 - 2017-12-20 + - fix event `key` attribute - fix support for mutators ## 2.1.0 - 2017-09-21 + - added support for setting a translation directly through the property ## 2.0.0 - 2017-08-30 + - added support for Laravel 5.5, dropped support for all older versions - rename config file from `laravel-translatable` to `translatable` ## 1.3.0 - 2017-06-12 + - add `forgetAllTranslations` ## 1.2.2 - 2016-01-27 + - improve support for fallback locale ## 1.2.1 - 2016-01-23 + - improve compatibility for Laravel 5.4 ## 1.2.0 - 2016-01-23 + - add compatibility for Laravel 5.4 ## 1.1.2 - 2016-10-02 + - made `isTranslatableAttribute` public ## 1.1.1 - 2016-08-24 + - add L5.3 compatibility ## 1.1.0 - 2016-05-02 + - added support for a fallback locale ## 1.0.0 - 2016-04-10 + - initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 4da74e3..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,55 +0,0 @@ -# Contributing - -Contributions are **welcome** and will be fully **credited**. - -Please read and understand the contribution guide before creating an issue or pull request. - -## Etiquette - -This project is open source, and as such, the maintainers give their free time to build and maintain the source code -held within. They make the code freely available in the hope that it will be of use to other developers. It would be -extremely unfair for them to suffer abuse or anger for their hard work. - -Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the -world that developers are civilized and selfless people. - -It's the duty of the maintainer to ensure that all submissions to the project are of sufficient -quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. - -## Viability - -When requesting or submitting new features, first consider whether it might be useful to others. Open -source projects are used by many developers, who may have entirely different needs to your own. Think about -whether or not your feature is likely to be used by other users of the project. - -## Procedure - -Before filing an issue: - -- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. -- Check to make sure your feature suggestion isn't already present within the project. -- Check the pull requests tab to ensure that the bug doesn't have a fix in progress. -- Check the pull requests tab to ensure that the feature isn't already in progress. - -Before submitting a pull request: - -- Check the codebase to ensure that your feature doesn't already exist. -- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. - -## Requirements - -If the project maintainer has any additional requirements, you will find them listed here. - -- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). - -- **Add tests!** - Your patch won't be accepted if it doesn't have tests. - -- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. - -- **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. - -- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. - -- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. - -**Happy coding**! diff --git a/README.md b/README.md index c9689ee..c959042 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,23 @@ -# A trait to make Eloquent models translatable +
+ + + + Logo for laravel-translatable + + + +

A trait to make Eloquent models translatable

[![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-translatable.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-translatable) -[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) -[![Build Status](https://img.shields.io/travis/spatie/laravel-translatable/master.svg?style=flat-square)](https://travis-ci.org/spatie/laravel-translatable) -[![Quality Score](https://img.shields.io/scrutinizer/g/spatie/laravel-translatable.svg?style=flat-square)](https://scrutinizer-ci.com/g/spatie/laravel-translatable) -[![StyleCI](https://styleci.io/repos/55690447/shield?branch=master)](https://styleci.io/repos/55690447) +[![MIT Licensed](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/spatie/laravel-translatable/run-tests.yml) [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-translatable.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-translatable) + +
-This package contains a trait to make Eloquent models translatable. Translations are stored as json. There is no extra table needed to hold them. - -Once the trait is installed on the model you can do these things: +This package contains a trait `HasTranslations` to make Eloquent models translatable. Translations are stored as json. There is no extra table needed to hold them. ```php -$newsItem = new NewsItem; // This is an Eloquent model -$newsItem - ->setTranslation('name', 'en', 'Name in English') - ->setTranslation('name', 'nl', 'Naam in het Nederlands') - ->save(); - -$newsItem->name; // Returns 'Name in English' given that the current app locale is 'en' -$newsItem->getTranslation('name', 'nl'); // returns 'Naam in het Nederlands' - -app()->setLocale('nl'); - -$newsItem->name; // Returns 'Naam in het Nederlands' -``` - -## Installation - -You can install the package via composer: - -``` bash -composer require spatie/laravel-translatable -``` - -## Making a model translatable - -The required steps to make a model translatable are: - -- First, you need to add the `Spatie\Translatable\HasTranslations`-trait. -- Next, you should create a public property `$translatable` which holds an array with all the names of attributes you wish to make translatable. -- Finally, you should make sure that all translatable attributes are set to the `text`-datatype in your database. If your database supports `json`-columns, use that. - -Here's an example of a prepared model: - -``` php use Illuminate\Database\Eloquent\Model; use Spatie\Translatable\HasTranslations; @@ -52,154 +25,76 @@ class NewsItem extends Model { use HasTranslations; - public $translatable = ['name']; -} -``` - -### Available methods - -#### Getting a translation - -The easiest way to get a translation for the current locale is to just get the property for the translated attribute. -For example (given that `name` is a translatable attribute): - -```php -$newsItem->name; -``` - -You can also use this method: + public $translatable = ['name']; // translatable attributes -```php -public function getTranslation(string $attributeName, string $locale) : string + // ... +} ``` -This function has an alias named `translate`. - -#### Getting all translations - -You can get all translations by calling `getTranslations()` without an argument: +After the trait is applied on the model you can do these things: ```php -$newsItem->getTranslations(); -``` - -Or you can use the accessor +$newsItem = new NewsItem; +$newsItem + ->setTranslation('name', 'en', 'Name in English') + ->setTranslation('name', 'nl', 'Naam in het Nederlands') + ->save(); -```php -$yourModel->translations -``` +$newsItem->name; // Returns 'Name in English' given that the current app locale is 'en' +$newsItem->getTranslation('name', 'nl'); // returns 'Naam in het Nederlands' -#### Setting a translation -The easiest way to set a translation for the current locale is to just set the property for a translatable attribute. -For example (given that `name` is a translatable attribute): +app()->setLocale('nl'); +$newsItem->name; // Returns 'Naam in het Nederlands' -```php -$newsItem->name = 'New translation'; -``` +$newsItem->getTranslations('name'); // returns an array of all name translations -To set a translation for a specific locale you can use this method: +// You can translate nested keys of a JSON column using the -> notation +// First, add the path to the $translatable array, e.g., 'meta->description' +$newsItem + ->setTranslation('meta->description', 'en', 'Description in English') + ->setTranslation('meta->description', 'nl', 'Beschrijving in het Nederlands') + ->save(); -``` php -public function setTranslation(string $attributeName, string $locale, string $value) +$attributeKey = 'meta->description'; +$newsItem->$attributeKey; // Returns 'Description in English' +$newsItem->getTranslation('meta->description', 'nl'); // Returns 'Beschrijving in het Nederlands' ``` -To actually save the translation, don't forget to save your model. +Also providing scoped queries for retrieving records based on locales ```php -$newsItem->setTranslation('name', 'en', 'Updated name in English'); +// Returns all news items with a name in English +NewsItem::whereLocale('name', 'en')->get(); -$newsItem->save(); -``` +// Returns all news items with a name in English or Dutch +NewsItem::whereLocales('name', ['en', 'nl'])->get(); -#### Validation +// Returns all news items that has name in English with value `Name in English` +NewsItem::query()->whereJsonContainsLocale('name', 'en', 'Name in English')->get(); -- if you want to validate an attribute for uniqueness before saving/updating the db, you might want to have a look at [laravel-unique-translation](https://github.com/codezero-be/laravel-unique-translation) which is made specifically for *laravel-translatable*. +// Returns all news items that has name in English or Dutch with value `Name in English` +NewsItem::query()->whereJsonContainsLocales('name', ['en', 'nl'], 'Name in English')->get(); -#### Forgetting a translation +// The last argument is the "operand" which you can tweak to achieve something like this: -You can forget a translation for a specific field: -``` php -public function forgetTranslation(string $attributeName, string $locale) -``` - -You can forget all translations for a specific locale: -``` php -public function forgetAllTranslations(string $locale) -``` - -#### Getting all translations in one go - -``` php -public function getTranslations(string $attributeName): array -``` - -#### Setting translations in one go +// Returns all news items that has name in English with value like `Name in...` +NewsItem::query()->whereJsonContainsLocale('name', 'en', 'Name in%', 'like')->get(); -``` php -public function setTranslations(string $attributeName, array $translations) +// Returns all news items that has name in English or Dutch with value like `Name in...` +NewsItem::query()->whereJsonContainsLocales('name', ['en', 'nl'], 'Name in%', 'like')->get(); ``` -Here's an example: - -``` php -$translations = [ - 'en' => 'Name in English', - 'nl' => 'Naam in het Nederlands' -]; - -$newsItem->setTranslations('name', $translations); -``` - -### Events - -#### TranslationHasBeenSet -Right after calling `setTranslation` the `Spatie\Translatable\Events\TranslationHasBeenSet`-event will be fired. - -It has these properties: -```php -/** @var \Illuminate\Database\Eloquent\Model */ -public $model; - -/** @var string */ -public $attributeName; - -/** @var string */ -public $locale; - -public $oldValue; -public $newValue; -``` - -### Creating models - -You can immediately set translations when creating a model. Here's an example: -```php -NewsItem::create([ - 'name' => [ - 'en' => 'Name in English', - 'nl' => 'Naam in het Nederlands' - ], -]); -``` - -### Querying translatable attributes - -If you're using MySQL 5.7 or above, it's recommended that you use the json data type for housing translations in the db. -This will allow you to query these columns like this: - -```php -NewsItem::where('name->en', 'Name in English')->get(); -``` +## Support us -## Changelog +[](https://spatie.be/github-ad-click/laravel-translatable) -Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. +We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). -## Upgrading +We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). -### From v2 to v3 +## Documentation -In most cases you can upgrade without making any changes to your codebase at all. `v3` introduced a `translations` accessor on your models. If you already had one defined on your model, you'll need to rename it. +All documentation is available [on our documentation site](https://spatie.be/docs/laravel-translatable). ## Testing @@ -209,17 +104,17 @@ composer test ## Contributing -Please see [CONTRIBUTING](CONTRIBUTING.md) for details. +Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. ## Security -If you discover any security related issues, please email freek@spatie.be instead of using the issue tracker. +If you've found a bug regarding security please mail [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker. ## Postcardware You're free to use this package, but if it makes it to your production environment we highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. -Our address is: Spatie, Samberstraat 69D, 2060 Antwerp, Belgium. +Our address is: Spatie, Kruikstraat 22, 2018 Antwerp, Belgium. We publish all received postcards [on our company website](https://spatie.be/en/opensource/postcards). @@ -231,12 +126,9 @@ We publish all received postcards [on our company website](https://spatie.be/en/ We got the idea to store translations as json in a column from [Mohamed Said](https://github.com/themsaid). Parts of the readme of [his multilingual package](https://github.com/themsaid/laravel-multilingual) were used in this readme. -## Support us - -Spatie is a webdesign agency based in Antwerp, Belgium. You'll find an overview of all our open source projects [on our website](https://spatie.be/opensource). +## Alternatives -Does your business depend on our contributions? Reach out and support us on [Patreon](https://www.patreon.com/spatie). -All pledges will be dedicated to allocating workforce on maintenance and new awesome stuff. +- [DB-Fields-Translations](https://github.com/Afzaal565/DB-Fields-Translations) ## License diff --git a/composer.json b/composer.json index d21e632..79bf8cc 100644 --- a/composer.json +++ b/composer.json @@ -24,21 +24,19 @@ "email": "sebastian@spatie.be", "homepage": "https://spatie.be/", "role": "Developer" - }, - { - "name": "Mohamed Said", - "email": "theMohamedSaid@gmail.com", - "role": "Original idea" } ], "require": { - "php" : "^7.2", - "laravel/framework": "~5.8.0" + "php": "^8.0", + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "spatie/laravel-package-tools": "^1.11" }, "require-dev": { - "phpunit/phpunit": "^8.0", - "orchestra/testbench": "~3.8.0", - "mockery/mockery": "^1.0" + "friendsofphp/php-cs-fixer": "^3.64", + "mockery/mockery": "^1.4", + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "pestphp/pest": "^1.20|^2.0|^3.0" }, "autoload": { "psr-4": { @@ -51,9 +49,25 @@ } }, "scripts": { - "test": "vendor/bin/phpunit" + "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes", + "test": "vendor/bin/pest" }, "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + }, "sort-packages": true - } + }, + "extra": { + "laravel": { + "providers": [ + "Spatie\\Translatable\\TranslatableServiceProvider" + ] + }, + "aliases": { + "Translatable": "Spatie\\Translatable\\Facades\\Translatable" + } + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/docs/_index.md b/docs/_index.md new file mode 100644 index 0000000..e08acf5 --- /dev/null +++ b/docs/_index.md @@ -0,0 +1,6 @@ +--- +title: v6 +slogan: Translatable Eloquent Models +githubUrl: https://github.com/spatie/laravel-translatable +branch: main +--- diff --git a/docs/about-us.md b/docs/about-us.md new file mode 100644 index 0000000..4589e99 --- /dev/null +++ b/docs/about-us.md @@ -0,0 +1,10 @@ +--- +title: About us +--- + +[Spatie](https://spatie.be) is a webdesign agency based in Antwerp, Belgium. + +Open source software is used in all projects we deliver. Laravel, Nginx, Ubuntu are just a few +of the free pieces of software we use every single day. For this, we are very grateful. +When we feel we have solved a problem in a way that can help other developers, +we release our code as open source software [on GitHub](https://spatie.be/open-source). diff --git a/docs/advanced-usage/_index.md b/docs/advanced-usage/_index.md new file mode 100644 index 0000000..8395637 --- /dev/null +++ b/docs/advanced-usage/_index.md @@ -0,0 +1,6 @@ +--- +title: Advanced usage +weight: 2 +--- + + diff --git a/docs/advanced-usage/available-events.md b/docs/advanced-usage/available-events.md new file mode 100644 index 0000000..3dc0839 --- /dev/null +++ b/docs/advanced-usage/available-events.md @@ -0,0 +1,25 @@ +--- +title: Available events +weight: 1 +--- + +Right after calling `setTranslation` the `Spatie\Translatable\Events\TranslationHasBeenSetEvent`-event will be fired. + +This is how that event looks like: + +```php +namespace Spatie\Translatable\Events; + +class TranslationHasBeenSetEvent +{ + public function __construct( + public mixed $model, + public string $key, + public string $locale, + public mixed $oldValue, + public mixed $newValue, + ) { + // + } +} +``` diff --git a/docs/advanced-usage/customize-the-toarray-method.md b/docs/advanced-usage/customize-the-toarray-method.md new file mode 100644 index 0000000..16fafea --- /dev/null +++ b/docs/advanced-usage/customize-the-toarray-method.md @@ -0,0 +1,31 @@ +--- +title: Customize the toArray method +weight: 1 +--- + +In many cases, the `toArray()` method on `Model` the class is called under the hood to serialize your model. + +To customize for all your models what should get returned for the translatable attributes you could wrap the`Spatie\Translatable\HasTranslations` trait into a custom trait and overrides the `toArray()` method. + +```php +namespace App\Traits; +use Spatie\Translatable\HasTranslations as BaseHasTranslations; + +trait HasTranslations +{ + use BaseHasTranslations; + + public function toArray() + { + $attributes = $this->attributesToArray(); // attributes selected by the query + // remove attributes if they are not selected + $translatables = array_filter($this->getTranslatableAttributes(), function ($key) use ($attributes) { + return array_key_exists($key, $attributes); + }); + foreach ($translatables as $field) { + $attributes[$field] = $this->getTranslation($field, \App::getLocale()); + } + return array_merge($attributes, $this->relationsToArray()); + } +} +``` diff --git a/docs/advanced-usage/usage-with-factories.md b/docs/advanced-usage/usage-with-factories.md new file mode 100644 index 0000000..308a72b --- /dev/null +++ b/docs/advanced-usage/usage-with-factories.md @@ -0,0 +1,41 @@ +--- +title: Usage with factories +weight: 1 +--- + +A small helper for making translations has been added for use in factories: + +This is what a few possible usages look like: + +```php +/** @var $this \Illuminate\Database\Eloquent\Factories\Factory */ + +$this->translations('en', 'english') +// output: ['en' => 'english'] + +$this->translations(['en', 'nl'], 'english') +// output: ['en' => 'english', 'nl' => 'english'] + +$this->translations(['en', 'nl'], ['english', 'dutch']) +// output: ['en' => 'english', 'nl' => 'dutch'] +``` + +The helper can also be used outside of factories using the following syntax: + +```php +\Illuminate\Database\Eloquent\Factories\Factory::translations('en', 'english'); +// output: ['en' => 'english'] +``` + +## In a Factory + +```php +class UserFactory extends \Illuminate\Database\Eloquent\Factories\Factory { + public function definition(): array + { + return [ + 'bio' => $this->translations('en', 'english'), + ]; + } +} +``` diff --git a/docs/basic-usage/_index.md b/docs/basic-usage/_index.md new file mode 100644 index 0000000..8885cab --- /dev/null +++ b/docs/basic-usage/_index.md @@ -0,0 +1,6 @@ +--- +title: Basic usage +weight: 1 +--- + + diff --git a/docs/basic-usage/getting-and-settings-translations.md b/docs/basic-usage/getting-and-settings-translations.md new file mode 100644 index 0000000..b352f3d --- /dev/null +++ b/docs/basic-usage/getting-and-settings-translations.md @@ -0,0 +1,123 @@ +--- +title: Getting and setting translations +weight: 1 +--- + +First, you must prepare your model as instructed in [the installation instructions](/docs/laravel-translatable/v6/installation-setup). + +## Setting a translation + +The easiest way to set a translation for the current locale is to just set the property for a translatable attribute. + +Here's an example, given that `name` is a translatable attribute: + +```php +$newsItem->name = 'New translation'; +``` + +To actually save the translation, don't forget to save your model. + +```php +$newsItem->name = 'New translation' +$newsItem->save(); +``` + +You can immediately set translations when creating a model. + +```php +NewsItem::create([ + 'name' => [ + 'en' => 'Name in English', + 'nl' => 'Naam in het Nederlands' + ], +]); +``` + +To set a translation for a specific locale you can use this method: + +```php +public function setTranslation(string $attributeName, string $locale, string $value) +``` + +You can set translations for multiple languages with + +```php +$translations = ['en' => 'hello', 'es' => 'hola']; +$newItem->name = $translations; + +// alternatively, use the `setTranslations` method + +$newsItem->setTranslations('name', $translations); + +$newItem->save(); +``` + +## Getting a translation + +The easiest way to get a translation for the current locale is to just get the property for the translated attribute. +For example (given that `name` is a translatable attribute): + +```php +$newsItem->name; +``` + +You can also use this method: + +```php +public function getTranslation(string $attributeName, string $locale, bool $useFallbackLocale = true) : string +``` + +This function has an alias named `translate`. + + +### Getting all translations + +You can get all translations by calling `getTranslations()` without an argument: + +```php +$newsItem->getTranslations(); +``` + +Or you can use the accessor: + +```php +$yourModel->translations +``` + +The methods above will give you back an array that holds all translations, for example: + +```php +$newsItem->getTranslations('name'); +// returns ['en' => 'Name in English', 'nl' => 'Naam in het Nederlands'] +``` + +The method above returns, all locales. If you only want specific locales, pass that to the second argument of `getTranslations`. + +```php +public function getTranslations(string $attributeName, array $allowedLocales): array +``` + +Here's an example: + +```php +$translations = [ + 'en' => 'Hello', + 'fr' => 'Bonjour', + 'de' => 'Hallo', +]; + +$newsItem->setTranslations('hello', $translations); +$newsItem->getTranslations('hello', ['en', 'fr']); // returns ['en' => 'Hello', 'fr' => 'Bonjour'] +``` + +### Get locales that a model has + +You can get all locales that a model has by calling `locales()` without an argument: + +```php + $translations = ['en' => 'hello', 'es' => 'hola']; + $newItem->name = $translations; + $newItem->save(); + + $newItem->locales(); // returns ['en', 'es'] +``` diff --git a/docs/basic-usage/handling-missing-translations.md b/docs/basic-usage/handling-missing-translations.md new file mode 100644 index 0000000..127512a --- /dev/null +++ b/docs/basic-usage/handling-missing-translations.md @@ -0,0 +1,141 @@ +--- +title: Handling missing translations +weight: 7 +--- + +Sometimes your model doesn't have a requested translation. Using the fallback functionality, you can decide what should +happen. + +To set up fallback you need to call static method on the facade `Spatie\Translatable\Facades\Translatable`. Typically, +you would put this +in [a service provider of your own](https://laravel.com/docs/8.x/providers#writing-service-providers): + +```php + // typically, in a service provider + + use Spatie\Translatable\Facades\Translatable; + + Translatable::fallback( + ... + ); +``` + +### Falling back to a specific locale + +If you want to have another `fallback_locale` than the app fallback locale (see `config/app.php`), you should pass it +as `$fallbackLocale` parameter: + +```php + use Spatie\Translatable\Facades\Translatable; + + Translatable::fallback( + fallbackLocale: 'fr', + ); +``` + +### Fallback locale per model + +If the fallback locale differs between models, you can define a `getFallbackLocale()` method on your model. + +```php +use Illuminate\Database\Eloquent\Model; +use Spatie\Translatable\HasTranslations; + +class NewsItem extends Model +{ + use HasTranslations; + + public $fillable = ['name', 'fallback_locale']; + + public $translatable = ['name']; + + public function getFallbackLocale() : string + { + return $this->fallback_locale; + } +} +``` + +### Falling back to any locale + +Sometimes it is favored to return any translation if neither the translation for the preferred locale nor the fallback +locale are set. To do so, just pass `$fallbackAny` to true: + +```php + use Spatie\Translatable\Facades\Translatable; + + Translatable::fallback( + fallbackAny: true, + ); +``` + +### Customize fallbacks + +You can set up a fallback callback that is called when a translation key is missing/not found. It just lets you execute +some custom code like logging something or contact a remote service for example. + +You have to register some code you want to run, by passing a closure to `$missingKeyCallback`. + +Here's an example with a closure that logs a warning with some info about the missing translation key: + +```php +use Spatie\Translatable\Facades\Translatable; +use Illuminate\Support\Facades\Log; + +Translatable::fallback(missingKeyCallback: function ( + Model $model, + string $translationKey, + string $locale, + string $fallbackTranslation, + string $fallbackLocale, +) { + + // do something (ex: logging, alerting, etc) + + Log::warning('Some translation key is missing from an eloquent model', [ + 'key' => $translationKey, + 'locale' => $locale, + 'fallback_locale' => $fallbackLocale, + 'fallback_translation' => $fallbackTranslation, + 'model_id' => $model->id, + 'model_class' => get_class($model), + ]); +}); +``` + +If the closure returns a string, it will be used as the fallback translation: + +```php +use Spatie\Translatable\Facades\Translatable; +use App\Service\MyRemoteTranslationService; + +Translatable::fallback(missingKeyCallback: function ( + Model $model, + string $translationKey, + string $locale, + string $fallbackTranslation, + string $fallbackLocale + ) { + + return MyRemoteTranslationService::getAutomaticTranslation($fallbackTranslation, $fallbackLocale, $locale); +}); +``` + +### Disabling fallbacks on a per model basis +By default, a fallback will be used when you access a non-existent translation attribute. + +You can disable fallbacks on a model with the `$useFallbackLocale` property. + +```php +use Illuminate\Database\Eloquent\Model; +use Spatie\Translatable\HasTranslations; + +class NewsItem extends Model +{ + use HasTranslations; + + public $translatable = ['name']; + + protected $useFallbackLocale = false; +} +``` diff --git a/docs/basic-usage/querying-translations.md b/docs/basic-usage/querying-translations.md new file mode 100644 index 0000000..506d7bf --- /dev/null +++ b/docs/basic-usage/querying-translations.md @@ -0,0 +1,34 @@ +--- +title: Querying translations +weight: 5 +--- + +If you're using MySQL 5.7 or above, it's recommended that you use the JSON data type for housing translations in the db. +This will allow you to query these columns like this: + +```php +NewsItem::where('name->en', 'Name in English')->get(); +``` + +Or if you're using MariaDB 10.2.3 or above : +```php +NewsItem::whereRaw("JSON_EXTRACT(name, '$.en') = 'Name in English'")->get(); +``` + +If you want to query records based on locales, you can use the `whereLocale` and `whereLocales` methods. + +```php +NewsItem::whereLocale('name', 'en')->get(); // Returns all news items with a name in English + +NewsItem::whereLocales('name', ['en', 'nl'])->get(); // Returns all news items with a name in English or Dutch +``` + +If you want to query records based on locale's value, you can use the `whereJsonContainsLocale` and `whereJsonContainsLocales` methods. + +```php +// Returns all news items that has name in English with value `Name in English` +NewsItem::query()->whereJsonContainsLocale('name', 'en', 'Name in English')->get(); + +// Returns all news items that has name in English or Dutch with value `Name in English` +NewsItem::query()->whereJsonContainsLocales('name', ['en', 'nl'], 'Name in English')->get(); +``` diff --git a/docs/basic-usage/removing-translations.md b/docs/basic-usage/removing-translations.md new file mode 100644 index 0000000..d115e81 --- /dev/null +++ b/docs/basic-usage/removing-translations.md @@ -0,0 +1,28 @@ +--- +title: Removing translations +weight: 2 +--- + +You can forget a translation for a specific field using the `forgetTranslation` function: + +```php +public function forgetTranslation(string $attributeName, string $locale) +``` + +Here's an example: + +```php +$newsItem->forgetTranslation('name', 'nl'); +``` + +You can forget all translations for a specific locale: + +```php +public function forgetAllTranslations(string $locale) +``` + +Here's an example: + +```php +$newsItem->forgetAllTranslations('nl'); +``` diff --git a/docs/basic-usage/replacing-translations.md b/docs/basic-usage/replacing-translations.md new file mode 100644 index 0000000..273e5ed --- /dev/null +++ b/docs/basic-usage/replacing-translations.md @@ -0,0 +1,24 @@ +--- +title: Replacing translations +weight: 3 +--- + +You can replace all the translations for a single key using this method: + +```php +public function replaceTranslations(string $key, array $translations) +``` + +Here's an example: + +```php +$translations = ['en' => 'hello', 'es' => 'hola']; + +$newsItem->setTranslations('hello', $translations); +$newsItem->getTranslations(); // ['en' => 'hello', 'es' => 'hola'] + +$newTranslations = ['en' => 'hello']; + +$newsItem->replaceTranslations('hello', $newTranslations); +$newsItem->getTranslations(); // ['en' => 'hello'] +``` diff --git a/docs/basic-usage/validating-translations.md b/docs/basic-usage/validating-translations.md new file mode 100644 index 0000000..6afd4b6 --- /dev/null +++ b/docs/basic-usage/validating-translations.md @@ -0,0 +1,7 @@ +--- +title: Validation translations +weight: 6 +--- + +If you want to validate an attribute for uniqueness before saving/updating the db, you might want to have a look at [laravel-unique-translation](https://github.com/codezero-be/laravel-unique-translation) which is made specifically for laravel-translatable. + diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..bd03745 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,6 @@ +--- +title: Changelog +weight: 6 +--- + +All notable changes to laravel-translatable are documented [on GitHub](https://github.com/spatie/laravel-translatable/blob/main/CHANGELOG.md) diff --git a/docs/installation-setup.md b/docs/installation-setup.md new file mode 100644 index 0000000..92f1b4d --- /dev/null +++ b/docs/installation-setup.md @@ -0,0 +1,31 @@ +--- +title: Installation & setup +weight: 4 +--- + +You can install the package via composer: + +```bash +composer require spatie/laravel-translatable +``` + +## Making a model translatable + +The required steps to make a model translatable are: + +- First, you need to add the `Spatie\Translatable\HasTranslations`-trait. +- Next, you should create a public property `$translatable` which holds an array with all the names of attributes you wish to make translatable. +- Finally, you should make sure that all translatable attributes are set to the `json`-datatype in your database. If your database doesn't support `json`-columns, use `text`. + +Here's an example of a prepared model: + +```php +use Illuminate\Database\Eloquent\Model; +use Spatie\Translatable\HasTranslations; + +class NewsItem extends Model +{ + use HasTranslations; + + public array $translatable = ['name']; +} diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 0000000..0ef0ec9 --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,24 @@ +--- +title: Introduction +weight: 1 +--- + +This package contains a trait to make Eloquent models translatable. Translations are stored as json. There is no extra table needed to hold them. + +Once the trait is installed on the model you can do these things: + +```php +$newsItem = new NewsItem(); // This is an Eloquent model +$newsItem + ->setTranslation('name', 'en', 'Name in English') + ->setTranslation('name', 'nl', 'Naam in het Nederlands') + ->save(); + +$newsItem->name; // Returns 'Name in English' given that the current app locale is 'en' +$newsItem->getTranslation('name', 'nl'); // returns 'Naam in het Nederlands' + +app()->setLocale('nl'); + +$newsItem->name; // Returns 'Naam in het Nederlands' +``` + diff --git a/docs/questions-issues.md b/docs/questions-issues.md new file mode 100644 index 0000000..bcbfda1 --- /dev/null +++ b/docs/questions-issues.md @@ -0,0 +1,8 @@ +--- +title: Questions and issues +weight: 5 +--- + +Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving the health library? Feel free to [create an issue on GitHub](https://github.com/spatie/laravel-translatable/issues), we'll try to address it as soon as possible. + +If you've found a bug regarding security please mail [freek@spatie.be](mailto:freek@spatie.be) instead of using the issue tracker. diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 0000000..f88dc0e --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,8 @@ +--- +title: Requirements +weight: 3 +--- + +The laravel-translatable package requires **PHP 8.0+**, **Laravel 9+**. + +This package uses `json` columns. MySQL 5.7 or higher is required. diff --git a/docs/support-us.md b/docs/support-us.md new file mode 100644 index 0000000..90f5c27 --- /dev/null +++ b/docs/support-us.md @@ -0,0 +1,8 @@ +--- +title: Support us +weight: 2 +--- + +We invest a lot of resources into creating our [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). + +We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..665a6a5 --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,15 @@ +--- +title: Upgrading +weight: 3 +--- + +### From v5 to v6 + +The config file has been removed. You can now define a fallback locale, set `fallBackAny` and handle custom behaviour for missing translations, via `Translatable::fallback()`. Take a look in the readme to learn how to specify the fallback behaviour you want. + +The `TranslationHasBeenSet` event has been renamed to `TranslationHasBeenSetEvent`. + +### From v2 to v3 + +In most cases you can upgrade without making any changes to your codebase at all. `v3` introduced a `translations` accessor on your models. If you already had one defined on your model, you'll need to rename it. + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index bcdc282..bdaf3e7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,25 +1,16 @@ - - - - tests - - - - - src/ - - - - - + + + + tests + + + + + + + + src/ + + diff --git a/src/Events/TranslationHasBeenSet.php b/src/Events/TranslationHasBeenSet.php deleted file mode 100644 index 5dfaf50..0000000 --- a/src/Events/TranslationHasBeenSet.php +++ /dev/null @@ -1,32 +0,0 @@ -model = $model; - - $this->key = $key; - - $this->locale = $locale; - - $this->oldValue = $oldValue; - $this->newValue = $newValue; - } -} diff --git a/src/Events/TranslationHasBeenSetEvent.php b/src/Events/TranslationHasBeenSetEvent.php new file mode 100644 index 0000000..4fa350d --- /dev/null +++ b/src/Events/TranslationHasBeenSetEvent.php @@ -0,0 +1,16 @@ +getTranslatableAttributes()); diff --git a/src/Facades/Translatable.php b/src/Facades/Translatable.php new file mode 100644 index 0000000..dd88583 --- /dev/null +++ b/src/Facades/Translatable.php @@ -0,0 +1,20 @@ +mergeCasts( + array_fill_keys($this->getTranslatableAttributes(), 'array'), + ); + } + + public static function usingLocale(string $locale): self + { + return (new self)->setLocale($locale); + } + + public function useFallbackLocale(): bool + { + if (property_exists($this, 'useFallbackLocale')) { + return $this->useFallbackLocale; + } + + return true; + } + + public function getAttributeValue($key): mixed { if (! $this->isTranslatableAttribute($key)) { return parent::getAttributeValue($key); } - return $this->getTranslation($key, $this->getLocale()); + return $this->getTranslation($key, $this->getLocale(), $this->useFallbackLocale()); + } + + protected function mutateAttributeForArray($key, $value): mixed + { + if (! $this->isTranslatableAttribute($key)) { + return parent::mutateAttributeForArray($key, $value); + } + + $translations = $this->getTranslations($key); + + return array_map(fn ($value) => parent::mutateAttributeForArray($key, $value), $translations); } public function setAttribute($key, $value) { - // Pass arrays and untranslatable attributes to the parent method. - if (! $this->isTranslatableAttribute($key) || is_array($value)) { + if (! $this->isTranslatableAttribute($key)) { return parent::setAttribute($key, $value); } - // If the attribute is translatable and not already translated, set a - // translation for the current app locale. + if (is_array($value) && (! array_is_list($value) || count($value) === 0)) { + return $this->setTranslations($key, $value); + } + return $this->setTranslation($key, $this->getLocale(), $value); } - public function translate(string $key, string $locale = ''): string + public function translate(string $key, string $locale = '', bool $useFallbackLocale = true): mixed { - return $this->getTranslation($key, $locale); + return $this->getTranslation($key, $locale, $useFallbackLocale); } - public function getTranslation(string $key, string $locale, bool $useFallbackLocale = true) + public function getTranslation(string $key, string $locale, bool $useFallbackLocale = true): mixed { - $locale = $this->normalizeLocale($key, $locale, $useFallbackLocale); + $normalizedLocale = $this->normalizeLocale($key, $locale, $useFallbackLocale); + + $isKeyMissingFromLocale = ($locale !== $normalizedLocale); $translations = $this->getTranslations($key); - $translation = $translations[$locale] ?? ''; + $baseKey = Str::before($key, '->'); // get base key in case it is JSON nested key + + $translatableConfig = app(Translatable::class); + + if (is_null(self::getAttributeFromArray($baseKey))) { + $translation = null; + } else { + $translation = isset($translations[$normalizedLocale]) ? $translations[$normalizedLocale] : null; + $translation ??= ($translatableConfig->allowNullForTranslation) ? null : ''; + } + + if ($isKeyMissingFromLocale && $translatableConfig->missingKeyCallback) { + try { + $callbackReturnValue = ($translatableConfig->missingKeyCallback)($this, $key, $locale, $translation, $normalizedLocale); + if (is_string($callbackReturnValue)) { + $translation = $callbackReturnValue; + } + } catch (Exception) { + // prevent the fallback to crash + } + } + + $key = str_replace('->', '-', $key); if ($this->hasGetMutator($key)) { return $this->mutateAttribute($key, $translation); } + if ($this->hasAttributeMutator($key)) { + return $this->mutateAttributeMarkedAttribute($key, $translation); + } + return $translation; } - public function getTranslationWithFallback(string $key, string $locale): string + public function getTranslationWithFallback(string $key, string $locale): mixed { return $this->getTranslation($key, $locale, true); } - public function getTranslationWithoutFallback(string $key, string $locale) + public function getTranslationWithoutFallback(string $key, string $locale): mixed { return $this->getTranslation($key, $locale, false); } - public function getTranslations(string $key = null) : array + public function getTranslations(?string $key = null, ?array $allowedLocales = null): array { if ($key !== null) { $this->guardAgainstNonTranslatableAttribute($key); + $translatableConfig = app(Translatable::class); - return array_filter(json_decode($this->getAttributes()[$key] ?? '' ?: '{}', true) ?: [], function ($value) { - return $value !== null && $value !== false && $value !== ''; - }); + if ($this->isNestedKey($key)) { + [$key, $nestedKey] = explode('.', str_replace('->', '.', $key), 2); + } + + return array_filter( + Arr::get($this->fromJson($this->getAttributeFromArray($key)), $nestedKey ?? null, []), + fn ($value, $locale) => $this->filterTranslations($value, $locale, $allowedLocales, $translatableConfig->allowNullForTranslation, $translatableConfig->allowEmptyStringForTranslation), + ARRAY_FILTER_USE_BOTH, + ); } - return array_reduce($this->getTranslatableAttributes(), function ($result, $item) { - $result[$item] = $this->getTranslations($item); + return array_reduce($this->getTranslatableAttributes(), function ($result, $item) use ($allowedLocales) { + $result[$item] = $this->getTranslations($item, $allowedLocales); return $result; }); @@ -84,19 +158,31 @@ public function setTranslation(string $key, string $locale, $value): self $oldValue = $translations[$locale] ?? ''; - if ($this->hasSetMutator($key)) { - $method = 'set'.Str::studly($key).'Attribute'; + $mutatorKey = str_replace('->', '-', $key); + + if ($this->hasSetMutator($mutatorKey)) { + $method = 'set'.Str::studly($mutatorKey).'Attribute'; $this->{$method}($value, $locale); $value = $this->attributes[$key]; + } elseif ($this->hasAttributeSetMutator($mutatorKey)) { // handle new attribute mutator + $this->setAttributeMarkedMutatedAttributeValue($mutatorKey, $value); + + $value = $this->attributes[$mutatorKey]; } $translations[$locale] = $value; - $this->attributes[$key] = $this->asJson($translations); + if ($this->isNestedKey($key)) { + unset($this->attributes[$key], $this->attributes[$mutatorKey]); - event(new TranslationHasBeenSet($this, $key, $locale, $oldValue, $value)); + $this->fillJsonAttribute($key, $translations); + } else { + $this->attributes[$key] = json_encode($translations, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + event(new TranslationHasBeenSetEvent($this, $key, $locale, $oldValue, $value)); return $this; } @@ -105,8 +191,12 @@ public function setTranslations(string $key, array $translations): self { $this->guardAgainstNonTranslatableAttribute($key); - foreach ($translations as $locale => $translation) { - $this->setTranslation($key, $locale, $translation); + if (! empty($translations)) { + foreach ($translations as $locale => $translation) { + $this->setTranslation($key, $locale, $translation); + } + } else { + $this->attributes[$key] = $this->asJson([]); } return $this; @@ -116,9 +206,27 @@ public function forgetTranslation(string $key, string $locale): self { $translations = $this->getTranslations($key); - unset($translations[$locale]); + unset( + $translations[$locale], + $this->$key + ); + + $this->setTranslations($key, $translations); - $this->setAttribute($key, $translations); + return $this; + } + + public function forgetTranslations(string $key, bool $asNull = false): self + { + $this->guardAgainstNonTranslatableAttribute($key); + + collect($this->getTranslatedLocales($key))->each(function (string $locale) use ($key) { + $this->forgetTranslation($key, $locale); + }); + + if ($asNull) { + $this->attributes[$key] = null; + } return $this; } @@ -132,33 +240,51 @@ public function forgetAllTranslations(string $locale): self return $this; } - public function getTranslatedLocales(string $key) : array + public function getTranslatedLocales(string $key): array { return array_keys($this->getTranslations($key)); } - public function isTranslatableAttribute(string $key) : bool + public function isNestedKey(string $key): bool + { + return str_contains($key, '->'); + } + + public function isTranslatableAttribute(string $key): bool { return in_array($key, $this->getTranslatableAttributes()); } - public function hasTranslation(string $key, string $locale = null): bool + public function hasTranslation(string $key, ?string $locale = null): bool { $locale = $locale ?: $this->getLocale(); return isset($this->getTranslations($key)[$locale]); } - protected function guardAgainstNonTranslatableAttribute(string $key) + public function replaceTranslations(string $key, array $translations): self + { + foreach ($this->getTranslatedLocales($key) as $locale) { + $this->forgetTranslation($key, $locale); + } + + $this->setTranslations($key, $translations); + + return $this; + } + + protected function guardAgainstNonTranslatableAttribute(string $key): void { if (! $this->isTranslatableAttribute($key)) { throw AttributeIsNotTranslatable::make($key, $this); } } - protected function normalizeLocale(string $key, string $locale, bool $useFallbackLocale) : string + protected function normalizeLocale(string $key, string $locale, bool $useFallbackLocale): string { - if (in_array($locale, $this->getTranslatedLocales($key))) { + $translatedLocales = $this->getTranslatedLocales($key); + + if (in_array($locale, $translatedLocales)) { return $locale; } @@ -166,39 +292,130 @@ protected function normalizeLocale(string $key, string $locale, bool $useFallbac return $locale; } - if (! is_null($fallbackLocale = config('app.fallback_locale'))) { + if (method_exists($this, 'getFallbackLocale')) { + $fallbackLocale = $this->getFallbackLocale(); + } + + $fallbackConfig = app(Translatable::class); + + $fallbackLocale ??= $fallbackConfig->fallbackLocale ?? config('app.fallback_locale'); + + if (! is_null($fallbackLocale) && in_array($fallbackLocale, $translatedLocales)) { return $fallbackLocale; } + if (! empty($translatedLocales) && $fallbackConfig->fallbackAny) { + return $translatedLocales[0]; + } + return $locale; } - protected function getLocale() : string + protected function filterTranslations(mixed $value = null, ?string $locale = null, ?array $allowedLocales = null, bool $allowNull = false, bool $allowEmptyString = false): bool + { + if ($value === null && ! $allowNull) { + return false; + } + + if ($value === '' && ! $allowEmptyString) { + return false; + } + + if ($allowedLocales === null) { + return true; + } + + if (! in_array($locale, $allowedLocales)) { + return false; + } + + return true; + } + + public function setLocale(string $locale): self + { + $this->translationLocale = $locale; + + return $this; + } + + public function getLocale(): string { - return config('app.locale'); + return $this->translationLocale ?: config('app.locale'); } - public function getTranslatableAttributes() : array + public function getTranslatableAttributes(): array { return is_array($this->translatable) ? $this->translatable : []; } - public function getTranslationsAttribute(): array + public function translations(): Attribute { - return collect($this->getTranslatableAttributes()) - ->mapWithKeys(function (string $key) { - return [$key => $this->getTranslations($key)]; - }) - ->toArray(); + return Attribute::get(function () { + return collect($this->getTranslatableAttributes()) + ->mapWithKeys(function (string $key) { + return [$key => $this->getTranslations($key)]; + }) + ->toArray(); + }); } - public function getCasts() : array + public function locales(): array { - return array_merge( - parent::getCasts(), - array_fill_keys($this->getTranslatableAttributes(), 'array') + return array_unique( + array_reduce($this->getTranslatableAttributes(), function ($result, $item) { + return array_merge($result, $this->getTranslatedLocales($item)); + }, []) ); } + + public function scopeWhereLocale(Builder $query, string $column, string $locale): void + { + $query->whereNotNull("{$column}->{$locale}"); + } + + public function scopeWhereLocales(Builder $query, string $column, array $locales): void + { + $query->where(function (Builder $query) use ($column, $locales) { + foreach ($locales as $locale) { + $query->orWhereNotNull("{$column}->{$locale}"); + } + }); + } + + public function scopeWhereJsonContainsLocale(Builder $query, string $column, string $locale, mixed $value, string $operand = '='): void + { + $query->where("{$column}->{$locale}", $operand, $value); + } + + public function scopeWhereJsonContainsLocales(Builder $query, string $column, array $locales, mixed $value, string $operand = '='): void + { + $query->where(function (Builder $query) use ($column, $locales, $value, $operand) { + foreach ($locales as $locale) { + $query->orWhere("{$column}->{$locale}", $operand, $value); + } + }); + } + + /** + * @deprecated + */ + public static function whereLocale(string $column, string $locale): Builder + { + return static::query()->whereNotNull("{$column}->{$locale}"); + } + + /** + * @deprecated + */ + public static function whereLocales(string $column, array $locales): Builder + { + return static::query()->where(function (Builder $query) use ($column, $locales) { + foreach ($locales as $locale) { + $query->orWhereNotNull("{$column}->{$locale}"); + } + }); + } } diff --git a/src/Translatable.php b/src/Translatable.php new file mode 100644 index 0000000..9f1a521 --- /dev/null +++ b/src/Translatable.php @@ -0,0 +1,51 @@ +fallbackLocale = $fallbackLocale; + $this->fallbackAny = $fallbackAny; + $this->missingKeyCallback = $missingKeyCallback; + + return $this; + } + + public function allowNullForTranslation(bool $allowNullForTranslation = true): self + { + $this->allowNullForTranslation = $allowNullForTranslation; + + return $this; + } + + public function allowEmptyStringForTranslation(bool $allowEmptyStringForTranslation = true): self + { + $this->allowEmptyStringForTranslation = $allowEmptyStringForTranslation; + + return $this; + } +} diff --git a/src/TranslatableServiceProvider.php b/src/TranslatableServiceProvider.php new file mode 100644 index 0000000..6cf6c01 --- /dev/null +++ b/src/TranslatableServiceProvider.php @@ -0,0 +1,28 @@ +name('laravel-translatable'); + } + + public function packageRegistered(): void + { + $this->app->singleton(Translatable::class, fn () => new Translatable); + $this->app->bind('translatable', Translatable::class); + + Factory::macro('translations', function (string|array $locales, mixed $value) { + return is_array($value) + ? array_combine((array) $locales, $value) + : array_fill_keys((array) $locales, $value); + }); + } +} diff --git a/tests/EventTest.php b/tests/EventTest.php index 91e385d..e281878 100644 --- a/tests/EventTest.php +++ b/tests/EventTest.php @@ -1,26 +1,17 @@ testModel = new TestModel; +}); - public function setUp(): void - { - parent::setUp(); +it('will fire an event when a translation has been set', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel = new TestModel(); - } - - /** @test */ - public function it_will_fire_an_event_when_a_translation_has_been_set() - { - $this->expectsEvents(TranslationHasBeenSet::class); - - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - } -} + Event::assertDispatched(TranslationHasBeenSetEvent::class); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..4bf710e --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php index 54a4d28..a1296ba 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,25 +2,33 @@ namespace Spatie\Translatable\Test; -use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; use Orchestra\Testbench\TestCase as Orchestra; +use Spatie\Translatable\TranslatableServiceProvider; abstract class TestCase extends Orchestra { - public function setUp(): void + protected function setUp(): void { parent::setUp(); $this->setUpDatabase(); } + protected function getPackageProviders($app) + { + return [TranslatableServiceProvider::class]; + } + protected function setUpDatabase() { Schema::create('test_models', function (Blueprint $table) { $table->increments('id'); $table->text('name')->nullable(); $table->text('other_field')->nullable(); + $table->text('field_with_mutator')->nullable(); + $table->json('nested')->nullable(); }); } } diff --git a/tests/TestModel.php b/tests/TestSupport/TestModel.php similarity index 50% rename from tests/TestModel.php rename to tests/TestSupport/TestModel.php index 508cf4f..ea9e337 100644 --- a/tests/TestModel.php +++ b/tests/TestSupport/TestModel.php @@ -1,6 +1,6 @@ attributes['field_with_mutator'] = $value; + } } diff --git a/tests/TestSupport/TestModelWithFallbackLocale.php b/tests/TestSupport/TestModelWithFallbackLocale.php new file mode 100644 index 0000000..a75e3ee --- /dev/null +++ b/tests/TestSupport/TestModelWithFallbackLocale.php @@ -0,0 +1,31 @@ +attributes['field_with_mutator'] = $value; + } +} diff --git a/tests/TestSupport/TestModelWithoutFallback.php b/tests/TestSupport/TestModelWithoutFallback.php new file mode 100644 index 0000000..9e46e90 --- /dev/null +++ b/tests/TestSupport/TestModelWithoutFallback.php @@ -0,0 +1,26 @@ +attributes['field_with_mutator'] = $value; + } +} diff --git a/tests/TranslatableTest.php b/tests/TranslatableTest.php index 885282a..d4b3d72 100644 --- a/tests/TranslatableTest.php +++ b/tests/TranslatableTest.php @@ -1,464 +1,1050 @@ testModel = new TestModel; +}); - public function setUp(): void - { - parent::setUp(); +it('will return package fallback locale translation when getting an unknown locale', function () { + config()->set('app.fallback_locale', 'nl'); + Translatable::fallback( + fallbackLocale: 'en', + ); - $this->testModel = new TestModel(); - } + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); - /** @test */ - public function it_will_return_fallback_locale_translation_when_getting_an_unknown_locale() - { - $this->app['config']->set('app.fallback_locale', 'en'); + expect($this->testModel->getTranslation('name', 'fr'))->toBe('testValue_en'); +}); - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->save(); +it('will return default fallback locale translation when getting an unknown locale', function () { + config()->set('app.fallback_locale', 'en'); - $this->assertSame('testValue_en', $this->testModel->getTranslation('name', 'fr')); - } + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); - /** @test */ - public function it_provides_a_flog_to_not_return_fallback_locale_translation_when_getting_an_unknown_locale() - { - $this->app['config']->set('app.fallback_locale', 'en'); + expect($this->testModel->getTranslation('name', 'fr'))->toBe('testValue_en'); +}); - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->save(); +it('provides a flog to not return fallback locale translation when getting an unknown locale', function () { + config()->set('app.fallback_locale', 'en'); - $this->assertSame('', $this->testModel->getTranslation('name', 'fr', false)); - } + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); - /** @test */ - public function it_will_return_fallback_locale_translation_when_getting_an_unknown_locale_and_fallback_is_true() - { - $this->app['config']->set('app.fallback_locale', 'en'); + expect($this->testModel->getTranslation('name', 'fr', false))->toBe(''); +}); - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->save(); +it('will return fallback locale translation when getting an unknown locale and fallback is true', function () { + config()->set('app.fallback_locale', 'en'); - $this->assertSame('testValue_en', $this->testModel->getTranslationWithFallback('name', 'fr')); - } + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); - /** @test */ - public function it_will_return_an_empty_string_when_getting_an_unknown_locale_and_fallback_is_not_set() - { - $this->app['config']->set('app.fallback_locale', ''); + expect($this->testModel->getTranslationWithFallback('name', 'fr'))->toBe('testValue_en'); +}); - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->save(); +it('will execute callback fallback when getting an unknown locale and fallback callback is enabled', function () { + Storage::fake(); - $this->assertSame('', $this->testModel->getTranslationWithoutFallback('name', 'fr')); - } + Translatable::fallback(missingKeyCallback: function ($model, string $translationKey, string $locale) { + // something assertable outside the closure + Storage::put('test.txt', 'test'); + }); - /** @test */ - public function it_will_return_an_empty_string_when_getting_an_unknown_locale_and_fallback_is_empty() - { - $this->app['config']->set('app.fallback_locale', ''); + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->save(); + expect($this->testModel->getTranslationWithFallback('name', 'fr'))->toBe('testValue_en'); - $this->assertSame('', $this->testModel->getTranslation('name', 'fr')); - } + Storage::assertExists('test.txt'); +}); - /** @test */ - public function it_can_save_a_translated_attribute() - { - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->save(); +it('will use callback fallback return value as translation', function () { + Translatable::fallback(missingKeyCallback: function ($model, string $translationKey, string $locale) { + return 'testValue_fallback_callback'; + }); - $this->assertSame('testValue_en', $this->testModel->name); - } + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); - /** @test */ - public function it_can_set_translated_values_when_creating_a_model() - { - $model = TestModel::create([ - 'name' => ['en' => 'testValue_en'], - ]); + expect($this->testModel->getTranslationWithFallback('name', 'fr'))->toBe('testValue_fallback_callback'); +}); - $this->assertSame('testValue_en', $model->name); - } +it('wont use callback fallback return value as translation if it is not a string', function () { + Translatable::fallback(missingKeyCallback: function ($model, string $translationKey, string $locale) { + return 123456; + }); - /** @test */ - public function it_can_save_multiple_translations() - { - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); - $this->testModel->save(); + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); - $this->assertSame('testValue_en', $this->testModel->name); - $this->assertSame('testValue_fr', $this->testModel->getTranslation('name', 'fr')); - } + expect($this->testModel->getTranslationWithFallback('name', 'fr'))->toBe('testValue_en'); +}); - /** @test */ - public function it_will_return_the_value_of_the_current_locale_when_using_the_property() - { - $this->testModel->setTranslation('name', 'en', 'testValue'); - $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); - $this->testModel->save(); +it('wont execute callback fallback when getting an existing translation', function () { + Storage::fake(); - app()->setLocale('fr'); + Translatable::fallback(missingKeyCallback: function ($model, string $translationKey, string $locale) { + // something assertable outside the closure + Storage::put('test.txt', 'test'); + }); - $this->assertSame('testValue_fr', $this->testModel->name); - } + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); - /** @test */ - public function it_can_get_all_translations_in_one_go() - { - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); - $this->testModel->save(); + expect($this->testModel->getTranslationWithFallback('name', 'en'))->toBe('testValue_en'); + + Storage::assertMissing('test.txt'); +}); + +it('wont fail if callback fallback throw exception', function () { + Translatable::fallback(missingKeyCallback: function ($model, string $translationKey, string $locale) { + throw new \Exception; + }); + + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); + + expect($this->testModel->getTranslationWithFallback('name', 'fr'))->toBe('testValue_en'); +}); + +it('will return an empty string when getting an unknown locale and fallback is not set', function () { + config()->set('app.fallback_locale', ''); + + Translatable::fallback( + fallbackLocale: '', + ); + + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); + + expect($this->testModel->getTranslationWithoutFallback('name', 'fr'))->toBe(''); +}); + +it('will return an empty string when getting an unknown locale and fallback is empty', function () { + config()->set('app.fallback_locale', ''); + + Translatable::fallback( + fallbackLocale: '', + ); + + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); + + expect($this->testModel->getTranslation('name', 'fr'))->toBe(''); +}); + +it('can save a translated attribute', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); + + expect($this->testModel->name)->toBe('testValue_en'); +}); - $this->assertSame([ +it('can set translated values when creating a model', function () { + $model = TestModel::create([ + 'name' => ['en' => 'testValue_en'], + ]); + + expect($model->name)->toBe('testValue_en'); +}); + +it('can save multiple translations', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->save(); + + expect($this->testModel->name)->toBe('testValue_en'); + expect($this->testModel->getTranslation('name', 'fr'))->toBe('testValue_fr'); +}); + +it('will return the value of the current locale when using the property', function () { + $this->testModel->setTranslation('name', 'en', 'testValue'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->save(); + + app()->setLocale('fr'); + + expect($this->testModel->name)->toBe('testValue_fr'); +}); + +it('can get all translations in one go', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->save(); + + $this->assertSame([ + 'en' => 'testValue_en', + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('name')); +}); + +it('can get specified translations in one go', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->save(); + + $this->assertSame([ + 'en' => 'testValue_en', + ], $this->testModel->getTranslations('name', ['en'])); +}); + +it('can get all translations for all translatable attributes in one go', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + + $this->testModel->setTranslation('other_field', 'en', 'testValue_en'); + $this->testModel->setTranslation('other_field', 'fr', 'testValue_fr'); + + $this->testModel->setTranslation('field_with_mutator', 'en', 'testValue_en'); + $this->testModel->setTranslation('field_with_mutator', 'fr', 'testValue_fr'); + $this->testModel->save(); + + $this->assertSame([ + 'name' => [ + 'en' => 'testValue_en', + 'fr' => 'testValue_fr', + ], + 'other_field' => [ 'en' => 'testValue_en', 'fr' => 'testValue_fr', - ], $this->testModel->getTranslations('name')); - } + ], + 'field_with_mutator' => [ + 'en' => 'testValue_en', + 'fr' => 'testValue_fr', + ], + ], $this->testModel->getTranslations()); +}); - /** @test */ - public function it_can_get_all_translations_for_all_translatable_attributes_in_one_go() - { - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); +it('can get specified translations for all translatable attributes in one go', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); - $this->testModel->setTranslation('other_field', 'en', 'testValue_en'); - $this->testModel->setTranslation('other_field', 'fr', 'testValue_fr'); - $this->testModel->save(); + $this->testModel->setTranslation('other_field', 'en', 'testValue_en'); + $this->testModel->setTranslation('other_field', 'fr', 'testValue_fr'); - $this->assertSame([ - 'name' => [ - 'en' => 'testValue_en', - 'fr' => 'testValue_fr', - ], - 'other_field' => [ - 'en' => 'testValue_en', - 'fr' => 'testValue_fr', - ], - ], $this->testModel->getTranslations()); - } + $this->testModel->setTranslation('field_with_mutator', 'en', 'testValue_en'); + $this->testModel->setTranslation('field_with_mutator', 'fr', 'testValue_fr'); + $this->testModel->save(); + + $this->assertSame([ + 'name' => ['en' => 'testValue_en'], + 'other_field' => ['en' => 'testValue_en'], + 'field_with_mutator' => ['en' => 'testValue_en'], + ], $this->testModel->getTranslations(null, ['en'])); +}); + +it('can get the locales which have a translation', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->save(); + + expect($this->testModel->getTranslatedLocales('name'))->toBe(['en', 'fr']); +}); + +it('can forget a translation', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->save(); + + $this->assertSame([ + 'en' => 'testValue_en', + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('name')); - /** @test */ - public function it_can_get_the_locales_which_have_a_translation() + $this->testModel->forgetTranslation('name', 'en'); + + $this->assertSame([ + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('name')); +}); + +it('can forget all translations of field', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->save(); + + $this->assertSame([ + 'en' => 'testValue_en', + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('name')); + + $this->testModel->forgetTranslations('name'); + + expect($this->testModel->getAttributes()['name'])->toBe('[]'); + expect($this->testModel->getTranslations('name'))->toBe([]); + + $this->testModel->save(); + + expect($this->testModel->fresh()->getAttributes()['name'])->toBe('[]'); + expect($this->testModel->fresh()->getTranslations('name'))->toBe([]); +}); + +it('can forget all translations of field and make field null', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->save(); + + $this->assertSame([ + 'en' => 'testValue_en', + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('name')); + + $this->testModel->forgetTranslations('name', true); + + expect($this->testModel->getAttributes()['name'])->toBeNull(); + expect($this->testModel->getTranslations('name'))->toBe([]); + + $this->testModel->save(); + + expect($this->testModel->fresh()->getAttributes()['name'])->toBeNull(); + expect($this->testModel->fresh()->getTranslations('name'))->toBe([]); +}); + +it('can forget a field with mutator translation', function () { + $this->testModel->setTranslation('field_with_mutator', 'en', 'testValue_en'); + $this->testModel->setTranslation('field_with_mutator', 'fr', 'testValue_fr'); + $this->testModel->save(); + + $this->assertSame([ + 'en' => 'testValue_en', + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('field_with_mutator')); + + $this->testModel->forgetTranslation('field_with_mutator', 'en'); + + $this->assertSame([ + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('field_with_mutator')); +}); + +it('can forget all translations', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + + $this->testModel->setTranslation('other_field', 'en', 'testValue_en'); + $this->testModel->setTranslation('other_field', 'fr', 'testValue_fr'); + + $this->testModel->setTranslation('field_with_mutator', 'en', 'testValue_en'); + $this->testModel->setTranslation('field_with_mutator', 'fr', 'testValue_fr'); + $this->testModel->save(); + + $this->assertSame([ + 'en' => 'testValue_en', + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('name')); + + $this->assertSame([ + 'en' => 'testValue_en', + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('other_field')); + + $this->assertSame([ + 'en' => 'testValue_en', + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('field_with_mutator')); + + $this->testModel->forgetAllTranslations('en'); + + $this->assertSame([ + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('name')); + + $this->assertSame([ + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('other_field')); + + $this->assertSame([ + 'fr' => 'testValue_fr', + ], $this->testModel->getTranslations('field_with_mutator')); +}); + +it('will throw an exception when trying to translate an untranslatable attribute', function () { + $this->expectException(AttributeIsNotTranslatable::class); + + $this->testModel->setTranslation('untranslated', 'en', 'value'); +}); + +it('is compatible with accessors on non translatable attributes', function () { + $testModel = new class extends TestModel { - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); - $this->testModel->save(); + public function getOtherFieldAttribute(): string + { + return 'accessorName'; + } + }; - $this->assertSame(['en', 'fr'], $this->testModel->getTranslatedLocales('name')); - } + expect('accessorName')->toEqual((new $testModel)->otherField); +}); - /** @test */ - public function it_can_forget_a_translation() +it('can use accessors on translated attributes', function () { + $testModel = new class extends TestModel { - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); - $this->testModel->save(); + public function getNameAttribute($value): string + { + return "I just accessed {$value}"; + } + }; - $this->assertSame([ - 'en' => 'testValue_en', - 'fr' => 'testValue_fr', - ], $this->testModel->getTranslations('name')); + $testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->forgetTranslation('name', 'en'); + expect('I just accessed testValue_en')->toEqual($testModel->name); +}); - $this->assertSame([ - 'fr' => 'testValue_fr', - ], $this->testModel->getTranslations('name')); - } +it('can be converted to array when using accessors on translated attributes', function () { + $testModel = new class extends TestModel + { + public function getNameAttribute($value) + { + return "I just accessed {$value}"; + } + }; + + $testModel->setTranslation('name', 'en', 'testValue_en'); + $testModel->setTranslation('name', 'nl', 'testValue_nl'); + + expect($testModel->toArray()) + ->toHaveKey('name') + ->toContain([ + 'en' => 'I just accessed testValue_en', + 'nl' => 'I just accessed testValue_nl', + ]); +}); - /** @test */ - public function it_can_forget_all_translations() +it('can use mutators on translated attributes', function () { + $testModel = new class extends TestModel { - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + public function setNameAttribute($value) + { + $this->attributes['name'] = "I just mutated {$value}"; + } + }; - $this->testModel->setTranslation('other_field', 'en', 'testValue_en'); - $this->testModel->setTranslation('other_field', 'fr', 'testValue_fr'); - $this->testModel->save(); + $testModel->setTranslation('name', 'en', 'testValue_en'); - $this->assertSame([ - 'en' => 'testValue_en', - 'fr' => 'testValue_fr', - ], $this->testModel->getTranslations('name')); + expect('I just mutated testValue_en')->toEqual($testModel->name); +}); - $this->assertSame([ +it('can set translations for default language', function () { + $model = TestModel::create([ + 'name' => [ 'en' => 'testValue_en', 'fr' => 'testValue_fr', - ], $this->testModel->getTranslations('other_field')); + ], + ]); - $this->testModel->forgetAllTranslations('en'); + app()->setLocale('en'); - $this->assertSame([ - 'fr' => 'testValue_fr', - ], $this->testModel->getTranslations('name')); + $model->name = 'updated_en'; + expect($model->name)->toEqual('updated_en'); + expect($model->getTranslation('name', 'fr'))->toEqual('testValue_fr'); - $this->assertSame([ - 'fr' => 'testValue_fr', - ], $this->testModel->getTranslations('other_field')); - } + app()->setLocale('fr'); + $model->name = 'updated_fr'; + expect($model->name)->toEqual('updated_fr'); + expect($model->getTranslation('name', 'en'))->toEqual('updated_en'); +}); + +it('can set multiple translations at once', function () { + $translations = ['nl' => 'hallo', 'en' => 'hello', 'kh' => 'សួរស្តី']; - /** @test */ - public function it_will_throw_an_exception_when_trying_to_translate_an_untranslatable_attribute() + $this->testModel->setTranslations('name', $translations); + $this->testModel->save(); + + expect($this->testModel->getTranslations('name'))->toEqual($translations); +}); + +it('can check if an attribute is translatable', function () { + expect($this->testModel->isTranslatableAttribute('name'))->toBeTrue(); + + expect($this->testModel->isTranslatableAttribute('other'))->toBeFalse(); +}); + +it('can check if an attribute has translation', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'nl', null); + $this->testModel->save(); + + expect($this->testModel->hasTranslation('name', 'en'))->toBeTrue(); + + expect($this->testModel->hasTranslation('name', 'pt'))->toBeFalse(); +}); + +it('can correctly set a field when a mutator is defined', function () { + $testModel = (new class extends TestModel { - $this->expectException(AttributeIsNotTranslatable::class); + public function setNameAttribute($value) + { + $this->attributes['name'] = "I just mutated {$value}"; + } + }); - $this->testModel->setTranslation('untranslated', 'en', 'value'); - } + $testModel->name = 'hello'; - /** @test */ - public function it_is_compatible_with_accessors_on_non_translatable_attributes() + $expected = ['en' => 'I just mutated hello']; + expect($testModel->getTranslations('name'))->toEqual($expected); +}); + +it('can set multiple translations when a mutator is defined', function () { + $testModel = (new class extends TestModel { - $testModel = new class() extends TestModel { - public function getOtherFieldAttribute() : string - { - return 'accessorName'; - } - }; - - $this->assertEquals((new $testModel())->otherField, 'accessorName'); - } - - /** @test */ - public function it_can_use_accessors_on_translated_attributes() + public function setNameAttribute($value) + { + $this->attributes['name'] = "I just mutated {$value}"; + } + }); + + $translations = [ + 'nl' => 'hallo', + 'en' => 'hello', + 'kh' => 'សួរស្តី', + ]; + + $testModel->setTranslations('name', $translations); + + $testModel->save(); + + $expected = [ + 'nl' => 'I just mutated hallo', + 'en' => 'I just mutated hello', + 'kh' => 'I just mutated សួរស្តី', + ]; + + expect($testModel->getTranslations('name'))->toEqual($expected); +}); + +it('can set multiple translations on field when a mutator is defined', function () { + $translations = [ + 'nl' => 'hallo', + 'en' => 'hello', + ]; + + $testModel = $this->testModel; + $testModel->field_with_mutator = $translations; + $testModel->save(); + + expect($testModel->getTranslations('field_with_mutator'))->toEqual($translations); +}); + +it('uses the attribute to mutate the translated value', function () { + $testModel = (new class extends TestModel { - $testModel = new class() extends TestModel { - public function getNameAttribute($value) : string - { - return "I just accessed {$value}"; - } - }; + public $mutatedValues = []; + + protected function name(): Attribute + { + return Attribute::get(function ($value) { + $this->mutatedValues[] = $value; + + return 'mutated'; + }); + } + }); - $testModel->setTranslation('name', 'en', 'testValue_en'); + $testModel->name = 'hello'; + $testModel->save(); - $this->assertEquals($testModel->name, 'I just accessed testValue_en'); - } + expect($testModel->name)->toEqual('mutated'); + expect($testModel->mutatedValues)->toBe(['hello']); +}); - /** @test */ - public function it_can_use_mutators_on_translated_attributes() +it('can translate a field based on the translations of another one', function () { + $testModel = (new class extends TestModel { - $testModel = new class() extends TestModel { - public function setNameAttribute($value) - { - $this->attributes['name'] = "I just mutated {$value}"; - } - }; + public function setOtherFieldAttribute($value, $locale = 'en') + { + $this->attributes['other_field'] = $value.' '.$this->getTranslation('name', $locale); + } + }); + + $testModel->setTranslations('name', [ + 'nl' => 'wereld', + 'en' => 'world', + ]); + + $testModel->setTranslations('other_field', [ + 'nl' => 'hallo', + 'en' => 'hello', + ]); + + $testModel->save(); + + $expected = [ + 'nl' => 'hallo wereld', + 'en' => 'hello world', + ]; + + expect($testModel->getTranslations('other_field'))->toEqual($expected); +}); + +it('handle null value from database', function () { + $testModel = (new class extends TestModel + { + public function setAttributesExternally(array $attributes) + { + $this->attributes = $attributes; + } + }); - $testModel->setTranslation('name', 'en', 'testValue_en'); + $testModel->setAttributesExternally(['name' => json_encode(null), 'other_field' => null]); - $this->assertEquals($testModel->name, 'I just mutated testValue_en'); - } + expect($testModel->name)->toEqual(''); + expect($testModel->other_field)->toEqual(''); +}); - /** @test */ - public function it_can_set_translations_for_default_language() - { - $model = TestModel::create([ - 'name' => [ - 'en' => 'testValue_en', - 'fr' => 'testValue_fr', - ], - ]); +it('can get all translations', function () { + $translations = ['nl' => 'hallo', 'en' => 'hello']; - app()->setLocale('en'); + $this->testModel->setTranslations('name', $translations); + $this->testModel->setTranslations('field_with_mutator', $translations); + $this->testModel->save(); - $model->name = 'updated_en'; - $this->assertEquals('updated_en', $model->name); - $this->assertEquals('testValue_fr', $model->getTranslation('name', 'fr')); + $this->assertEquals([ + 'name' => ['nl' => 'hallo', 'en' => 'hello'], + 'other_field' => [], + 'field_with_mutator' => ['nl' => 'hallo', 'en' => 'hello'], + ], $this->testModel->translations); +}); - app()->setLocale('fr'); - $model->name = 'updated_fr'; - $this->assertEquals('updated_fr', $model->name); - $this->assertEquals('updated_en', $model->getTranslation('name', 'en')); - } +it('will return fallback locale translation when getting an empty translation from the locale', function () { + config()->set('app.fallback_locale', 'en'); - /** @test */ - public function it_can_set_multiple_translations_at_once() - { - $translations = ['nl' => 'hallo', 'en' => 'hello', 'kh' => 'សួរស្តី']; + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'nl', null); + $this->testModel->save(); - $this->testModel->setTranslations('name', $translations); - $this->testModel->save(); + expect($this->testModel->getTranslation('name', 'nl'))->toBe('testValue_en'); +}); - $this->assertEquals($translations, $this->testModel->getTranslations('name')); - } +it('will return correct translation value if value is set to zero', function () { + $this->testModel->setTranslation('name', 'nl', '0'); + $this->testModel->save(); - /** @test */ - public function it_can_check_if_an_attribute_is_translatable() - { - $this->assertTrue($this->testModel->isTranslatableAttribute('name')); + expect($this->testModel->getTranslation('name', 'nl'))->toBe('0'); +}); - $this->assertFalse($this->testModel->isTranslatableAttribute('other')); - } +it('will not return fallback value if value is set to zero', function () { + config()->set('app.fallback_locale', 'en'); - /** @test */ - public function it_can_check_if_an_attribute_has_translation() - { - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->setTranslation('name', 'nl', null); - $this->testModel->save(); + $this->testModel->setTranslation('name', 'en', '1'); + $this->testModel->setTranslation('name', 'nl', '0'); + $this->testModel->save(); - $this->assertTrue($this->testModel->hasTranslation('name', 'en')); + expect($this->testModel->getTranslation('name', 'nl'))->toBe('0'); +}); - $this->assertFalse($this->testModel->hasTranslation('name', 'pt')); - } +it('will not remove zero value of other locale in database', function () { + config()->set('app.fallback_locale', 'en'); - /** @test */ - public function it_can_correctly_set_a_field_when_a_mutator_is_defined() - { - $testModel = (new class() extends TestModel { - public function setNameAttribute($value) - { - $this->attributes['name'] = "I just mutated {$value}"; - } - }); + $this->testModel->setTranslation('name', 'nl', '0'); + $this->testModel->setTranslation('name', 'en', '1'); + $this->testModel->save(); - $testModel->name = 'hello'; + expect($this->testModel->getTranslation('name', 'nl'))->toBe('0'); +}); - $expected = ['en' => 'I just mutated hello']; - $this->assertEquals($expected, $testModel->getTranslations('name')); - } +it('can be translated based on given locale', function () { + $value = 'World'; - /** @test */ - public function it_can_set_multiple_translations_when_a_mutator_is_defined() - { - $testModel = (new class() extends TestModel { - public function setNameAttribute($value) - { - $this->attributes['name'] = "I just mutated {$value}"; - } - }); - - $translations = [ - 'nl' => 'hallo', - 'en' => 'hello', - 'kh' => 'សួរស្តី', - ]; - - $testModel->setTranslations('name', $translations); - - $testModel->save(); - - $expected = [ - 'nl' => 'I just mutated hallo', - 'en' => 'I just mutated hello', - 'kh' => 'I just mutated សួរស្តី', - ]; - - $this->assertEquals($expected, $testModel->getTranslations('name')); - } - - /** @test */ - public function it_can_translate_a_field_based_on_the_translations_of_another_one() - { - $testModel = (new class() extends TestModel { - public function setOtherFieldAttribute($value, $locale = 'en') - { - $this->attributes['other_field'] = $value.' '.$this->getTranslation('name', $locale); - } - }); - - $testModel->setTranslations('name', [ - 'nl' => 'wereld', - 'en' => 'world', - ]); + $this->testModel = TestModel::usingLocale('en')->fill([ + 'name' => $value, + ]); + $this->testModel->save(); - $testModel->setTranslations('other_field', [ - 'nl' => 'hallo', - 'en' => 'hello', - ]); + expect($this->testModel->getTranslation('name', 'en'))->toBe($value); +}); - $testModel->save(); +it('can set and fetch attributes based on set locale', function () { + $en = 'World'; + $fr = 'Monde'; - $expected = [ - 'nl' => 'hallo wereld', - 'en' => 'hello world', - ]; + $this->testModel->setLocale('en'); + $this->testModel->name = $en; + $this->testModel->setLocale('fr'); + $this->testModel->name = $fr; - $this->assertEquals($expected, $testModel->getTranslations('other_field')); - } + $this->testModel->save(); - /** @test */ - public function it_handle_null_value_from_database() - { - $testModel = (new class() extends TestModel { - public function setAttributesExternally(array $attributes) - { - $this->attributes = $attributes; - } - }); + $this->testModel->setLocale('en'); + expect($this->testModel->name)->toBe($en); + $this->testModel->setLocale('fr'); + expect($this->testModel->name)->toBe($fr); +}); - $testModel->setAttributesExternally(['name' => json_encode(null), 'other_field' => null]); +it('can replace translations', function () { + $translations = ['nl' => 'hallo', 'en' => 'hello', 'kh' => 'សួរស្តី']; - $this->assertEquals('', $testModel->name); - $this->assertEquals('', $testModel->other_field); - } + $this->testModel->setTranslations('name', $translations); + $this->testModel->save(); - /** @test */ - public function it_can_get_all_translations() - { - $translations = ['nl' => 'hallo', 'en' => 'hello']; + $newTranslations = ['es' => 'hola']; + $this->testModel->replaceTranslations('name', $newTranslations); - $this->testModel->setTranslations('name', $translations); - $this->testModel->save(); + expect($this->testModel->getTranslations('name'))->toEqual($newTranslations); +}); - $this->assertEquals([ - 'name' => ['nl' => 'hallo', 'en' => 'hello'], - 'other_field' => [], - ], $this->testModel->translations); - } +it('can use any locale if given locale not set', function () { + config()->set('app.fallback_locale', 'en'); - /** @test */ - public function it_will_return_fallback_locale_translation_when_getting_an_empty_translation_from_the_locale() - { - $this->app['config']->set('app.fallback_locale', 'en'); + Translatable::fallback( + fallbackAny: true, + ); - $this->testModel->setTranslation('name', 'en', 'testValue_en'); - $this->testModel->setTranslation('name', 'nl', null); - $this->testModel->save(); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->setTranslation('name', 'de', 'testValue_de'); + $this->testModel->save(); - $this->assertSame('testValue_en', $this->testModel->getTranslation('name', 'nl')); - } + $this->testModel->setLocale('it'); + expect($this->testModel->name)->toBe('testValue_fr'); +}); - /** @test */ - public function it_will_return_correct_translation_value_if_value_is_set_to_zero() - { - $this->testModel->setTranslation('name', 'nl', '0'); - $this->testModel->save(); +it('will return set translation when fallback any set', function () { + config()->set('app.fallback_locale', 'en'); + + Translatable::fallback( + fallbackAny: true, + ); + + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->setTranslation('name', 'de', 'testValue_de'); + $this->testModel->save(); + + $this->testModel->setLocale('de'); + expect($this->testModel->name)->toBe('testValue_de'); +}); + +it('will return fallback translation when fallback any set', function () { + config()->set('app.fallback_locale', 'en'); + + Translatable::fallback( + fallbackAny: true, + ); + + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); + + $this->testModel->setLocale('de'); + expect($this->testModel->name)->toBe('testValue_en'); +}); + +it('provides a flog to not return any translation when getting an unknown locale', function () { + config()->set('app.fallback_locale', 'en'); + + Translatable::fallback( + fallbackAny: true, + ); + + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->setTranslation('name', 'de', 'testValue_de'); + $this->testModel->save(); + + $this->testModel->setLocale('it'); + expect($this->testModel->getTranslation('name', 'it', false))->toBe(''); +}); + +it('will return default fallback locale translation when getting an unknown locale with fallback any', function () { + config()->set('app.fallback_locale', 'en'); + + Translatable::fallback( + fallbackAny: true, + ); + + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->save(); + + expect($this->testModel->getTranslation('name', 'fr'))->toBe('testValue_en'); +}); + +it('will return all locales when getting all translations', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->setTranslation('name', 'tr', 'testValue_tr'); + $this->testModel->save(); + + expect($this->testModel->locales())->toEqual([ + 'en', + 'fr', + 'tr', + ]); +}); + +it('queries the database whether a locale exists', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->setTranslation('name', 'tr', 'testValue_tr'); + $this->testModel->save(); + + expect($this->testModel->whereLocale('name', 'en')->get())->toHaveCount(1); + + expect($this->testModel->whereLocale('name', 'de')->get())->toHaveCount(0); +}); + +it('queries the database for multiple locales', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->setTranslation('name', 'tr', 'testValue_tr'); + $this->testModel->save(); + + expect($this->testModel->whereLocales('name', ['en', 'tr'])->get())->toHaveCount(1); + + expect($this->testModel->whereLocales('name', ['de', 'be'])->get())->toHaveCount(0); +}); + +it('queries the database whether a value exists in a locale', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->setTranslation('name', 'tr', 'testValue_tr'); + $this->testModel->save(); + + expect($this->testModel->whereJsonContainsLocale('name', 'en', 'testValue_en')->get())->toHaveCount(1); + + expect($this->testModel->whereJsonContainsLocale('name', 'en', 'test%en', 'like')->get())->toHaveCount(1); + + expect($this->testModel->whereJsonContainsLocale('name', 'en', 'testValue_fr')->get())->toHaveCount(0); +}); + +it('queries the database whether a value exists in a multiple locales', function () { + $this->testModel->setTranslation('name', 'en', 'testValue_en'); + $this->testModel->setTranslation('name', 'fr', 'testValue_fr'); + $this->testModel->setTranslation('name', 'tr', 'testValue_tr'); + $this->testModel->save(); + + expect($this->testModel->whereJsonContainsLocales('name', ['en', 'fr'], 'testValue_en')->get())->toHaveCount(1); + + expect($this->testModel->whereJsonContainsLocales('name', ['en', 'fr'], 'test%en', 'like')->get())->toHaveCount(1); + + expect($this->testModel->whereJsonContainsLocales('name', ['en', 'fr'], 'testValue_tr')->get())->toHaveCount(0); +}); + +it('can disable attribute locale fallback on a per model basis', function () { + config()->set('app.fallback_locale', 'en'); + + $model = new TestModelWithoutFallback; + + $model->setTranslation('name', 'en', 'testValue_en'); + $model->save(); + + $model->setLocale('fr'); + + expect($model->name)->toBe(''); +}); + +it('can set fallback locale on model', function () { + config()->set('app.fallback_locale', 'en'); + + $model = new TestModelWithFallbackLocale; + + TestModelWithFallbackLocale::$fallbackLocale = 'fr'; + + $model->setTranslation('name', 'fr', 'testValue_fr'); + $model->setTranslation('name', 'en', 'testValue_en'); + $model->save(); - $this->assertSame('0', $this->testModel->getTranslation('name', 'nl')); - } + $model->setLocale('nl'); - /** @test */ - public function it_will_not_return_fallback_value_if_value_is_set_to_zero() + expect($model->name)->toBe('testValue_fr'); +}); + +it('translations macro meets expectations', function (mixed $expected, string|array $locales, mixed $value) { + expect(Factory::translations($locales, $value))->toEqual($expected); +})->with([ + [['en' => 'english'], 'en', 'english'], + [['en' => 'english', 'nl' => 'english'], ['en', 'nl'], 'english'], + [['en' => 'english', 'nl' => 'dutch'], ['en', 'nl'], ['english', 'dutch']], +]); + +it('should return null when the underlying attribute in database is null', function () { + // we need to remove the name attribute from the translatable array + // and add it back to make sure the name + // attribute is holding `null` raw value + $this->testModel->translatable = array_filter($this->testModel->translatable, fn ($attribute) => $attribute !== 'name'); + $this->testModel->name = null; + $this->testModel->translatable = array_merge($this->testModel->translatable, ['name']); + + $translation = $this->testModel->getTranslation('name', 'en'); + + expect($translation)->toBeNull(); +}); + +it('should return locales with empty string translations when allowEmptyStringForTranslation is true', function () { + Translatable::allowEmptyStringForTranslation(); + + $this->testModel->setTranslation('name', 'en', ''); + + $translations = $this->testModel->getTranslations('name'); + + expect($translations)->toEqual(['en' => '']); +}); + +it('should not return locales with empty string translations when allowEmptyStringForTranslation is false', function () { + Translatable::allowEmptyStringForTranslation(false); + + $this->testModel->setTranslation('name', 'en', ''); + + $translations = $this->testModel->getTranslations('name'); + + expect($translations)->toEqual([]); +}); + +it('should return locales with null translations when allowNullForTranslation is true', function () { + Translatable::allowNullForTranslation(); + + $this->testModel->setTranslation('name', 'en', null); + + $translations = $this->testModel->getTranslations('name'); + + expect($translations)->toEqual(['en' => null]); +}); + +it('should not return locales with null translations when allowNullForTranslation is false', function () { + Translatable::allowNullForTranslation(false); + + $this->testModel->setTranslation('name', 'en', null); + + $translations = $this->testModel->getTranslations('name'); + + expect($translations)->toEqual([]); +}); + +it('can set an array list as value for translation using `setTranslation`', function () { + $this->testModel->setTranslation('name', 'en', ['testValue_en']); + $this->testModel->save(); + + expect($this->testModel->getTranslation('name', 'en'))->toEqual(['testValue_en']); +}); + +it('can set an array list as value for translation using default local', function () { + $this->testModel->name = ['testValue_en']; + $this->testModel->save(); + + expect($this->testModel->getTranslation('name', 'en'))->toEqual(['testValue_en']); +}); + +it('can treat an empty array as value for clearing translations', function () { + $this->testModel->name = []; + $this->testModel->save(); + + expect($this->testModel->getTranslations('name'))->toEqual([]); +}); + +it('can set and retrieve translations for nested fields', function () { + $testModel = new class extends TestModel { - $this->app['config']->set('app.fallback_locale', 'en'); + public $translatable = ['nested->field', 'nested->deep->field']; + }; + + $nestedFieldKey = 'nested->field'; + $nestedDeepFieldKey = 'nested->deep->field'; + + $testModel = $testModel::create([ + $nestedFieldKey => ['ar' => 'nestedFieldKey_ar'], + ]); + + app()->setLocale('nl'); + $testModel->$nestedFieldKey = 'nestedFieldKey_nl'; + + $testModel->setTranslation($nestedFieldKey, 'en', 'nestedFieldKey_en'); + + $testModel->setTranslations($nestedDeepFieldKey, [ + 'ar' => 'nestedDeepFieldKey_ar', + 'en' => 'nestedDeepFieldKey_en', + ]); + + $testModel->save(); + + expect($testModel->getTranslations()) + ->toEqual([ + $nestedFieldKey => [ + 'ar' => 'nestedFieldKey_ar', + 'nl' => 'nestedFieldKey_nl', + 'en' => 'nestedFieldKey_en', + ], + $nestedDeepFieldKey => [ + 'ar' => 'nestedDeepFieldKey_ar', + 'en' => 'nestedDeepFieldKey_en', + ], + ]); + + expect($testModel->getTranslations($nestedDeepFieldKey)) + ->toEqual([ + 'ar' => 'nestedDeepFieldKey_ar', + 'en' => 'nestedDeepFieldKey_en', + ]); - $this->testModel->setTranslation('name', 'en', '1'); - $this->testModel->setTranslation('name', 'nl', '0'); - $this->testModel->save(); + // fallback en used here while no nl lang in this field + expect($testModel->$nestedDeepFieldKey) + ->toEqual('nestedDeepFieldKey_en'); - $this->assertSame('0', $this->testModel->getTranslation('name', 'nl')); - } + app()->setLocale('ar'); + expect($testModel->$nestedFieldKey)->toBe('nestedFieldKey_ar'); + expect($testModel->getTranslation($nestedDeepFieldKey, 'en'))->toBe('nestedDeepFieldKey_en'); +}); - /** @test */ - public function it_will_not_remove_zero_value_of_other_locale_in_database() +it('uses mutators for setting and getting translated values of nested fields', function () { + $testModel = new class extends TestModel { - $this->app['config']->set('app.fallback_locale', 'en'); + public $translatable = ['nested->field', 'nested->deep->field']; + + public function setNestedFieldAttribute($value) + { + $this->attributes['nested->field'] = strtolower($value); + } + + public function getNestedFieldAttribute($value) + { + return ucfirst($value); + } + + protected function nestedDeepField(): Attribute + { + return new Attribute( + get: fn (string $value) => ucfirst($value), + set: fn (string $value) => strtolower($value), + ); + } + }; + + $nestedFieldKey = 'nested->field'; + $nestedDeepFieldKey = 'nested->deep->field'; + + app()->setLocale('ar'); + $testModel->$nestedFieldKey = 'NESTED FIELD AR'; + $testModel->$nestedDeepFieldKey = 'NESTED DEEP FIELD AR'; + $testModel->save(); + + expect($testModel->$nestedFieldKey) + ->toEqual('Nested field ar'); + + expect($testModel->$nestedDeepFieldKey) + ->toEqual('Nested deep field ar'); +}); + +it('should return null when translation is null and allowNullForTranslation is true', function () { + Translatable::allowNullForTranslation(true); + + $this->testModel->setTranslation('name', 'en', null); + + $translation = $this->testModel->getTranslation('name', 'en'); + + expect($translation)->toBeNull(); +}); + +it('should return empty string when translation is null and allowNullForTranslation is false', function () { + Translatable::allowNullForTranslation(false); + + $this->testModel->setTranslation('name', 'en', null); - $this->testModel->setTranslation('name', 'nl', '0'); - $this->testModel->setTranslation('name', 'en', '1'); - $this->testModel->save(); + $translation = $this->testModel->getTranslation('name', 'en'); - $this->assertSame('0', $this->testModel->getTranslation('name', 'nl')); - } -} + expect($translation)->toBe(''); +});