diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index a548779..737b3a5 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -22,7 +22,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Run Laravel Pint uses: aglipanci/laravel-pint-action@latest diff --git a/.github/workflows/test-runner.yml b/.github/workflows/test-runner.yml index 50dfb2c..2dbc09f 100644 --- a/.github/workflows/test-runner.yml +++ b/.github/workflows/test-runner.yml @@ -67,7 +67,7 @@ jobs: - name: Install dependencies run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "filament/support" --no-interaction --no-update + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "filament/support:^3.3" --no-interaction --no-update composer update --${{ matrix.dependency-version }} --prefer-dist --no-progress --no-interaction - name: Run test suite diff --git a/composer.json b/composer.json index e866f8c..f047f36 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ "php": "^8.2", "bezhansalleh/filament-shield": "^3.3", "datalinx/php-utils": "^2.5", + "eclipsephp/common": "dev-main", "filament/filament": "^3.3", "laravel/framework": "^11.0|^12.0", "spatie/laravel-package-tools": "^1.19" diff --git a/database/factories/CountryFactory.php b/database/factories/CountryFactory.php index 3155a1c..e03800e 100644 --- a/database/factories/CountryFactory.php +++ b/database/factories/CountryFactory.php @@ -27,6 +27,7 @@ public function definition(): array 'num_code' => str_pad(fake()->numberBetween(1, 999), 3, '0', STR_PAD_LEFT), 'name' => fake()->country(), 'flag' => fake()->emoji(), + 'region_id' => null, ]; } } diff --git a/database/factories/RegionFactory.php b/database/factories/RegionFactory.php new file mode 100644 index 0000000..2dc0007 --- /dev/null +++ b/database/factories/RegionFactory.php @@ -0,0 +1,60 @@ + + */ + public function definition(): array + { + return [ + 'code' => strtoupper($this->faker->unique()->lexify('???')), + 'name' => $this->faker->unique()->words(2, true), + 'is_special' => false, + ]; + } + + /** + * Set the region as special. + * + * @return $this + */ + public function special(): static + { + return $this->state(['is_special' => true]); + } + + /** + * Set the region as geographical. + * + * @return $this + */ + public function geographical(): static + { + return $this->state(['is_special' => false]); + } + + /** + * Set the region as a child of a parent region. + * + * @return $this + */ + public function withParent(Region $parent): static + { + return $this->state(['parent_id' => $parent->id]); + } +} diff --git a/database/migrations/2025_07_26_120000_create_regions_table.php b/database/migrations/2025_07_26_120000_create_regions_table.php new file mode 100644 index 0000000..e94705a --- /dev/null +++ b/database/migrations/2025_07_26_120000_create_regions_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('code')->nullable()->unique(); + $table->unsignedBigInteger('parent_id')->nullable(); + $table->boolean('is_special')->default(false); + $table->string('name'); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('parent_id')->references('id')->on('world_regions')->onDelete('cascade'); + $table->index(['is_special', 'parent_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('world_regions'); + } +}; diff --git a/database/migrations/2025_07_26_120001_add_region_id_to_countries_table.php b/database/migrations/2025_07_26_120001_add_region_id_to_countries_table.php new file mode 100644 index 0000000..8051d38 --- /dev/null +++ b/database/migrations/2025_07_26_120001_add_region_id_to_countries_table.php @@ -0,0 +1,26 @@ +unsignedBigInteger('region_id')->nullable()->after('flag'); + $table->foreign('region_id')->references('id')->on('world_regions')->onDelete('set null'); + $table->index('region_id'); + }); + } + + public function down(): void + { + Schema::table('world_countries', function (Blueprint $table) { + $table->dropForeign(['region_id']); + $table->dropIndex(['region_id']); + $table->dropColumn('region_id'); + }); + } +}; diff --git a/database/migrations/2025_07_26_120002_create_country_in_special_region_table.php b/database/migrations/2025_07_26_120002_create_country_in_special_region_table.php new file mode 100644 index 0000000..a411eaa --- /dev/null +++ b/database/migrations/2025_07_26_120002_create_country_in_special_region_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('country_id', 2); + $table->unsignedBigInteger('region_id'); + $table->date('start_date'); + $table->date('end_date')->nullable(); + $table->timestamps(); + + $table->foreign('country_id')->references('id')->on('world_countries')->onDelete('cascade'); + $table->foreign('region_id')->references('id')->on('world_regions')->onDelete('cascade'); + + $table->unique(['country_id', 'region_id', 'start_date'], 'unique_country_region_start'); + $table->index(['region_id', 'start_date', 'end_date'], 'idx_region_dates'); + }); + } + + public function down(): void + { + Schema::dropIfExists('world_country_in_special_region'); + } +}; diff --git a/resources/lang/en/countries.php b/resources/lang/en/countries.php index 15ca88f..4ac9fe1 100644 --- a/resources/lang/en/countries.php +++ b/resources/lang/en/countries.php @@ -22,6 +22,12 @@ 'num_code' => [ 'label' => 'Num. Code', ], + 'region' => [ + 'label' => 'Region', + ], + 'special_regions' => [ + 'label' => 'Special Regions', + ], ], 'actions' => [ @@ -67,6 +73,18 @@ 'label' => 'Numeric code', 'helper' => 'Numeric code (ISO-3166)', ], + 'region' => [ + 'label' => 'Geographical Region', + 'helper' => 'The geographical region this country belongs to', + ], + 'special_regions' => [ + 'label' => 'Special Regions', + 'helper' => 'Special regions this country belongs to (e.g., European Union)', + 'add_button' => 'Add Special Region', + 'region_label' => 'Region', + 'start_date_label' => 'Start Date', + 'end_date_label' => 'End Date', + ], ], 'import' => [ @@ -86,4 +104,18 @@ 'title' => 'Countries Import failed', ], ], + + 'filters' => [ + 'geographical_region' => [ + 'label' => 'Geographical Region', + ], + 'special_region' => [ + 'label' => 'Special Region', + ], + ], + + 'validation' => [ + 'duplicate_special_region_membership' => 'This country is already a member of :region.', + 'unknown_region' => 'this region', + ], ]; diff --git a/resources/lang/en/regions.php b/resources/lang/en/regions.php new file mode 100644 index 0000000..bfd42e2 --- /dev/null +++ b/resources/lang/en/regions.php @@ -0,0 +1,81 @@ + 'Regions', + 'breadcrumb' => 'Regions', + 'plural' => 'Regions', + + 'form' => [ + 'name' => [ + 'label' => 'Name', + ], + 'code' => [ + 'label' => 'Code', + 'helper' => 'Optional unique code for the region (e.g., EU, ASEAN)', + ], + 'parent' => [ + 'label' => 'Parent Region', + 'helper' => 'Select a parent region to create a sub-region', + ], + 'is_special' => [ + 'label' => 'Special Region', + 'helper' => 'Special regions are custom regions like EU or EEA that countries can join/leave', + ], + ], + + 'table' => [ + 'name' => [ + 'label' => 'Name', + ], + 'code' => [ + 'label' => 'Code', + ], + 'parent' => [ + 'label' => 'Parent Region', + ], + 'is_special' => [ + 'label' => 'Special', + ], + 'countries_count' => [ + 'label' => 'Countries', + ], + 'children_count' => [ + 'label' => 'Sub-regions', + ], + ], + + 'filters' => [ + 'type' => [ + 'label' => 'Type', + 'geographical' => 'Geographical', + 'special' => 'Special', + ], + 'parent' => [ + 'label' => 'Parent Region', + ], + ], + + 'actions' => [ + 'create' => [ + 'label' => 'New Region', + 'heading' => 'Create Region', + ], + 'edit' => [ + 'label' => 'Edit', + 'heading' => 'Edit Region', + ], + 'delete' => [ + 'label' => 'Delete', + 'heading' => 'Delete Region', + ], + 'restore' => [ + 'label' => 'Restore', + 'heading' => 'Restore Region', + ], + 'force_delete' => [ + 'label' => 'Force Delete', + 'heading' => 'Force Delete Region', + 'description' => 'Are you sure you want to permanently delete ":name"? This action cannot be undone.', + ], + ], +]; diff --git a/resources/lang/hr/countries.php b/resources/lang/hr/countries.php index 01dace4..79ee803 100644 --- a/resources/lang/hr/countries.php +++ b/resources/lang/hr/countries.php @@ -22,6 +22,12 @@ 'num_code' => [ 'label' => 'Brojčana šifra', ], + 'region' => [ + 'label' => 'Regija', + ], + 'special_regions' => [ + 'label' => 'Posebne regije', + ], ], 'actions' => [ @@ -67,6 +73,18 @@ 'label' => 'Brojčana šifra', 'helper' => 'Numerička oznaka (ISO-3166)', ], + 'region' => [ + 'label' => 'Geografska regija', + 'helper' => 'Geografska regija kojoj ova zemlja pripada', + ], + 'special_regions' => [ + 'label' => 'Posebne regije', + 'helper' => 'Posebne regije kojima ova zemlja pripada (npr. Europska unija)', + 'add_button' => 'Dodaj posebnu regiju', + 'region_label' => 'Regija', + 'start_date_label' => 'Datum početka', + 'end_date_label' => 'Datum završetka', + ], ], 'import' => [ @@ -86,4 +104,18 @@ 'title' => 'Uvoz država neuspješan', ], ], + + 'filters' => [ + 'geographical_region' => [ + 'label' => 'Geografska regija', + ], + 'special_region' => [ + 'label' => 'Posebna regija', + ], + ], + + 'validation' => [ + 'duplicate_special_region_membership' => 'Ova država je već član regije :region.', + 'unknown_region' => 'ove regije', + ], ]; diff --git a/resources/lang/hr/regions.php b/resources/lang/hr/regions.php new file mode 100644 index 0000000..b231faf --- /dev/null +++ b/resources/lang/hr/regions.php @@ -0,0 +1,81 @@ + 'Regije', + 'breadcrumb' => 'Regije', + 'plural' => 'Regije', + + 'form' => [ + 'name' => [ + 'label' => 'Naziv', + ], + 'code' => [ + 'label' => 'Kod', + 'helper' => 'Opcionalni jedinstveni kod za regiju (npr. EU, ASEAN)', + ], + 'parent' => [ + 'label' => 'Nadređena regija', + 'helper' => 'Odaberite nadređenu regiju za stvaranje podregije', + ], + 'is_special' => [ + 'label' => 'Posebna regija', + 'helper' => 'Posebne regije su prilagođene regije poput EU ili EEA kojima se zemlje mogu pridružiti/napustiti', + ], + ], + + 'table' => [ + 'name' => [ + 'label' => 'Naziv', + ], + 'code' => [ + 'label' => 'Kod', + ], + 'parent' => [ + 'label' => 'Nadređena regija', + ], + 'is_special' => [ + 'label' => 'Posebna', + ], + 'countries_count' => [ + 'label' => 'Zemlje', + ], + 'children_count' => [ + 'label' => 'Podregije', + ], + ], + + 'filters' => [ + 'type' => [ + 'label' => 'Tip', + 'geographical' => 'Geografska', + 'special' => 'Posebna', + ], + 'parent' => [ + 'label' => 'Nadređena regija', + ], + ], + + 'actions' => [ + 'create' => [ + 'label' => 'Nova regija', + 'heading' => 'Stvori regiju', + ], + 'edit' => [ + 'label' => 'Uredi', + 'heading' => 'Uredi regiju', + ], + 'delete' => [ + 'label' => 'Obriši', + 'heading' => 'Obriši regiju', + ], + 'restore' => [ + 'label' => 'Vrati', + 'heading' => 'Vrati regiju', + ], + 'force_delete' => [ + 'label' => 'Trajno obriši', + 'heading' => 'Trajno obriši regiju', + 'description' => 'Jeste li sigurni da želite trajno obrisati ":name"? Ova radnja se ne može poništiti.', + ], + ], +]; diff --git a/resources/lang/sl/countries.php b/resources/lang/sl/countries.php index 927fee8..02f0880 100644 --- a/resources/lang/sl/countries.php +++ b/resources/lang/sl/countries.php @@ -22,6 +22,12 @@ 'num_code' => [ 'label' => 'Num. šifra', ], + 'region' => [ + 'label' => 'Regija', + ], + 'special_regions' => [ + 'label' => 'Posebne regije', + ], ], 'actions' => [ @@ -67,6 +73,18 @@ 'label' => 'Num. šifra', 'helper' => 'Numerična šifra (ISO-3166)', ], + 'region' => [ + 'label' => 'Geografska regija', + 'helper' => 'Geografska regija, ki ji ta država pripada', + ], + 'special_regions' => [ + 'label' => 'Posebne regije', + 'helper' => 'Posebne regije, ki jim ta država pripada (npr. Evropska unija)', + 'add_button' => 'Dodaj posebno regijo', + 'region_label' => 'Regija', + 'start_date_label' => 'Datum začetka', + 'end_date_label' => 'Datum konca', + ], ], 'import' => [ @@ -86,4 +104,18 @@ 'title' => 'Uvoz držav neuspešen', ], ], + + 'filters' => [ + 'geographical_region' => [ + 'label' => 'Geografska regija', + ], + 'special_region' => [ + 'label' => 'Posebna regija', + ], + ], + + 'validation' => [ + 'duplicate_special_region_membership' => 'Ta država je že članica regije :region.', + 'unknown_region' => 'te regije', + ], ]; diff --git a/resources/lang/sl/regions.php b/resources/lang/sl/regions.php new file mode 100644 index 0000000..3bbe8c5 --- /dev/null +++ b/resources/lang/sl/regions.php @@ -0,0 +1,81 @@ + 'Regije', + 'breadcrumb' => 'Regije', + 'plural' => 'Regije', + + 'form' => [ + 'name' => [ + 'label' => 'Ime', + ], + 'code' => [ + 'label' => 'Koda', + 'helper' => 'Neobvezna edinstvena koda za regijo (npr. EU, ASEAN)', + ], + 'parent' => [ + 'label' => 'Nadrejena regija', + 'helper' => 'Izberite nadrejeno regijo za ustvarjanje podregije', + ], + 'is_special' => [ + 'label' => 'Posebna regija', + 'helper' => 'Posebne regije so prilagojene regije kot EU ali EEA, katerim se lahko države pridružijo/zapustijo', + ], + ], + + 'table' => [ + 'name' => [ + 'label' => 'Ime', + ], + 'code' => [ + 'label' => 'Koda', + ], + 'parent' => [ + 'label' => 'Nadrejena regija', + ], + 'is_special' => [ + 'label' => 'Posebna', + ], + 'countries_count' => [ + 'label' => 'Države', + ], + 'children_count' => [ + 'label' => 'Podregije', + ], + ], + + 'filters' => [ + 'type' => [ + 'label' => 'Tip', + 'geographical' => 'Geografska', + 'special' => 'Posebna', + ], + 'parent' => [ + 'label' => 'Nadrejena regija', + ], + ], + + 'actions' => [ + 'create' => [ + 'label' => 'Nova regija', + 'heading' => 'Ustvari regijo', + ], + 'edit' => [ + 'label' => 'Uredi', + 'heading' => 'Uredi regijo', + ], + 'delete' => [ + 'label' => 'Izbriši', + 'heading' => 'Izbriši regijo', + ], + 'restore' => [ + 'label' => 'Obnovi', + 'heading' => 'Obnovi regijo', + ], + 'force_delete' => [ + 'label' => 'Trajno izbriši', + 'heading' => 'Trajno izbriši regijo', + 'description' => 'Ali ste prepričani, da želite trajno izbrisati ":name"? Tega dejanja ni mogoče razveljaviti.', + ], + ], +]; diff --git a/resources/lang/sr/countries.php b/resources/lang/sr/countries.php index 1578cc5..bb21da5 100644 --- a/resources/lang/sr/countries.php +++ b/resources/lang/sr/countries.php @@ -22,6 +22,12 @@ 'num_code' => [ 'label' => 'Brojčana šifra', ], + 'region' => [ + 'label' => 'Regija', + ], + 'special_regions' => [ + 'label' => 'Posebne regije', + ], ], 'actions' => [ @@ -67,6 +73,18 @@ 'label' => 'Brojčana šifra', 'helper' => 'Numerička oznaka (ISO-3166)', ], + 'region' => [ + 'label' => 'Geografska regija', + 'helper' => 'Geografska regija kojoj ova zemlja pripada', + ], + 'special_regions' => [ + 'label' => 'Posebne regije', + 'helper' => 'Posebne regije kojima ova zemlja pripada (npr. Evropska unija)', + 'add_button' => 'Dodaj posebnu regiju', + 'region_label' => 'Regija', + 'start_date_label' => 'Datum početka', + 'end_date_label' => 'Datum završetka', + ], ], 'import' => [ @@ -86,4 +104,18 @@ 'title' => 'Uvoz država neuspešan', ], ], + + 'filters' => [ + 'geographical_region' => [ + 'label' => 'Geografska regija', + ], + 'special_region' => [ + 'label' => 'Posebna regija', + ], + ], + + 'validation' => [ + 'duplicate_special_region_membership' => 'Ova država je već član regije :region.', + 'unknown_region' => 'ove regije', + ], ]; diff --git a/resources/lang/sr/regions.php b/resources/lang/sr/regions.php new file mode 100644 index 0000000..da8e082 --- /dev/null +++ b/resources/lang/sr/regions.php @@ -0,0 +1,81 @@ + 'Regije', + 'breadcrumb' => 'Regije', + 'plural' => 'Regije', + + 'form' => [ + 'name' => [ + 'label' => 'Naziv', + ], + 'code' => [ + 'label' => 'Kod', + 'helper' => 'Opcioni jedinstveni kod za regiju (npr. EU, ASEAN)', + ], + 'parent' => [ + 'label' => 'Nadređena regija', + 'helper' => 'Izaberite nadređenu regiju za stvaranje podregije', + ], + 'is_special' => [ + 'label' => 'Posebna regija', + 'helper' => 'Posebne regije su prilagođene regije poput EU ili EEA kojima se zemlje mogu pridružiti/napustiti', + ], + ], + + 'table' => [ + 'name' => [ + 'label' => 'Naziv', + ], + 'code' => [ + 'label' => 'Kod', + ], + 'parent' => [ + 'label' => 'Nadređena regija', + ], + 'is_special' => [ + 'label' => 'Posebna', + ], + 'countries_count' => [ + 'label' => 'Zemlje', + ], + 'children_count' => [ + 'label' => 'Podregije', + ], + ], + + 'filters' => [ + 'type' => [ + 'label' => 'Tip', + 'geographical' => 'Geografska', + 'special' => 'Posebna', + ], + 'parent' => [ + 'label' => 'Nadređena regija', + ], + ], + + 'actions' => [ + 'create' => [ + 'label' => 'Nova regija', + 'heading' => 'Stvori regiju', + ], + 'edit' => [ + 'label' => 'Uredi', + 'heading' => 'Uredi regiju', + ], + 'delete' => [ + 'label' => 'Obriši', + 'heading' => 'Obriši regiju', + ], + 'restore' => [ + 'label' => 'Vrati', + 'heading' => 'Vrati regiju', + ], + 'force_delete' => [ + 'label' => 'Trajno obriši', + 'heading' => 'Trajno obriši regiju', + 'description' => 'Jeste li sigurni da želite trajno obrisati ":name"? Ova radnja se ne može poništiti.', + ], + ], +]; diff --git a/src/Filament/Clusters/World/Resources/CountryResource.php b/src/Filament/Clusters/World/Resources/CountryResource.php index f1c01fd..15c64f2 100644 --- a/src/Filament/Clusters/World/Resources/CountryResource.php +++ b/src/Filament/Clusters/World/Resources/CountryResource.php @@ -6,6 +6,9 @@ use Eclipse\World\Filament\Clusters\World; use Eclipse\World\Filament\Clusters\World\Resources\CountryResource\Pages; use Eclipse\World\Models\Country; +use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Resources\Resource; @@ -19,6 +22,7 @@ use Filament\Tables\Actions\RestoreAction; use Filament\Tables\Actions\RestoreBulkAction; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; @@ -70,6 +74,69 @@ public static function form(Form $form): Form ->length(3) ->label(__('eclipse-world::countries.form.num_code.label')) ->helperText(__('eclipse-world::countries.form.num_code.helper')), + + Select::make('region_id') + ->label(__('eclipse-world::countries.form.region.label')) + ->relationship('region', 'name', fn ($query) => $query->where('is_special', false)) + ->searchable() + ->preload() + ->helperText(__('eclipse-world::countries.form.region.helper')), + + Repeater::make('countryInSpecialRegions') + ->relationship() + ->label(__('eclipse-world::countries.form.special_regions.label')) + ->columns(3) + ->columnSpan(2) + ->createItemButtonLabel(__('eclipse-world::countries.form.special_regions.add_button')) + ->defaultItems(0) + ->minItems(0) + ->schema([ + Select::make('region_id') + ->relationship('region', 'name', fn ($query) => $query->where('is_special', true)) + ->searchable() + ->preload() + ->label(__('eclipse-world::countries.form.special_regions.region_label')) + ->rules([ + function ($get) { + return function (string $attribute, $value, \Closure $fail) use ($get) { + if (! $value) { + return; + } + + $countryId = $get('../../id'); + $currentRecordId = $get('id'); + + if (! $countryId) { + return; + } + + // Check for any existing membership with same country and region + $query = \Eclipse\World\Models\CountryInSpecialRegion::where('country_id', $countryId) + ->where('region_id', $value); + + // Exclude current record when editing + if ($currentRecordId) { + $query->where('id', '!=', $currentRecordId); + } + + if ($query->exists()) { + $regionName = \Eclipse\World\Models\Region::find($value)?->name ?? __('eclipse-world::countries.validation.unknown_region'); + $fail(__('eclipse-world::countries.validation.duplicate_special_region_membership', [ + 'region' => $regionName, + ])); + } + }; + }, + ]), + + DatePicker::make('start_date') + ->required() + ->label(__('eclipse-world::countries.form.special_regions.start_date_label')), + + DatePicker::make('end_date') + ->nullable() + ->label(__('eclipse-world::countries.form.special_regions.end_date_label')), + ]), ]); } @@ -106,8 +173,48 @@ public static function table(Table $table): Table ->searchable() ->sortable() ->width(100), + + TextColumn::make('region.name') + ->label(__('eclipse-world::countries.table.region.label')) + ->searchable() + ->sortable() + ->placeholder('—'), + + TextColumn::make('special_regions') + ->label(__('eclipse-world::countries.table.special_regions.label')) + ->getStateUsing(fn ($record) => $record->getSpecialRegionsAt()->pluck('name')->join(', ')) + ->placeholder('—') + ->wrap(), ]) ->filters([ + SelectFilter::make('region_id') + ->label(__('eclipse-world::countries.filters.geographical_region.label')) + ->relationship('region', 'name', fn ($query) => $query->where('is_special', false)) + ->searchable() + ->preload(), + SelectFilter::make('special_regions') + ->label(__('eclipse-world::countries.filters.special_region.label')) + ->options(function () { + return \Eclipse\World\Models\Region::where('is_special', true) + ->pluck('name', 'id') + ->toArray(); + }) + ->query(function (Builder $query, array $data): Builder { + if (! empty($data['value'])) { + return $query->whereHas('specialRegions', function (Builder $query) use ($data) { + $query->where('world_regions.id', $data['value']) + ->where('world_country_in_special_region.start_date', '<=', now()) + ->where(function ($query) { + $query->whereNull('world_country_in_special_region.end_date') + ->orWhere('world_country_in_special_region.end_date', '>=', now()); + }); + }); + } + + return $query; + }) + ->searchable() + ->preload(), TrashedFilter::make(), ]) ->actions([ diff --git a/src/Filament/Clusters/World/Resources/RegionResource.php b/src/Filament/Clusters/World/Resources/RegionResource.php new file mode 100644 index 0000000..901972b --- /dev/null +++ b/src/Filament/Clusters/World/Resources/RegionResource.php @@ -0,0 +1,214 @@ +schema([ + TextInput::make('name') + ->label(__('eclipse-world::regions.form.name.label')) + ->required() + ->maxLength(255), + + TextInput::make('code') + ->label(__('eclipse-world::regions.form.code.label')) + ->maxLength(10) + ->unique(table: Region::class, ignoreRecord: true) + ->helperText(__('eclipse-world::regions.form.code.helper')), + + Select::make('parent_id') + ->label(__('eclipse-world::regions.form.parent.label')) + ->relationship('parent', 'name') + ->searchable() + ->preload() + ->helperText(__('eclipse-world::regions.form.parent.helper')), + + Checkbox::make('is_special') + ->label(__('eclipse-world::regions.form.is_special.label')) + ->helperText(__('eclipse-world::regions.form.is_special.helper')), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultPaginationPageOption(50) + ->defaultSort('name') + ->striped() + ->columns([ + TextColumn::make('name') + ->label(__('eclipse-world::regions.table.name.label')) + ->searchable() + ->sortable(), + + TextColumn::make('code') + ->label(__('eclipse-world::regions.table.code.label')) + ->searchable() + ->sortable() + ->placeholder('—'), + + TextColumn::make('parent.name') + ->label(__('eclipse-world::regions.table.parent.label')) + ->searchable() + ->sortable() + ->placeholder('—'), + + IconColumn::make('is_special') + ->label(__('eclipse-world::regions.table.is_special.label')) + ->boolean() + ->sortable(), + + TextColumn::make('countries_count') + ->label(__('eclipse-world::regions.table.countries_count.label')) + ->getStateUsing(function (Region $record): int { + if ($record->is_special) { + return $record->getCountriesInSpecialRegion()->count(); + } + + return $record->countries()->count(); + }) + ->sortable(query: function (Builder $query, string $direction): Builder { + return $query->withCount([ + 'countries', + 'specialCountries' => function ($query) { + $query->where('world_country_in_special_region.start_date', '<=', now()) + ->where(function ($query) { + $query->whereNull('world_country_in_special_region.end_date') + ->orWhere('world_country_in_special_region.end_date', '>=', now()); + }); + }, + ])->orderBy('countries_count', $direction); + }), + + TextColumn::make('children_count') + ->label(__('eclipse-world::regions.table.children_count.label')) + ->counts('children') + ->sortable(), + ]) + ->filters([ + SelectFilter::make('is_special') + ->label(__('eclipse-world::regions.filters.type.label')) + ->options([ + 0 => __('eclipse-world::regions.filters.type.geographical'), + 1 => __('eclipse-world::regions.filters.type.special'), + ]), + SelectFilter::make('parent_id') + ->label(__('eclipse-world::regions.filters.parent.label')) + ->relationship('parent', 'name') + ->searchable() + ->preload(), + TrashedFilter::make(), + ]) + ->actions([ + EditAction::make() + ->label(__('eclipse-world::regions.actions.edit.label')) + ->modalHeading(__('eclipse-world::regions.actions.edit.heading')), + ActionGroup::make([ + DeleteAction::make() + ->label(__('eclipse-world::regions.actions.delete.label')) + ->modalHeading(__('eclipse-world::regions.actions.delete.heading')), + RestoreAction::make() + ->label(__('eclipse-world::regions.actions.restore.label')) + ->modalHeading(__('eclipse-world::regions.actions.restore.heading')), + ForceDeleteAction::make() + ->label(__('eclipse-world::regions.actions.force_delete.label')) + ->modalHeading(__('eclipse-world::regions.actions.force_delete.heading')) + ->modalDescription(fn (Region $record): string => __('eclipse-world::regions.actions.force_delete.description', [ + 'name' => $record->name, + ])), + ]), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make() + ->label(__('eclipse-world::regions.actions.delete.label')), + RestoreBulkAction::make() + ->label(__('eclipse-world::regions.actions.restore.label')), + ForceDeleteBulkAction::make() + ->label(__('eclipse-world::regions.actions.force_delete.label')), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListRegions::route('/'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } + + public static function getPermissionPrefixes(): array + { + return [ + 'view_any', + 'create', + 'update', + 'restore', + 'restore_any', + 'delete', + 'delete_any', + 'force_delete', + 'force_delete_any', + ]; + } + + public static function getNavigationLabel(): string + { + return __('eclipse-world::regions.nav_label'); + } + + public static function getBreadcrumb(): string + { + return __('eclipse-world::regions.breadcrumb'); + } + + public static function getPluralModelLabel(): string + { + return __('eclipse-world::regions.plural'); + } +} diff --git a/src/Filament/Clusters/World/Resources/RegionResource/Pages/ListRegions.php b/src/Filament/Clusters/World/Resources/RegionResource/Pages/ListRegions.php new file mode 100644 index 0000000..5be01a4 --- /dev/null +++ b/src/Filament/Clusters/World/Resources/RegionResource/Pages/ListRegions.php @@ -0,0 +1,21 @@ +label(__('eclipse-world::regions.actions.create.label')) + ->modalHeading(__('eclipse-world::regions.actions.create.heading')), + ]; + } +} diff --git a/src/Jobs/ImportCountries.php b/src/Jobs/ImportCountries.php index 61c2ec2..1868b61 100644 --- a/src/Jobs/ImportCountries.php +++ b/src/Jobs/ImportCountries.php @@ -4,7 +4,9 @@ use Eclipse\Common\Foundation\Jobs\QueueableJob; use Eclipse\World\Models\Country; +use Eclipse\World\Models\Region; use Exception; +use Illuminate\Support\Carbon; class ImportCountries extends QueueableJob { @@ -14,6 +16,9 @@ class ImportCountries extends QueueableJob protected function execute(): void { + // First, import/update regions + $this->importRegions(); + // Load existing countries into an associative array $existingCountries = Country::withTrashed()->get()->keyBy('id'); @@ -35,6 +40,7 @@ protected function execute(): void 'num_code' => $rawData['ccn3'], 'name' => $rawData['name']['common'], 'flag' => $rawData['flag'], + 'region_id' => $this->getRegionIdForCountry($rawData), ]; if (isset($existingCountries[$data['id']])) { @@ -43,6 +49,9 @@ protected function execute(): void Country::create($data); } } + + // Seed special regions after countries are imported + $this->seedSpecialRegions(); } protected function getJobName(): string @@ -54,4 +63,133 @@ protected function getNotificationTitle(): string { return __("eclipse-world::countries.notifications.{$this->status->value}.title", [], $this->locale); } + + private function importRegions(): void + { + $geographicalRegions = $this->getGeographicalRegionsStructure(); + + foreach ($geographicalRegions as $parentName => $subRegions) { + $parent = Region::updateOrCreate( + ['name' => $parentName, 'is_special' => false], + ['code' => null, 'parent_id' => null] + ); + + foreach ($subRegions as $subRegionName) { + Region::updateOrCreate( + ['name' => $subRegionName, 'is_special' => false], + ['code' => null, 'parent_id' => $parent->id] + ); + } + } + } + + private function getGeographicalRegionsStructure(): array + { + return [ + 'Africa' => [ + 'Eastern Africa', + 'Middle Africa', + 'Northern Africa', + 'Southern Africa', + 'Western Africa', + ], + 'Americas' => [ + 'Caribbean', + 'Central America', + 'North America', + 'South America', + ], + 'Asia' => [ + 'Central Asia', + 'Eastern Asia', + 'South-Eastern Asia', + 'Southern Asia', + 'Western Asia', + ], + 'Europe' => [ + 'Central Europe', + 'Eastern Europe', + 'Northern Europe', + 'Southeast Europe', + 'Southern Europe', + 'Western Europe', + ], + 'Oceania' => [ + 'Australia and New Zealand', + 'Melanesia', + 'Micronesia', + 'Polynesia', + ], + ]; + } + + private function getRegionIdForCountry(array $countryData): ?int + { + if (! isset($countryData['subregion'])) { + return null; + } + + return Region::where('name', $countryData['subregion']) + ->where('is_special', false) + ->value('id'); + } + + private function seedSpecialRegions(): void + { + // Create EU special region + $euRegion = Region::updateOrCreate( + ['code' => 'EU'], + [ + 'name' => 'European Union', + 'is_special' => true, + ] + ); + + $euMemberCountries = [ + 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', + 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', + 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', + ]; + + $existingCountries = Country::whereIn('id', $euMemberCountries)->pluck('id'); + + // Get current memberships + $currentMemberships = $euRegion->specialCountries() + ->wherePivot('start_date', '<=', Carbon::now()->toDateString()) + ->where(function ($query) { + $query->whereNull('world_country_in_special_region.end_date') + ->orWhere('world_country_in_special_region.end_date', '>=', Carbon::now()->toDateString()); + }) + ->pluck('world_countries.id') + ->toArray(); + + // Only update if there are changes needed + $countriesToAdd = $existingCountries->diff($currentMemberships); + $countriesToRemove = collect($currentMemberships)->diff($existingCountries); + + // Add new countries + if ($countriesToAdd->isNotEmpty()) { + $membershipData = $countriesToAdd->mapWithKeys(fn ($countryId) => [ + $countryId => [ + 'start_date' => Carbon::now()->toDateString(), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + $euRegion->specialCountries()->attach($membershipData); + } + + // Remove countries that are no longer members + if ($countriesToRemove->isNotEmpty()) { + foreach ($countriesToRemove as $countryId) { + $euRegion->specialCountries() + ->wherePivot('country_id', $countryId) + ->whereNull('world_country_in_special_region.end_date') + ->updateExistingPivot($countryId, [ + 'end_date' => Carbon::now()->subDay()->toDateString(), + 'updated_at' => now(), + ]); + } + } + } } diff --git a/src/Models/Country.php b/src/Models/Country.php index 45fb04e..4803e42 100644 --- a/src/Models/Country.php +++ b/src/Models/Country.php @@ -3,9 +3,14 @@ namespace Eclipse\World\Models; use Eclipse\World\Factories\CountryFactory; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Carbon; class Country extends Model { @@ -23,8 +28,64 @@ class Country extends Model 'num_code', 'name', 'flag', + 'region_id', ]; + /** + * Get the region that the country belongs to. + */ + public function region(): BelongsTo + { + return $this->belongsTo(Region::class, 'region_id'); + } + + /** + * Get the special regions that the country belongs to. + */ + public function specialRegions(): BelongsToMany + { + return $this->belongsToMany(Region::class, 'world_country_in_special_region', 'country_id', 'region_id') + ->withPivot(['start_date', 'end_date']) + ->withTimestamps(); + } + + public function countryInSpecialRegions(): HasMany + { + return $this->hasMany(CountryInSpecialRegion::class, 'country_id'); + } + + /** + * Check if the country belongs to a special region at a given date. + */ + public function belongsToSpecialRegion(Region $region, ?Carbon $date = null): bool + { + if (! $region->is_special) { + return false; + } + + return $this->getSpecialRegionsAt($date)->contains('id', $region->id); + } + + /** + * Get the special regions that the country belongs to at a given date. + */ + public function getSpecialRegionsAt(?Carbon $date = null): Collection + { + $checkDate = $date ?? Carbon::now(); + + return $this->specialRegions() + ->wherePivot('start_date', '<=', $checkDate->toDateString()) + ->where( + fn ($query) => $query + ->whereNull('world_country_in_special_region.end_date') + ->orWhere('world_country_in_special_region.end_date', '>=', $checkDate->toDateString()) + ) + ->get(); + } + + /** + * Get the factory for the model. + */ protected static function newFactory(): CountryFactory { return CountryFactory::new(); diff --git a/src/Models/CountryInSpecialRegion.php b/src/Models/CountryInSpecialRegion.php new file mode 100644 index 0000000..51ebd52 --- /dev/null +++ b/src/Models/CountryInSpecialRegion.php @@ -0,0 +1,27 @@ + 'date', + 'end_date' => 'date', + ]; + + protected $fillable = [ + 'country_id', + 'region_id', + 'start_date', + 'end_date', + ]; + + public function region() + { + return $this->belongsTo(Region::class); + } +} diff --git a/src/Models/Region.php b/src/Models/Region.php new file mode 100644 index 0000000..fd415f7 --- /dev/null +++ b/src/Models/Region.php @@ -0,0 +1,116 @@ + 'boolean', + ]; + + /** + * Get the parent region. + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** + * Get the child regions. + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + /** + * Get the countries in the region. + */ + public function countries(): HasMany + { + return $this->hasMany(Country::class, 'region_id'); + } + + /** + * Get the countries in the special region. + */ + public function specialCountries(): BelongsToMany + { + return $this->belongsToMany(Country::class, 'world_country_in_special_region', 'region_id', 'country_id') + ->withPivot(['start_date', 'end_date']) + ->withTimestamps(); + } + + /** + * Get the countries in the special region at a given date. + */ + public function getCountriesInSpecialRegion(?Carbon $date = null): Collection + { + if (! $this->is_special) { + return new Collection; + } + + $checkDate = $date ?? Carbon::now(); + + return $this->specialCountries() + ->wherePivot('start_date', '<=', $checkDate->toDateString()) + ->where(fn ($query) => $query + ->whereNull('world_country_in_special_region.end_date') + ->orWhere('world_country_in_special_region.end_date', '>=', $checkDate->toDateString()) + ) + ->get(); + } + + /** + * Check if the region is geographical. + */ + public function isGeographical(): bool + { + return ! $this->is_special; + } + + /** + * Get all the descendants of the region. + */ + public function getAllDescendants(): Collection + { + $descendants = new Collection; + + foreach ($this->children as $child) { + $descendants->push($child); + $descendants = $descendants->merge($child->getAllDescendants()); + } + + return $descendants; + } + + /** + * Get the factory for the model. + */ + protected static function newFactory(): RegionFactory + { + return RegionFactory::new(); + } +} diff --git a/src/Policies/RegionPolicy.php b/src/Policies/RegionPolicy.php new file mode 100644 index 0000000..3e540e7 --- /dev/null +++ b/src/Policies/RegionPolicy.php @@ -0,0 +1,92 @@ +can('view_any_region'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(Authorizable $user, Region $region): bool + { + return $user->can('view_region'); + } + + /** + * Determine whether the user can create models. + */ + public function create(Authorizable $user): bool + { + return $user->can('create_region'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(Authorizable $user, Region $region): bool + { + return $user->can('update_region'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(Authorizable $user, Region $region): bool + { + return $user->can('delete_region'); + } + + /** + * Determine whether the user can delete any models. + */ + public function deleteAny(Authorizable $user): bool + { + return $user->can('delete_any_region'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(Authorizable $user, Region $region): bool + { + return $user->can('force_delete_region'); + } + + /** + * Determine whether the user can permanently delete any models. + */ + public function forceDeleteAny(Authorizable $user): bool + { + return $user->can('force_delete_any_region'); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(Authorizable $user, Region $region): bool + { + return $user->can('restore_region'); + } + + /** + * Determine whether the user can restore any models. + */ + public function restoreAny(Authorizable $user): bool + { + return $user->can('restore_any_region'); + } +} diff --git a/tests/Feature/CountryResourceTest.php b/tests/Feature/CountryResourceTest.php index 6609101..595d8e7 100644 --- a/tests/Feature/CountryResourceTest.php +++ b/tests/Feature/CountryResourceTest.php @@ -3,6 +3,7 @@ use Eclipse\World\Filament\Clusters\World\Resources\CountryResource; use Eclipse\World\Filament\Clusters\World\Resources\CountryResource\Pages\ListCountries; use Eclipse\World\Models\Country; +use Eclipse\World\Models\Region; use function Pest\Livewire\livewire; @@ -150,3 +151,41 @@ $this->assertModelMissing($country); }); + +test('country can be assigned to a region', function () { + $region = Region::factory()->geographical()->create(); + $country = Country::factory()->create(); + + $data = ['region_id' => $region->id]; + + livewire(ListCountries::class) + ->callTableAction('edit', $country, $data) + ->assertHasNoTableActionErrors(); + + $country->refresh(); + expect($country->region_id)->toEqual($region->id); + expect($country->region->name)->toEqual($region->name); +}); + +test('countries can be filtered by region', function () { + $region1 = Region::factory()->geographical()->create(); + $region2 = Region::factory()->geographical()->create(); + + $country1 = Country::factory()->create(['region_id' => $region1->id]); + $country2 = Country::factory()->create(['region_id' => $region2->id]); + $country3 = Country::factory()->create(['region_id' => null]); + + livewire(ListCountries::class) + ->filterTable('region_id', $region1->id) + ->assertCanSeeTableRecords([$country1]) + ->assertCanNotSeeTableRecords([$country2, $country3]); +}); + +test('region column is displayed in countries table', function () { + $region = Region::factory()->geographical()->create(['name' => 'Test Region']); + $country = Country::factory()->create(['region_id' => $region->id]); + + livewire(ListCountries::class) + ->assertCanSeeTableRecords([$country]) + ->assertTableColumnExists('region.name'); +}); diff --git a/tests/Feature/RegionResourceTest.php b/tests/Feature/RegionResourceTest.php new file mode 100644 index 0000000..27c6989 --- /dev/null +++ b/tests/Feature/RegionResourceTest.php @@ -0,0 +1,205 @@ +setUpSuperAdmin(); +}); + +test('unauthorized access can be prevented', function () { + // Create regular user with no permissions + $this->setUpCommonUser(); + + // Create test region + $region = Region::factory()->create(); + + // View table + $this->get(RegionResource::getUrl()) + ->assertForbidden(); + + // Add direct permission to view the table, since otherwise any other action below is not available even for testing + $this->user->givePermissionTo('view_any_region'); + + // Create region + livewire(ListRegions::class) + ->assertActionDisabled('create'); + + // Edit region + livewire(ListRegions::class) + ->assertCanSeeTableRecords([$region]) + ->assertTableActionDisabled('edit', $region); + + // Delete region + livewire(ListRegions::class) + ->assertTableActionDisabled('delete', $region) + ->assertTableBulkActionDisabled('delete'); + + // Restore and force delete + $region->delete(); + $this->assertSoftDeleted($region); + + livewire(ListRegions::class) + ->assertTableActionDisabled('restore', $region) + ->assertTableBulkActionDisabled('restore') + ->assertTableActionDisabled('forceDelete', $region) + ->assertTableBulkActionDisabled('forceDelete'); +}); + +test('regions table can be displayed', function () { + $this->get(RegionResource::getUrl()) + ->assertSuccessful(); +}); + +test('form validation works', function () { + $component = livewire(ListRegions::class); + + // Test required fields + $component->callAction('create') + ->assertHasActionErrors([ + 'name' => 'required', + ]); + + // Test with valid data + $component->callAction('create', Region::factory()->definition()) + ->assertHasNoActionErrors(); +}); + +test('new region can be created', function () { + $data = Region::factory()->definition(); + + livewire(ListRegions::class) + ->callAction('create', $data) + ->assertHasNoActionErrors(); + + $region = Region::where('name', $data['name'])->first(); + expect($region)->toBeObject(); + + foreach ($data as $key => $val) { + expect($region->$key)->toEqual($val); + } +}); + +test('existing region can be updated', function () { + $region = Region::factory()->create([ + 'name' => 'Test Region', + 'code' => 'TR', + 'is_special' => false, + ]); + + $data = [ + 'name' => 'Updated Region', + 'code' => 'UR', + 'is_special' => true, + ]; + + livewire(ListRegions::class) + ->callTableAction('edit', $region, $data) + ->assertHasNoTableActionErrors(); + + $region->refresh(); + + foreach ($data as $key => $val) { + expect($region->$key)->toEqual($val); + } +}); + +test('region can be deleted', function () { + $region = Region::factory()->create(); + + livewire(ListRegions::class) + ->callTableAction('delete', $region) + ->assertHasNoTableActionErrors(); + + $this->assertSoftDeleted($region); +}); + +test('region can be restored', function () { + $region = Region::factory()->create(); + $region->delete(); + + $this->assertSoftDeleted($region); + + livewire(ListRegions::class) + ->filterTable('trashed') + ->assertTableActionExists('restore') + ->assertTableActionEnabled('restore', $region) + ->assertTableActionVisible('restore', $region) + ->callTableAction('restore', $region) + ->assertHasNoTableActionErrors(); + + $this->assertNotSoftDeleted($region); +}); + +test('region can be force deleted', function () { + $region = Region::factory()->create(); + + $region->delete(); + $this->assertSoftDeleted($region); + + livewire(ListRegions::class) + ->filterTable('trashed') + ->assertTableActionExists('forceDelete') + ->assertTableActionEnabled('forceDelete', $region) + ->assertTableActionVisible('forceDelete', $region) + ->callTableAction('forceDelete', $region) + ->assertHasNoTableActionErrors(); + + $this->assertModelMissing($region); +}); + +test('region with parent can be created', function () { + $parent = Region::factory()->create(); + $data = Region::factory()->withParent($parent)->make()->toArray(); + + livewire(ListRegions::class) + ->callAction('create', $data) + ->assertHasNoActionErrors(); + + $region = Region::where('name', $data['name'])->first(); + expect($region)->toBeObject(); + expect($region->parent_id)->toEqual($parent->id); +}); + +test('special region can be created', function () { + $data = Region::factory()->special()->make()->toArray(); + + livewire(ListRegions::class) + ->callAction('create', $data) + ->assertHasNoActionErrors(); + + $region = Region::where('name', $data['name'])->first(); + expect($region)->toBeObject(); + expect($region->is_special)->toBeTrue(); +}); + +test('regions can be filtered by type', function () { + $geographical = Region::factory()->geographical()->create(); + $special = Region::factory()->special()->create(); + + // Filter by geographical + livewire(ListRegions::class) + ->filterTable('is_special', '0') + ->assertCanSeeTableRecords([$geographical]) + ->assertCanNotSeeTableRecords([$special]); + + // Filter by special + livewire(ListRegions::class) + ->filterTable('is_special', '1') + ->assertCanSeeTableRecords([$special]) + ->assertCanNotSeeTableRecords([$geographical]); +}); + +test('regions can be filtered by parent', function () { + $parent = Region::factory()->create(); + $child = Region::factory()->withParent($parent)->create(); + $orphan = Region::factory()->create(); + + livewire(ListRegions::class) + ->filterTable('parent_id', $parent->id) + ->assertCanSeeTableRecords([$child]) + ->assertCanNotSeeTableRecords([$parent, $orphan]); +}); diff --git a/tests/Unit/ImportCountriesJobTest.php b/tests/Unit/ImportCountriesJobTest.php new file mode 100644 index 0000000..94bc17a --- /dev/null +++ b/tests/Unit/ImportCountriesJobTest.php @@ -0,0 +1,145 @@ + Http::response([ + [ + 'cca2' => 'US', + 'cca3' => 'USA', + 'ccn3' => '840', + 'name' => ['common' => 'United States'], + 'flag' => '🇺🇸', + 'independent' => true, + 'region' => 'Americas', + 'subregion' => 'North America', + ], + [ + 'cca2' => 'DE', + 'cca3' => 'DEU', + 'ccn3' => '276', + 'name' => ['common' => 'Germany'], + 'flag' => '🇩🇪', + 'independent' => true, + 'region' => 'Europe', + 'subregion' => 'Western Europe', + ], + ]), + ]); + + $job = new ImportCountries(null); + $job->handle(); + + // Check that regions were created + expect(Region::where('name', 'Americas')->exists())->toBeTrue(); + expect(Region::where('name', 'Europe')->exists())->toBeTrue(); + expect(Region::where('name', 'North America')->exists())->toBeTrue(); + expect(Region::where('name', 'Western Europe')->exists())->toBeTrue(); + + // Check parent-child relationships + $americas = Region::where('name', 'Americas')->first(); + $northAmerica = Region::where('name', 'North America')->first(); + expect($northAmerica->parent_id)->toEqual($americas->id); + + $europe = Region::where('name', 'Europe')->first(); + $westernEurope = Region::where('name', 'Western Europe')->first(); + expect($westernEurope->parent_id)->toEqual($europe->id); +}); + +test('import countries job assigns regions to countries', function () { + // Mock the HTTP response + Http::fake([ + 'https://raw.githubusercontent.com/mledoze/countries/master/dist/countries.json' => Http::response([ + [ + 'cca2' => 'US', + 'cca3' => 'USA', + 'ccn3' => '840', + 'name' => ['common' => 'United States'], + 'flag' => '🇺🇸', + 'independent' => true, + 'region' => 'Americas', + 'subregion' => 'North America', + ], + ]), + ]); + + $job = new ImportCountries(null); + $job->handle(); + + $country = Country::where('id', 'US')->first(); + expect($country)->toBeObject(); + expect($country->region)->toBeInstanceOf(Region::class); + expect($country->region->name)->toEqual('North America'); +}); + +test('import countries job handles countries without subregion', function () { + // Create a country directly to test the region assignment logic + $countryData = [ + 'cca2' => 'XX', + 'cca3' => 'XXX', + 'ccn3' => '999', + 'name' => ['common' => 'Test Country'], + 'flag' => '🏳️', + 'independent' => true, + 'region' => 'Test Region', + // No subregion field + ]; + + // Test the region assignment logic directly + $job = new ImportCountries(null); + $reflection = new ReflectionClass($job); + $method = $reflection->getMethod('getRegionIdForCountry'); + $method->setAccessible(true); + + $regionId = $method->invoke($job, $countryData); + expect($regionId)->toBeNull(); + + // Also test that a country can be created with null region_id + $country = Country::create([ + 'id' => 'XX', + 'a3_id' => 'XXX', + 'num_code' => '999', + 'name' => 'Test Country', + 'flag' => '🏳️', + 'region_id' => null, + ]); + + expect($country->region_id)->toBeNull(); +}); + +test('import countries job updates existing countries', function () { + // Create existing country + $existingCountry = Country::factory()->create([ + 'id' => 'US', + 'name' => 'Old Name', + ]); + + // Mock the HTTP response + Http::fake([ + 'https://raw.githubusercontent.com/mledoze/countries/master/dist/countries.json' => Http::response([ + [ + 'cca2' => 'US', + 'cca3' => 'USA', + 'ccn3' => '840', + 'name' => ['common' => 'United States'], + 'flag' => '🇺🇸', + 'independent' => true, + 'region' => 'Americas', + 'subregion' => 'North America', + ], + ]), + ]); + + $job = new ImportCountries(null); + $job->handle(); + + $existingCountry->refresh(); + expect($existingCountry->name)->toEqual('United States'); + expect($existingCountry->region)->toBeInstanceOf(Region::class); + expect($existingCountry->region->name)->toEqual('North America'); +}); diff --git a/tests/Unit/RegionModelTest.php b/tests/Unit/RegionModelTest.php new file mode 100644 index 0000000..eace81e --- /dev/null +++ b/tests/Unit/RegionModelTest.php @@ -0,0 +1,154 @@ +create(); + $child = Region::factory()->withParent($parent)->create(); + + expect($child->parent)->toBeInstanceOf(Region::class); + expect($child->parent->id)->toEqual($parent->id); + expect($parent->children)->toHaveCount(1); + expect($parent->children->first()->id)->toEqual($child->id); +}); + +test('region can have countries', function () { + $region = Region::factory()->create(); + $country = Country::factory()->create(['region_id' => $region->id]); + + expect($region->countries)->toHaveCount(1); + expect($region->countries->first()->id)->toEqual($country->id); +}); + +test('special region can have countries with dates', function () { + $region = Region::factory()->special()->create(); + $country = Country::factory()->create(); + + $startDate = Carbon::now()->subDays(30); + $endDate = Carbon::now()->addDays(30); + + $region->specialCountries()->attach($country->id, [ + 'start_date' => $startDate->toDateString(), + 'end_date' => $endDate->toDateString(), + ]); + + expect($region->specialCountries)->toHaveCount(1); + expect($region->specialCountries->first()->id)->toEqual($country->id); +}); + +test('getCountriesInSpecialRegion returns countries within date range', function () { + $region = Region::factory()->special()->create(); + $country1 = Country::factory()->create(); + $country2 = Country::factory()->create(); + $country3 = Country::factory()->create(); + + $now = Carbon::now(); + + // Country 1: Active (started in past, no end date) + $region->specialCountries()->attach($country1->id, [ + 'start_date' => $now->copy()->subDays(30)->toDateString(), + 'end_date' => null, + ]); + + // Country 2: Active (started in past, ends in future) + $region->specialCountries()->attach($country2->id, [ + 'start_date' => $now->copy()->subDays(30)->toDateString(), + 'end_date' => $now->copy()->addDays(30)->toDateString(), + ]); + + // Country 3: Inactive (ended in past) + $region->specialCountries()->attach($country3->id, [ + 'start_date' => $now->copy()->subDays(60)->toDateString(), + 'end_date' => $now->copy()->subDays(10)->toDateString(), + ]); + + $activeCountries = $region->getCountriesInSpecialRegion($now); + + expect($activeCountries)->toHaveCount(2); + expect($activeCountries->pluck('id')->toArray())->toContain($country1->id, $country2->id); + expect($activeCountries->pluck('id')->toArray())->not->toContain($country3->id); +}); + +test('getCountriesInSpecialRegion returns empty collection for geographical region', function () { + $region = Region::factory()->geographical()->create(); + $country = Country::factory()->create(); + + $region->specialCountries()->attach($country->id, [ + 'start_date' => Carbon::now()->toDateString(), + ]); + + $countries = $region->getCountriesInSpecialRegion(); + + expect($countries)->toHaveCount(0); +}); + +test('isGeographical returns correct value', function () { + $geographical = Region::factory()->geographical()->create(); + $special = Region::factory()->special()->create(); + + expect($geographical->isGeographical())->toBeTrue(); + expect($special->isGeographical())->toBeFalse(); +}); + +test('getAllDescendants returns all nested children', function () { + $grandparent = Region::factory()->create(); + $parent = Region::factory()->withParent($grandparent)->create(); + $child = Region::factory()->withParent($parent)->create(); + $sibling = Region::factory()->withParent($grandparent)->create(); + + $descendants = $grandparent->getAllDescendants(); + + expect($descendants)->toHaveCount(3); + expect($descendants->pluck('id')->toArray())->toContain($parent->id, $child->id, $sibling->id); +}); + +test('country belongsToSpecialRegion works correctly', function () { + $region = Region::factory()->special()->create(); + $country = Country::factory()->create(); + + $now = Carbon::now(); + + // Add country to region with current membership + $region->specialCountries()->attach($country->id, [ + 'start_date' => $now->copy()->subDays(30)->toDateString(), + 'end_date' => $now->copy()->addDays(30)->toDateString(), + ]); + + expect($country->belongsToSpecialRegion($region, $now))->toBeTrue(); + expect($country->belongsToSpecialRegion($region, $now->copy()->addDays(60)))->toBeFalse(); +}); + +test('country getSpecialRegionsAt returns correct regions', function () { + $region1 = Region::factory()->special()->create(); + $region2 = Region::factory()->special()->create(); + $region3 = Region::factory()->special()->create(); + $country = Country::factory()->create(); + + $now = Carbon::now(); + + // Active in region1 + $region1->specialCountries()->attach($country->id, [ + 'start_date' => $now->copy()->subDays(30)->toDateString(), + 'end_date' => null, + ]); + + // Active in region2 + $region2->specialCountries()->attach($country->id, [ + 'start_date' => $now->copy()->subDays(30)->toDateString(), + 'end_date' => $now->copy()->addDays(30)->toDateString(), + ]); + + // Not active in region3 (ended) + $region3->specialCountries()->attach($country->id, [ + 'start_date' => $now->copy()->subDays(60)->toDateString(), + 'end_date' => $now->copy()->subDays(10)->toDateString(), + ]); + + $activeRegions = $country->getSpecialRegionsAt($now); + + expect($activeRegions)->toHaveCount(2); + expect($activeRegions->pluck('id')->toArray())->toContain($region1->id, $region2->id); + expect($activeRegions->pluck('id')->toArray())->not->toContain($region3->id); +});