From 43a0b1a79e7c670bb2ca4ed4ecf3ce25ae591b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omer=20=C5=A0abi=C4=87?= Date: Wed, 2 Jul 2025 15:48:13 +0200 Subject: [PATCH 1/4] feat: add posts prototype --- database/factories/PostFactory.php | 25 ++++ .../2025_07_02_122310_create_posts_table.php | 30 +++++ .../Clusters/World/Resources/PostResource.php | 116 ++++++++++++++++++ .../PostResource/Pages/CreatePost.php | 18 +++ .../Resources/PostResource/Pages/EditPost.php | 23 ++++ .../PostResource/Pages/ListPosts.php | 19 +++ src/Models/Post.php | 26 ++++ 7 files changed, 257 insertions(+) create mode 100644 database/factories/PostFactory.php create mode 100644 database/migrations/2025_07_02_122310_create_posts_table.php create mode 100644 src/Filament/Clusters/World/Resources/PostResource.php create mode 100644 src/Filament/Clusters/World/Resources/PostResource/Pages/CreatePost.php create mode 100644 src/Filament/Clusters/World/Resources/PostResource/Pages/EditPost.php create mode 100644 src/Filament/Clusters/World/Resources/PostResource/Pages/ListPosts.php create mode 100644 src/Models/Post.php diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php new file mode 100644 index 0000000..c314ed1 --- /dev/null +++ b/database/factories/PostFactory.php @@ -0,0 +1,25 @@ + $this->faker->word(), + 'name' => $this->faker->name(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + + 'country_id' => Country::factory(), + ]; + } +} diff --git a/database/migrations/2025_07_02_122310_create_posts_table.php b/database/migrations/2025_07_02_122310_create_posts_table.php new file mode 100644 index 0000000..ba83e5d --- /dev/null +++ b/database/migrations/2025_07_02_122310_create_posts_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('country_id', 2); + $table->string('code'); + $table->string('name'); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('country_id') + ->references('id') + ->on('world_countries') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + }); + } + + public function down(): void + { + Schema::dropIfExists('world_posts'); + } +}; diff --git a/src/Filament/Clusters/World/Resources/PostResource.php b/src/Filament/Clusters/World/Resources/PostResource.php new file mode 100644 index 0000000..8ccb376 --- /dev/null +++ b/src/Filament/Clusters/World/Resources/PostResource.php @@ -0,0 +1,116 @@ +schema([ + Select::make('country_id') + ->relationship('country', 'name') + ->searchable() + ->required(), + + TextInput::make('code') + ->required(), + + TextInput::make('name') + ->required(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('country.name') + ->searchable() + ->sortable(), + + TextColumn::make('code'), + + TextColumn::make('name') + ->searchable() + ->sortable(), + ]) + ->filters([ + TrashedFilter::make(), + ]) + ->actions([ + EditAction::make(), + DeleteAction::make(), + RestoreAction::make(), + ForceDeleteAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + RestoreBulkAction::make(), + ForceDeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => PostResource\Pages\ListPosts::route('/'), + 'create' => PostResource\Pages\CreatePost::route('/create'), + 'edit' => PostResource\Pages\EditPost::route('/{record}/edit'), + ]; + } + + 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', + ]; + } +} diff --git a/src/Filament/Clusters/World/Resources/PostResource/Pages/CreatePost.php b/src/Filament/Clusters/World/Resources/PostResource/Pages/CreatePost.php new file mode 100644 index 0000000..43f81b4 --- /dev/null +++ b/src/Filament/Clusters/World/Resources/PostResource/Pages/CreatePost.php @@ -0,0 +1,18 @@ +belongsTo(Country::class); + } +} From 333eb81fe2f2e4f9056be30b2da56608e8cae579 Mon Sep 17 00:00:00 2001 From: SlimDeluxe <131700+SlimDeluxe@users.noreply.github.com> Date: Wed, 2 Jul 2025 13:48:35 +0000 Subject: [PATCH 2/4] style: fix code style --- database/migrations/2025_07_02_122310_create_posts_table.php | 3 ++- src/Filament/Clusters/World/Resources/PostResource.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/database/migrations/2025_07_02_122310_create_posts_table.php b/database/migrations/2025_07_02_122310_create_posts_table.php index ba83e5d..ab951d6 100644 --- a/database/migrations/2025_07_02_122310_create_posts_table.php +++ b/database/migrations/2025_07_02_122310_create_posts_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('world_posts', function (Blueprint $table) { diff --git a/src/Filament/Clusters/World/Resources/PostResource.php b/src/Filament/Clusters/World/Resources/PostResource.php index 8ccb376..461add2 100644 --- a/src/Filament/Clusters/World/Resources/PostResource.php +++ b/src/Filament/Clusters/World/Resources/PostResource.php @@ -23,7 +23,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\SoftDeletingScope; -class PostResource extends Resource implements HasShieldPermissions +class PostResource extends Resource implements HasShieldPermissions { protected static ?string $model = Post::class; From 0d1bbaf0e10c2a3e94aa1b6a49c6ba9d97df9dd0 Mon Sep 17 00:00:00 2001 From: Kilian Trunk Date: Mon, 7 Jul 2025 13:58:09 +0200 Subject: [PATCH 3/4] feat(posts): implement posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(posts): implement postal code resource - Switch create/edit forms to use dialogs (same pattern as country) - Show country name with flag emoji in posts table - Enforce unique country+post-code key (DB migration + user-friendly UI error) - Add import script for SI and HR (prompt for country, run in background) - Extract all UI strings to lang files and add translations - Add tests for: • Posts CRUD • Access/permissions (mirror country resource tests) • Filtering by country • Preventing duplicate country+post-code combos * chore(posts): add missing factory to post model * chore(posts): add missing migration * chore(posts): rename signature * chore(posts): prepend country flag in the country column * chore(posts): remove unused translations --- ...add_unique_country_code_to_posts_table.php | 28 ++ resources/lang/en/posts.php | 78 +++++ resources/lang/hr/posts.php | 78 +++++ resources/lang/sl/posts.php | 78 +++++ resources/lang/sr/posts.php | 78 +++++ src/Console/Commands/ImportPostsCommand.php | 45 +++ src/EclipseWorldServiceProvider.php | 2 + .../Clusters/World/Resources/PostResource.php | 84 ++++- .../PostResource/Pages/CreatePost.php | 18 - .../Resources/PostResource/Pages/EditPost.php | 23 -- .../PostResource/Pages/ListPosts.php | 37 +- src/Jobs/ImportPosts.php | 132 +++++++ src/Models/Post.php | 25 ++ src/Policies/PostPolicy.php | 84 +++++ tests/Feature/PostResourceTest.php | 330 ++++++++++++++++++ 15 files changed, 1065 insertions(+), 55 deletions(-) create mode 100644 database/migrations/2025_07_03_191122_add_unique_country_code_to_posts_table.php create mode 100644 resources/lang/en/posts.php create mode 100644 resources/lang/hr/posts.php create mode 100644 resources/lang/sl/posts.php create mode 100644 resources/lang/sr/posts.php create mode 100644 src/Console/Commands/ImportPostsCommand.php delete mode 100644 src/Filament/Clusters/World/Resources/PostResource/Pages/CreatePost.php delete mode 100644 src/Filament/Clusters/World/Resources/PostResource/Pages/EditPost.php create mode 100644 src/Jobs/ImportPosts.php create mode 100644 src/Policies/PostPolicy.php create mode 100644 tests/Feature/PostResourceTest.php diff --git a/database/migrations/2025_07_03_191122_add_unique_country_code_to_posts_table.php b/database/migrations/2025_07_03_191122_add_unique_country_code_to_posts_table.php new file mode 100644 index 0000000..230c252 --- /dev/null +++ b/database/migrations/2025_07_03_191122_add_unique_country_code_to_posts_table.php @@ -0,0 +1,28 @@ +unique(['country_id', 'code'], 'unique_country_post_code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('world_posts', function (Blueprint $table) { + $table->dropUnique('unique_country_post_code'); + }); + } +}; diff --git a/resources/lang/en/posts.php b/resources/lang/en/posts.php new file mode 100644 index 0000000..e64e477 --- /dev/null +++ b/resources/lang/en/posts.php @@ -0,0 +1,78 @@ + 'Posts', + 'breadcrumb' => 'Posts', + 'plural' => 'Posts', + + 'table' => [ + 'country' => [ + 'label' => 'Country', + ], + 'code' => [ + 'label' => 'Code', + ], + 'name' => [ + 'label' => 'Name', + ], + ], + + 'actions' => [ + 'create' => [ + 'label' => 'New post', + 'heading' => 'Create Post', + ], + 'edit' => [ + 'label' => 'Edit', + 'heading' => 'Edit Post', + ], + 'delete' => [ + 'label' => 'Delete', + 'heading' => 'Delete Post', + ], + 'restore' => [ + 'label' => 'Restore', + 'heading' => 'Restore Post', + ], + 'force_delete' => [ + 'label' => 'Permanent delete', + 'heading' => 'Permanent Deletion of Post', + 'description' => 'Are you sure you want to delete the post :name? This action cannot be undone.', + ], + ], + + 'form' => [ + 'country_id' => [ + 'label' => 'Country', + ], + 'code' => [ + 'label' => 'Code', + ], + 'name' => [ + 'label' => 'Name', + ], + ], + + 'filter' => [ + 'country' => [ + 'label' => 'Country', + ], + ], + + 'validation' => [ + 'unique_country_code' => 'A post with this code already exists for the selected country.', + ], + + 'import' => [ + 'action_label' => 'Import Posts', + 'modal_heading' => 'Import Posts', + 'country_label' => 'Select Country', + 'country_helper' => 'Choose the country for which you want to import postal data', + 'success_title' => 'Import Posts', + 'success_message' => 'The import posts job has been queued for :country.', + 'countries' => [ + 'SI' => 'Slovenia', + 'HR' => 'Croatia', + ], + ], +]; \ No newline at end of file diff --git a/resources/lang/hr/posts.php b/resources/lang/hr/posts.php new file mode 100644 index 0000000..285fe02 --- /dev/null +++ b/resources/lang/hr/posts.php @@ -0,0 +1,78 @@ + 'Pošte', + 'breadcrumb' => 'Pošte', + 'plural' => 'Pošte', + + 'table' => [ + 'country' => [ + 'label' => 'Država', + ], + 'code' => [ + 'label' => 'Šifra', + ], + 'name' => [ + 'label' => 'Ime', + ], + ], + + 'actions' => [ + 'create' => [ + 'label' => 'Nova pošta', + 'heading' => 'Stvaranje pošte', + ], + 'edit' => [ + 'label' => 'Uredi', + 'heading' => 'Uređivanje pošte', + ], + 'delete' => [ + 'label' => 'Izbriši', + 'heading' => 'Brisanje pošte', + ], + 'restore' => [ + 'label' => 'Obnovi', + 'heading' => 'Obnova pošte', + ], + 'force_delete' => [ + 'label' => 'Trajno izbriši', + 'heading' => 'Trajno brisanje pošte', + 'description' => 'Jeste li sigurni da želite izbrisati poštu :name? Zapis više neće biti moguće obnoviti.', + ], + ], + + 'form' => [ + 'country_id' => [ + 'label' => 'Država', + ], + 'code' => [ + 'label' => 'Šifra', + ], + 'name' => [ + 'label' => 'Ime', + ], + ], + + 'filter' => [ + 'country' => [ + 'label' => 'Država', + ], + ], + + 'validation' => [ + 'unique_country_code' => 'Pošta s ovom šifrom već postoji za odabranu državu.', + ], + + 'import' => [ + 'action_label' => 'Uvezi pošte', + 'modal_heading' => 'Uvoz pošta', + 'country_label' => 'Odaberi državu', + 'country_helper' => 'Odaberi državu za koju želiš uvoziti poštanske podatke', + 'success_title' => 'Uvoz pošta', + 'success_message' => 'Zadatak za uvoz pošta je dodan u red čekanja za :country.', + 'countries' => [ + 'SI' => 'Slovenija', + 'HR' => 'Hrvatska', + ], + ], +]; \ No newline at end of file diff --git a/resources/lang/sl/posts.php b/resources/lang/sl/posts.php new file mode 100644 index 0000000..bfeacfd --- /dev/null +++ b/resources/lang/sl/posts.php @@ -0,0 +1,78 @@ + 'Pošte', + 'breadcrumb' => 'Pošte', + 'plural' => 'Pošte', + + 'table' => [ + 'country' => [ + 'label' => 'Država', + ], + 'code' => [ + 'label' => 'Šifra', + ], + 'name' => [ + 'label' => 'Ime', + ], + ], + + 'actions' => [ + 'create' => [ + 'label' => 'Nova pošta', + 'heading' => 'Ustvarjanje pošte', + ], + 'edit' => [ + 'label' => 'Uredi', + 'heading' => 'Urejanje pošte', + ], + 'delete' => [ + 'label' => 'Izbriši', + 'heading' => 'Brisanje pošte', + ], + 'restore' => [ + 'label' => 'Obnovi', + 'heading' => 'Obnovitev pošte', + ], + 'force_delete' => [ + 'label' => 'Trajno izbriši', + 'heading' => 'Trajen izbris pošte', + 'description' => 'Ste prepričani, da želite izbrisati pošto :name? Zapisa ne bo možno več obnoviti.', + ], + ], + + 'form' => [ + 'country_id' => [ + 'label' => 'Država', + ], + 'code' => [ + 'label' => 'Šifra', + ], + 'name' => [ + 'label' => 'Ime', + ], + ], + + 'filter' => [ + 'country' => [ + 'label' => 'Država', + ], + ], + + 'validation' => [ + 'unique_country_code' => 'Pošta s to šifro že obstaja za izbrano državo.', + ], + + 'import' => [ + 'action_label' => 'Uvozi pošte', + 'modal_heading' => 'Uvoz pošt', + 'country_label' => 'Izberi državo', + 'country_helper' => 'Izberi državo, za katero želiš uvoziti poštne podatke', + 'success_title' => 'Uvoz pošt', + 'success_message' => 'Naloga za uvoz pošt je bila dodana v čakalno vrsto za :country.', + 'countries' => [ + 'SI' => 'Slovenija', + 'HR' => 'Hrvaška', + ], + ], +]; \ No newline at end of file diff --git a/resources/lang/sr/posts.php b/resources/lang/sr/posts.php new file mode 100644 index 0000000..1353b77 --- /dev/null +++ b/resources/lang/sr/posts.php @@ -0,0 +1,78 @@ + 'Pošte', + 'breadcrumb' => 'Pošte', + 'plural' => 'Pošte', + + 'table' => [ + 'country' => [ + 'label' => 'Država', + ], + 'code' => [ + 'label' => 'Šifra', + ], + 'name' => [ + 'label' => 'Ime', + ], + ], + + 'actions' => [ + 'create' => [ + 'label' => 'Nova pošta', + 'heading' => 'Kreiranje pošte', + ], + 'edit' => [ + 'label' => 'Uredi', + 'heading' => 'Uređivanje pošte', + ], + 'delete' => [ + 'label' => 'Izbriši', + 'heading' => 'Brisanje pošte', + ], + 'restore' => [ + 'label' => 'Obnovi', + 'heading' => 'Obnavljanje pošte', + ], + 'force_delete' => [ + 'label' => 'Trajno izbriši', + 'heading' => 'Trajno brisanje pošte', + 'description' => 'Da li ste sigurni da želite izbrisati poštu :name? Zapis više neće biti moguće obnoviti.', + ], + ], + + 'form' => [ + 'country_id' => [ + 'label' => 'Država', + ], + 'code' => [ + 'label' => 'Šifra', + ], + 'name' => [ + 'label' => 'Ime', + ], + ], + + 'filter' => [ + 'country' => [ + 'label' => 'Država', + ], + ], + + 'validation' => [ + 'unique_country_code' => 'Pošta sa ovom šifrom već postoji za odabranu državu.', + ], + + 'import' => [ + 'action_label' => 'Uvezi pošte', + 'modal_heading' => 'Uvoz pošta', + 'country_label' => 'Odaberi državu', + 'country_helper' => 'Odaberi državu za koju želiš da uvoziš poštanske podatke', + 'success_title' => 'Uvoz pošta', + 'success_message' => 'Zadatak za uvoz pošta je dodat u red čekanja za :country.', + 'countries' => [ + 'SI' => 'Slovenija', + 'HR' => 'Hrvatska', + ], + ], +]; \ No newline at end of file diff --git a/src/Console/Commands/ImportPostsCommand.php b/src/Console/Commands/ImportPostsCommand.php new file mode 100644 index 0000000..555099c --- /dev/null +++ b/src/Console/Commands/ImportPostsCommand.php @@ -0,0 +1,45 @@ +argument('country')); + + if (!in_array($country, ['SI', 'HR'])) { + $this->error('Invalid country code. Only SI and HR are supported.'); + return self::FAILURE; + } + + $this->info("Dispatching import job for country: {$country}"); + + ImportPosts::dispatch($country); + + $this->info('Import job has been queued successfully!'); + $this->comment('The import will run in the background. Check the logs for progress.'); + + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/src/EclipseWorldServiceProvider.php b/src/EclipseWorldServiceProvider.php index 22da1a2..9c8a16f 100644 --- a/src/EclipseWorldServiceProvider.php +++ b/src/EclipseWorldServiceProvider.php @@ -3,6 +3,7 @@ namespace Eclipse\World; use Eclipse\World\Console\Commands\ImportCommand; +use Eclipse\World\Console\Commands\ImportPostsCommand; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -17,6 +18,7 @@ public function configurePackage(Package $package): void ->hasTranslations() ->hasCommands([ ImportCommand::class, + ImportPostsCommand::class, ]) ->discoversMigrations() ->runsMigrations(); diff --git a/src/Filament/Clusters/World/Resources/PostResource.php b/src/Filament/Clusters/World/Resources/PostResource.php index 461add2..0cf5eef 100644 --- a/src/Filament/Clusters/World/Resources/PostResource.php +++ b/src/Filament/Clusters/World/Resources/PostResource.php @@ -8,7 +8,9 @@ use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; +use Filament\Forms\Get; use Filament\Resources\Resource; +use Filament\Tables\Actions\ActionGroup; use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteBulkAction; @@ -19,9 +21,11 @@ use Filament\Tables\Actions\RestoreBulkAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\TrashedFilter; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\SoftDeletingScope; +use Illuminate\Validation\Rule; class PostResource extends Resource implements HasShieldPermissions { @@ -40,13 +44,29 @@ public static function form(Form $form): Form Select::make('country_id') ->relationship('country', 'name') ->searchable() - ->required(), + ->required() + ->label(__('eclipse-world::posts.form.country_id.label')) + ->live(), TextInput::make('code') - ->required(), + ->required() + ->label(__('eclipse-world::posts.form.code.label')) + ->rules(function (Get $get, ?Post $record) { + return [ + 'required', + 'string', + Rule::unique('world_posts', 'code') + ->where('country_id', $get('country_id')) + ->ignore($record?->id), + ]; + }) + ->validationMessages([ + 'unique' => __('eclipse-world::posts.validation.unique_country_code'), + ]), TextInput::make('name') - ->required(), + ->required() + ->label(__('eclipse-world::posts.form.name.label')), ]); } @@ -55,29 +75,54 @@ public static function table(Table $table): Table return $table ->columns([ TextColumn::make('country.name') + ->label(__('eclipse-world::posts.table.country.label')) + ->formatStateUsing(fn(string $state, Post $record) => trim("{$record->country->flag} {$state}")) ->searchable() ->sortable(), - TextColumn::make('code'), + TextColumn::make('code') + ->label(__('eclipse-world::posts.table.code.label')), TextColumn::make('name') + ->label(__('eclipse-world::posts.table.name.label')) ->searchable() ->sortable(), ]) ->filters([ + SelectFilter::make('country_id') + ->label(__('eclipse-world::posts.filter.country.label')) + ->relationship('country', 'name') + ->searchable() + ->preload(), TrashedFilter::make(), ]) ->actions([ - EditAction::make(), - DeleteAction::make(), - RestoreAction::make(), - ForceDeleteAction::make(), + EditAction::make() + ->label(__('eclipse-world::posts.actions.edit.label')) + ->modalHeading(__('eclipse-world::posts.actions.edit.heading')), + ActionGroup::make([ + DeleteAction::make() + ->label(__('eclipse-world::posts.actions.delete.label')) + ->modalHeading(__('eclipse-world::posts.actions.delete.heading')), + RestoreAction::make() + ->label(__('eclipse-world::posts.actions.restore.label')) + ->modalHeading(__('eclipse-world::posts.actions.restore.heading')), + ForceDeleteAction::make() + ->label(__('eclipse-world::posts.actions.force_delete.label')) + ->modalHeading(__('eclipse-world::posts.actions.force_delete.heading')) + ->modalDescription(fn(Post $record): string => __('eclipse-world::posts.actions.force_delete.description', [ + 'name' => $record->name, + ])), + ]), ]) ->bulkActions([ BulkActionGroup::make([ - DeleteBulkAction::make(), - RestoreBulkAction::make(), - ForceDeleteBulkAction::make(), + DeleteBulkAction::make() + ->label(__('eclipse-world::posts.actions.delete.label')), + RestoreBulkAction::make() + ->label(__('eclipse-world::posts.actions.restore.label')), + ForceDeleteBulkAction::make() + ->label(__('eclipse-world::posts.actions.force_delete.label')), ]), ]); } @@ -86,8 +131,6 @@ public static function getPages(): array { return [ 'index' => PostResource\Pages\ListPosts::route('/'), - 'create' => PostResource\Pages\CreatePost::route('/create'), - 'edit' => PostResource\Pages\EditPost::route('/{record}/edit'), ]; } @@ -113,4 +156,19 @@ public static function getPermissionPrefixes(): array 'force_delete_any', ]; } + + public static function getNavigationLabel(): string + { + return __('eclipse-world::posts.nav_label'); + } + + public static function getBreadcrumb(): string + { + return __('eclipse-world::posts.breadcrumb'); + } + + public static function getPluralModelLabel(): string + { + return __('eclipse-world::posts.plural'); + } } diff --git a/src/Filament/Clusters/World/Resources/PostResource/Pages/CreatePost.php b/src/Filament/Clusters/World/Resources/PostResource/Pages/CreatePost.php deleted file mode 100644 index 43f81b4..0000000 --- a/src/Filament/Clusters/World/Resources/PostResource/Pages/CreatePost.php +++ /dev/null @@ -1,18 +0,0 @@ -label(__('eclipse-world::posts.actions.create.label')) + ->modalHeading(__('eclipse-world::posts.actions.create.heading')), + Action::make('import_posts') + ->label(__('eclipse-world::posts.import.action_label')) + ->icon('heroicon-o-arrow-down-tray') + ->form([ + Select::make('country_id') + ->label(__('eclipse-world::posts.import.country_label')) + ->helperText(__('eclipse-world::posts.import.country_helper')) + ->options([ + 'SI' => __('eclipse-world::posts.import.countries.SI'), + 'HR' => __('eclipse-world::posts.import.countries.HR'), + ]) + ->required() + ->native(false), + ]) + ->modalHeading(__('eclipse-world::posts.import.modal_heading')) + ->action(function (array $data) { + // Dispatch the job with selected country + ImportPosts::dispatch($data['country_id']); + + // Show notification + Notification::make() + ->title(__('eclipse-world::posts.import.success_title')) + ->body(__('eclipse-world::posts.import.success_message', [ + 'country' => __('eclipse-world::posts.import.countries.' . $data['country_id']) + ])) + ->success() + ->send(); + }) + ->requiresConfirmation(), ]; } } diff --git a/src/Jobs/ImportPosts.php b/src/Jobs/ImportPosts.php new file mode 100644 index 0000000..1571bfe --- /dev/null +++ b/src/Jobs/ImportPosts.php @@ -0,0 +1,132 @@ +countryId = $countryId; + } + + /** + * Execute the job. + */ + public function handle(): void + { + if (!in_array($this->countryId, ['SI', 'HR'])) { + throw new Exception("Country {$this->countryId} not supported for import"); + } + + $batchSize = 1000; + $offset = 0; + $processedCodes = []; + + Log::info("Starting postal data import for country: {$this->countryId}"); + + do { + [$totalRecords, $records] = $this->getData($batchSize, $offset); + + foreach ($records as $record) { + [$postalCode, $placeName] = $this->getRecordData($record); + + if (array_key_exists($postalCode, $processedCodes)) { + continue; + } + + $processedCodes[$postalCode] = true; + + $existingPost = Post::where('country_id', $this->countryId) + ->where('code', $postalCode) + ->first(); + + if (empty($existingPost)) { + Post::create([ + 'country_id' => $this->countryId, + 'code' => $postalCode, + 'name' => $placeName, + ]); + } elseif ($existingPost->name !== $placeName) { + $existingPost->update(['name' => $placeName]); + } + } + + $offset += $batchSize; + } while ($offset < $totalRecords); + + Log::info("Postal data import completed for {$this->countryId}"); + } + + /** + * Get data from the external API + */ + private function getData(int $batchSize, int $offset): array + { + $url = self::OPENDATASOFT_RECORDS_API_URL + . "search/?dataset=geonames-postal-code@public" + . "&q=" + . "&rows={$batchSize}" + . "&start={$offset}" + . "&sort=postal_code" + . "&refine.country_code={$this->countryId}"; + + $response = Http::get($url); + + if (!$response->successful()) { + throw new Exception("Failed to fetch data from Opendatasoft API: " . $response->status()); + } + + $data = $response->json(); + + if (empty($data)) { + throw new Exception('Empty data set received from Opendatasoft API'); + } + + return [ + $data['nhits'], + $data['records'], + ]; + } + + /** + * Extract code and name from record based on country + */ + private function getRecordData(array $record): array + { + switch ($this->countryId) { + case 'HR': + return [ + $record['fields']['postal_code'], + $record['fields']['admin_name3'], + ]; + + case 'SI': + return [ + $record['fields']['postal_code'], + $record['fields']['place_name'], + ]; + + default: + throw new Exception("Country {$this->countryId} not supported"); + } + } +} \ No newline at end of file diff --git a/src/Models/Post.php b/src/Models/Post.php index 6c97954..1f7fed6 100644 --- a/src/Models/Post.php +++ b/src/Models/Post.php @@ -2,10 +2,12 @@ namespace Eclipse\World\Models; +use Eclipse\World\Factories\PostFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Validation\Rule; class Post extends Model { @@ -23,4 +25,27 @@ public function country(): BelongsTo { return $this->belongsTo(Country::class); } + + protected static function newFactory(): PostFactory + { + return PostFactory::new(); + } + + /** + * Get validation rules for the model + */ + public static function getValidationRules(?self $record = null): array + { + return [ + 'country_id' => ['required', 'string', 'max:2', 'exists:world_countries,id'], + 'code' => [ + 'required', + 'string', + Rule::unique('world_posts', 'code') + ->where('country_id', request('country_id')) + ->ignore($record?->id), + ], + 'name' => ['required', 'string', 'max:255'], + ]; + } } diff --git a/src/Policies/PostPolicy.php b/src/Policies/PostPolicy.php new file mode 100644 index 0000000..cec8bd5 --- /dev/null +++ b/src/Policies/PostPolicy.php @@ -0,0 +1,84 @@ +can('view_any_post'); + } + + /** + * Determine whether the user can create models. + */ + public function create(Authorizable $user): bool + { + return $user->can('create_post'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(Authorizable $user, Post $post): bool + { + return $user->can('update_post'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(Authorizable $user, Post $post): bool + { + return $user->can('delete_post'); + } + + /** + * Determine whether the user can bulk delete. + */ + public function deleteAny(Authorizable $user): bool + { + return $user->can('delete_any_post'); + } + + /** + * Determine whether the user can permanently delete. + */ + public function forceDelete(Authorizable $user, Post $post): bool + { + return $user->can('force_delete_post'); + } + + /** + * Determine whether the user can permanently bulk delete. + */ + public function forceDeleteAny(Authorizable $user): bool + { + return $user->can('force_delete_any_post'); + } + + /** + * Determine whether the user can restore. + */ + public function restore(Authorizable $user, Post $post): bool + { + return $user->can('restore_post'); + } + + /** + * Determine whether the user can bulk restore. + */ + public function restoreAny(Authorizable $user): bool + { + return $user->can('restore_any_post'); + } +} \ No newline at end of file diff --git a/tests/Feature/PostResourceTest.php b/tests/Feature/PostResourceTest.php new file mode 100644 index 0000000..cadce18 --- /dev/null +++ b/tests/Feature/PostResourceTest.php @@ -0,0 +1,330 @@ +setUpSuperAdmin(); +}); + +test('unauthorized access can be prevented', function () { + // Create regular user with no permissions + $this->setUpCommonUser(); + + // Create test data + $country = Country::factory()->create(); + $post = Post::factory()->create(['country_id' => $country->id]); + + // View table + $this->get(PostResource::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_post'); + + // Create post + livewire(ListPosts::class) + ->assertActionDisabled('create'); + + // Edit post + livewire(ListPosts::class) + ->assertCanSeeTableRecords([$post]) + ->assertTableActionDisabled('edit', $post); + + // Delete post + livewire(ListPosts::class) + ->assertTableActionDisabled('delete', $post) + ->assertTableBulkActionDisabled('delete'); + + // Restore and force delete + $post->delete(); + $this->assertSoftDeleted($post); + + livewire(ListPosts::class) + ->assertTableActionDisabled('restore', $post) + ->assertTableBulkActionDisabled('restore') + ->assertTableActionDisabled('forceDelete', $post) + ->assertTableBulkActionDisabled('forceDelete'); +}); + +test('posts table can be displayed', function () { + $this->get(PostResource::getUrl()) + ->assertSuccessful(); +}); + +test('form validation works', function () { + $country = Country::factory()->create(); + $component = livewire(ListPosts::class); + + // Test required fields + $component->callAction('create') + ->assertHasActionErrors([ + 'country_id' => 'required', + 'code' => 'required', + 'name' => 'required', + ]); + + // Test with valid data + $validData = [ + 'country_id' => $country->id, + 'code' => '1000', + 'name' => 'Ljubljana', + ]; + + $component->callAction('create', $validData) + ->assertHasNoActionErrors(); +}); + +test('new post can be created', function () { + $country = Country::factory()->create(); + $data = [ + 'country_id' => $country->id, + 'code' => '1000', + 'name' => 'Ljubljana', + ]; + + livewire(ListPosts::class) + ->callAction('create', $data) + ->assertHasNoActionErrors(); + + $post = Post::where('code', $data['code']) + ->where('country_id', $data['country_id']) + ->first(); + + expect($post)->toBeObject(); + + foreach ($data as $key => $val) { + expect($post->$key)->toEqual($val); + } +}); + +test('existing post can be updated', function () { + $country = Country::factory()->create(); + $post = Post::factory()->create([ + 'country_id' => $country->id, + 'code' => '1000', + 'name' => 'Ljubljana', + ]); + + $data = [ + 'code' => '2000', + 'name' => 'Maribor', + ]; + + livewire(ListPosts::class) + ->callTableAction('edit', $post, $data) + ->assertHasNoTableActionErrors(); + + $post->refresh(); + + foreach ($data as $key => $val) { + expect($post->$key)->toEqual($val); + } +}); + +test('post can be deleted', function () { + $country = Country::factory()->create(); + $post = Post::factory()->create(['country_id' => $country->id]); + + livewire(ListPosts::class) + ->callTableAction('delete', $post) + ->assertHasNoTableActionErrors(); + + $this->assertSoftDeleted($post); +}); + +test('post can be restored', function () { + $country = Country::factory()->create(); + $post = Post::factory()->create(['country_id' => $country->id]); + $post->delete(); + + $this->assertSoftDeleted($post); + + livewire(ListPosts::class) + ->filterTable('trashed') + ->assertTableActionExists('restore') + ->assertTableActionEnabled('restore', $post) + ->assertTableActionVisible('restore', $post) + ->callTableAction('restore', $post) + ->assertHasNoTableActionErrors(); + + $this->assertNotSoftDeleted($post); +}); + +test('post can be force deleted', function () { + $country = Country::factory()->create(); + $post = Post::factory()->create(['country_id' => $country->id]); + + $post->delete(); + $this->assertSoftDeleted($post); + + livewire(ListPosts::class) + ->filterTable('trashed') + ->assertTableActionExists('forceDelete') + ->assertTableActionEnabled('forceDelete', $post) + ->assertTableActionVisible('forceDelete', $post) + ->callTableAction('forceDelete', $post) + ->assertHasNoTableActionErrors(); + + $this->assertModelMissing($post); +}); + +test('filtering by country works', function () { + // Create two countries + $country1 = Country::factory()->create(['id' => 'SI', 'name' => 'Slovenia']); + $country2 = Country::factory()->create(['id' => 'HR', 'name' => 'Croatia']); + + // Create posts for each country + $post1 = Post::factory()->create(['country_id' => $country1->id, 'name' => 'Ljubljana']); + $post2 = Post::factory()->create(['country_id' => $country2->id, 'name' => 'Zagreb']); + + // Test filtering by first country + livewire(ListPosts::class) + ->filterTable('country_id', $country1->id) + ->assertCanSeeTableRecords([$post1]) + ->assertCanNotSeeTableRecords([$post2]); + + // Test filtering by second country + livewire(ListPosts::class) + ->filterTable('country_id', $country2->id) + ->assertCanSeeTableRecords([$post2]) + ->assertCanNotSeeTableRecords([$post1]); + + // Test removing filter shows all posts + livewire(ListPosts::class) + ->removeTableFilter('country_id') + ->assertCanSeeTableRecords([$post1, $post2]); +}); + +test('cannot create duplicate country-post code combo', function () { + $country = Country::factory()->create(['id' => 'SI']); + + // Create first post + $firstPost = Post::factory()->create([ + 'country_id' => $country->id, + 'code' => '1000', + 'name' => 'Ljubljana', + ]); + + // Try to create duplicate country-code combination + $duplicateData = [ + 'country_id' => $country->id, + 'code' => '1000', + 'name' => 'Different Name', + ]; + + livewire(ListPosts::class) + ->callAction('create', $duplicateData) + ->assertHasActionErrors(['code']); + + // Verify only one post exists + expect(Post::where('country_id', $country->id)->where('code', '1000')->count()) + ->toBe(1); +}); + +test('can create same post code for different countries', function () { + // Create two countries + $country1 = Country::factory()->create(['id' => 'SI']); + $country2 = Country::factory()->create(['id' => 'HR']); + + // Create post with same code for first country + $post1Data = [ + 'country_id' => $country1->id, + 'code' => '1000', + 'name' => 'Ljubljana', + ]; + + livewire(ListPosts::class) + ->callAction('create', $post1Data) + ->assertHasNoActionErrors(); + + // Create post with same code for second country (should work) + $post2Data = [ + 'country_id' => $country2->id, + 'code' => '1000', + 'name' => 'Zagreb', + ]; + + livewire(ListPosts::class) + ->callAction('create', $post2Data) + ->assertHasNoActionErrors(); + + // Verify both posts exist + expect(Post::where('code', '1000')->count())->toBe(2); + expect(Post::where('country_id', $country1->id)->where('code', '1000')->count())->toBe(1); + expect(Post::where('country_id', $country2->id)->where('code', '1000')->count())->toBe(1); +}); + +test('country flag is displayed in table', function () { + $country = Country::factory()->create([ + 'id' => 'SI', + 'name' => 'Slovenia', + 'flag' => '🇸🇮', + ]); + + $post = Post::factory()->create([ + 'country_id' => $country->id, + 'code' => '1000', + 'name' => 'Ljubljana', + ]); + + livewire(ListPosts::class) + ->assertCanSeeTableRecords([$post]) + ->assertSeeHtml('🇸🇮'); +}); + +test('updating post respects unique constraint', function () { + $country = Country::factory()->create(['id' => 'SI']); + + // Create two posts + $post1 = Post::factory()->create([ + 'country_id' => $country->id, + 'code' => '1000', + 'name' => 'Ljubljana', + ]); + + $post2 = Post::factory()->create([ + 'country_id' => $country->id, + 'code' => '2000', + 'name' => 'Maribor', + ]); + + // Try to update post2 to have same code as post1 + livewire(ListPosts::class) + ->callTableAction('edit', $post2, [ + 'code' => '1000', // This should fail + 'name' => 'Updated Name', + ]) + ->assertHasTableActionErrors(['code']); + + // Verify post2 wasn't updated + $post2->refresh(); + expect($post2->code)->toBe('2000'); + expect($post2->name)->toBe('Maribor'); +}); + +test('can update post with same code (no change)', function () { + $country = Country::factory()->create(['id' => 'SI']); + + $post = Post::factory()->create([ + 'country_id' => $country->id, + 'code' => '1000', + 'name' => 'Ljubljana', + ]); + + // Update post name but keep same code (should work) + livewire(ListPosts::class) + ->callTableAction('edit', $post, [ + 'code' => '1000', // Same code + 'name' => 'Updated Ljubljana', + ]) + ->assertHasNoTableActionErrors(); + + $post->refresh(); + expect($post->code)->toBe('1000'); + expect($post->name)->toBe('Updated Ljubljana'); +}); \ No newline at end of file From 1da1a7e4a646620ea0a0116ab5999076f3ee586b Mon Sep 17 00:00:00 2001 From: SlimDeluxe <131700+SlimDeluxe@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:58:29 +0000 Subject: [PATCH 4/4] style: fix code style --- resources/lang/en/posts.php | 2 +- resources/lang/hr/posts.php | 2 +- resources/lang/sl/posts.php | 2 +- resources/lang/sr/posts.php | 2 +- src/Console/Commands/ImportPostsCommand.php | 5 +++-- .../Clusters/World/Resources/PostResource.php | 6 ++--- .../PostResource/Pages/ListPosts.php | 2 +- src/Jobs/ImportPosts.php | 22 +++++++++---------- src/Policies/PostPolicy.php | 2 +- tests/Feature/PostResourceTest.php | 14 ++++++------ 10 files changed, 30 insertions(+), 29 deletions(-) diff --git a/resources/lang/en/posts.php b/resources/lang/en/posts.php index e64e477..e4f1137 100644 --- a/resources/lang/en/posts.php +++ b/resources/lang/en/posts.php @@ -75,4 +75,4 @@ 'HR' => 'Croatia', ], ], -]; \ No newline at end of file +]; diff --git a/resources/lang/hr/posts.php b/resources/lang/hr/posts.php index 285fe02..15f33ee 100644 --- a/resources/lang/hr/posts.php +++ b/resources/lang/hr/posts.php @@ -75,4 +75,4 @@ 'HR' => 'Hrvatska', ], ], -]; \ No newline at end of file +]; diff --git a/resources/lang/sl/posts.php b/resources/lang/sl/posts.php index bfeacfd..5403542 100644 --- a/resources/lang/sl/posts.php +++ b/resources/lang/sl/posts.php @@ -75,4 +75,4 @@ 'HR' => 'Hrvaška', ], ], -]; \ No newline at end of file +]; diff --git a/resources/lang/sr/posts.php b/resources/lang/sr/posts.php index 1353b77..a65b5f3 100644 --- a/resources/lang/sr/posts.php +++ b/resources/lang/sr/posts.php @@ -75,4 +75,4 @@ 'HR' => 'Hrvatska', ], ], -]; \ No newline at end of file +]; diff --git a/src/Console/Commands/ImportPostsCommand.php b/src/Console/Commands/ImportPostsCommand.php index 555099c..d68d3f1 100644 --- a/src/Console/Commands/ImportPostsCommand.php +++ b/src/Console/Commands/ImportPostsCommand.php @@ -28,8 +28,9 @@ public function handle(): int { $country = strtoupper($this->argument('country')); - if (!in_array($country, ['SI', 'HR'])) { + if (! in_array($country, ['SI', 'HR'])) { $this->error('Invalid country code. Only SI and HR are supported.'); + return self::FAILURE; } @@ -42,4 +43,4 @@ public function handle(): int return self::SUCCESS; } -} \ No newline at end of file +} diff --git a/src/Filament/Clusters/World/Resources/PostResource.php b/src/Filament/Clusters/World/Resources/PostResource.php index 0cf5eef..3a0127f 100644 --- a/src/Filament/Clusters/World/Resources/PostResource.php +++ b/src/Filament/Clusters/World/Resources/PostResource.php @@ -20,8 +20,8 @@ use Filament\Tables\Actions\RestoreAction; use Filament\Tables\Actions\RestoreBulkAction; use Filament\Tables\Columns\TextColumn; -use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\SelectFilter; +use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\SoftDeletingScope; @@ -76,7 +76,7 @@ public static function table(Table $table): Table ->columns([ TextColumn::make('country.name') ->label(__('eclipse-world::posts.table.country.label')) - ->formatStateUsing(fn(string $state, Post $record) => trim("{$record->country->flag} {$state}")) + ->formatStateUsing(fn (string $state, Post $record) => trim("{$record->country->flag} {$state}")) ->searchable() ->sortable(), @@ -110,7 +110,7 @@ public static function table(Table $table): Table ForceDeleteAction::make() ->label(__('eclipse-world::posts.actions.force_delete.label')) ->modalHeading(__('eclipse-world::posts.actions.force_delete.heading')) - ->modalDescription(fn(Post $record): string => __('eclipse-world::posts.actions.force_delete.description', [ + ->modalDescription(fn (Post $record): string => __('eclipse-world::posts.actions.force_delete.description', [ 'name' => $record->name, ])), ]), diff --git a/src/Filament/Clusters/World/Resources/PostResource/Pages/ListPosts.php b/src/Filament/Clusters/World/Resources/PostResource/Pages/ListPosts.php index eaa0eaa..e037a45 100644 --- a/src/Filament/Clusters/World/Resources/PostResource/Pages/ListPosts.php +++ b/src/Filament/Clusters/World/Resources/PostResource/Pages/ListPosts.php @@ -43,7 +43,7 @@ protected function getHeaderActions(): array Notification::make() ->title(__('eclipse-world::posts.import.success_title')) ->body(__('eclipse-world::posts.import.success_message', [ - 'country' => __('eclipse-world::posts.import.countries.' . $data['country_id']) + 'country' => __('eclipse-world::posts.import.countries.'.$data['country_id']), ])) ->success() ->send(); diff --git a/src/Jobs/ImportPosts.php b/src/Jobs/ImportPosts.php index 1571bfe..ef19ac4 100644 --- a/src/Jobs/ImportPosts.php +++ b/src/Jobs/ImportPosts.php @@ -3,6 +3,7 @@ namespace Eclipse\World\Jobs; use Eclipse\World\Models\Post; +use Exception; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -10,7 +11,6 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; -use Exception; class ImportPosts implements ShouldQueue { @@ -33,7 +33,7 @@ public function __construct(string $countryId) */ public function handle(): void { - if (!in_array($this->countryId, ['SI', 'HR'])) { + if (! in_array($this->countryId, ['SI', 'HR'])) { throw new Exception("Country {$this->countryId} not supported for import"); } @@ -82,17 +82,17 @@ public function handle(): void private function getData(int $batchSize, int $offset): array { $url = self::OPENDATASOFT_RECORDS_API_URL - . "search/?dataset=geonames-postal-code@public" - . "&q=" - . "&rows={$batchSize}" - . "&start={$offset}" - . "&sort=postal_code" - . "&refine.country_code={$this->countryId}"; + .'search/?dataset=geonames-postal-code@public' + .'&q=' + ."&rows={$batchSize}" + ."&start={$offset}" + .'&sort=postal_code' + ."&refine.country_code={$this->countryId}"; $response = Http::get($url); - if (!$response->successful()) { - throw new Exception("Failed to fetch data from Opendatasoft API: " . $response->status()); + if (! $response->successful()) { + throw new Exception('Failed to fetch data from Opendatasoft API: '.$response->status()); } $data = $response->json(); @@ -129,4 +129,4 @@ private function getRecordData(array $record): array throw new Exception("Country {$this->countryId} not supported"); } } -} \ No newline at end of file +} diff --git a/src/Policies/PostPolicy.php b/src/Policies/PostPolicy.php index cec8bd5..2251a4c 100644 --- a/src/Policies/PostPolicy.php +++ b/src/Policies/PostPolicy.php @@ -81,4 +81,4 @@ public function restoreAny(Authorizable $user): bool { return $user->can('restore_any_post'); } -} \ No newline at end of file +} diff --git a/tests/Feature/PostResourceTest.php b/tests/Feature/PostResourceTest.php index cadce18..73124f6 100644 --- a/tests/Feature/PostResourceTest.php +++ b/tests/Feature/PostResourceTest.php @@ -94,7 +94,7 @@ $post = Post::where('code', $data['code']) ->where('country_id', $data['country_id']) ->first(); - + expect($post)->toBeObject(); foreach ($data as $key => $val) { @@ -202,7 +202,7 @@ test('cannot create duplicate country-post code combo', function () { $country = Country::factory()->create(['id' => 'SI']); - + // Create first post $firstPost = Post::factory()->create([ 'country_id' => $country->id, @@ -265,7 +265,7 @@ 'name' => 'Slovenia', 'flag' => '🇸🇮', ]); - + $post = Post::factory()->create([ 'country_id' => $country->id, 'code' => '1000', @@ -279,14 +279,14 @@ test('updating post respects unique constraint', function () { $country = Country::factory()->create(['id' => 'SI']); - + // Create two posts $post1 = Post::factory()->create([ 'country_id' => $country->id, 'code' => '1000', 'name' => 'Ljubljana', ]); - + $post2 = Post::factory()->create([ 'country_id' => $country->id, 'code' => '2000', @@ -309,7 +309,7 @@ test('can update post with same code (no change)', function () { $country = Country::factory()->create(['id' => 'SI']); - + $post = Post::factory()->create([ 'country_id' => $country->id, 'code' => '1000', @@ -327,4 +327,4 @@ $post->refresh(); expect($post->code)->toBe('1000'); expect($post->name)->toBe('Updated Ljubljana'); -}); \ No newline at end of file +});