diff --git a/database/factories/AddressFactory.php b/database/factories/AddressFactory.php new file mode 100644 index 0000000..b28d220 --- /dev/null +++ b/database/factories/AddressFactory.php @@ -0,0 +1,50 @@ + + */ + public function definition(): array + { + $type = match (fake()->numberBetween(1, 3)) { + 1 => [AddressType::COMPANY_ADDRESS->value], + 2 => [AddressType::DEFAULT_ADDRESS->value], + 3 => [AddressType::COMPANY_ADDRESS->value, AddressType::DEFAULT_ADDRESS->value], + }; + + $hasCompanyAddress = in_array(AddressType::COMPANY_ADDRESS->value, $type); + + return [ + 'recipient' => fake()->name(), + 'company_name' => $hasCompanyAddress ? fake()->company() : null, + 'company_vat_id' => $hasCompanyAddress ? fake()->numerify('##########') : null, + 'street_address' => [ + fake()->streetAddress(), + fake()->streetAddress(), + ], + 'postal_code' => fake()->postcode(), + 'city' => fake()->city(), + 'type' => $type, + 'country_id' => Country::inRandomOrder()->first()?->id ?? Country::factory()->create()->id, + 'user_id' => User::inRandomOrder()->first()?->id ?? User::factory()->create()->id, + ]; + } +} diff --git a/database/migrations/2025_06_16_150346_create_user_addresses_table.php b/database/migrations/2025_06_16_150346_create_user_addresses_table.php new file mode 100644 index 0000000..95cb9bc --- /dev/null +++ b/database/migrations/2025_06_16_150346_create_user_addresses_table.php @@ -0,0 +1,44 @@ +id(); + $table->string('recipient', 100); + $table->string('company_name', 100) + ->nullable(); + $table->string('company_vat_id', 50) + ->nullable(); + $table->json('street_address'); + $table->string('postal_code', 50); + $table->string('city', 100); + $table->json('type')->nullable(); + $table->string('country_id', 2); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->softDeletes(); + $table->timestamps(); + $table->foreign('country_id') + ->references('id') + ->on('world_countries'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_addresses'); + } +}; diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 499192c..1e6d45d 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -4,6 +4,7 @@ use Eclipse\Core\Models\Site; use Eclipse\Core\Models\User; +use Eclipse\Core\Models\User\Address; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; @@ -41,6 +42,10 @@ public function run(): void foreach (Site::all() as $site) { $user->sites()->attach($site); + Address::factory()->create([ + 'user_id' => $user->id, + ]); + if (isset($preset['role'])) { setPermissionsTeamId($site->id); $user->assignRole($preset['role'])->save(); diff --git a/database/settings/2025_05_08_075602_create_eclipse_settings.php b/database/settings/2025_05_08_075602_create_eclipse_settings.php index 5cc9fa6..630318a 100644 --- a/database/settings/2025_05_08_075602_create_eclipse_settings.php +++ b/database/settings/2025_05_08_075602_create_eclipse_settings.php @@ -7,5 +7,6 @@ public function up(): void { $this->migrator->add('eclipse.email_verification', false); + $this->migrator->add('eclipse.address_book', false); } }; diff --git a/src/Enums/AddressType.php b/src/Enums/AddressType.php new file mode 100644 index 0000000..8fc65e9 --- /dev/null +++ b/src/Enums/AddressType.php @@ -0,0 +1,28 @@ + 'Default', + self::COMPANY_ADDRESS => 'Company' + }; + } + + public function getDescription(): ?string + { + return match ($this) { + self::DEFAULT_ADDRESS => 'This is the default address', + self::COMPANY_ADDRESS => 'This is a company address' + }; + } +} diff --git a/src/Filament/Pages/ManageEclipse.php b/src/Filament/Pages/ManageEclipse.php index f41e8fb..da5b976 100644 --- a/src/Filament/Pages/ManageEclipse.php +++ b/src/Filament/Pages/ManageEclipse.php @@ -22,6 +22,8 @@ public function form(Form $form): Form ->schema([ Forms\Components\Toggle::make('email_verification') ->label('Enable user email verification'), + Forms\Components\Toggle::make('address_book') + ->label('Enable address book'), ]); } diff --git a/src/Filament/Resources/UserResource.php b/src/Filament/Resources/UserResource.php index 10d5055..c8a9dc8 100644 --- a/src/Filament/Resources/UserResource.php +++ b/src/Filament/Resources/UserResource.php @@ -5,6 +5,7 @@ use BezhanSalleh\FilamentShield\Contracts\HasShieldPermissions; use Eclipse\Core\Filament\Exports\TableExport; use Eclipse\Core\Filament\Resources; +use Eclipse\Core\Filament\Resources\UserResource\RelationManagers\AddressesRelationManager; use Eclipse\Core\Models\User; use Filament\Forms; use Filament\Forms\Components\Actions\Action; @@ -287,7 +288,7 @@ public static function infolist(Infolist $infolist): Infolist public static function getRelations(): array { return [ - // + AddressesRelationManager::class, ]; } diff --git a/src/Filament/Resources/UserResource/Pages/EditUser.php b/src/Filament/Resources/UserResource/Pages/EditUser.php index b40ffdf..0219e66 100644 --- a/src/Filament/Resources/UserResource/Pages/EditUser.php +++ b/src/Filament/Resources/UserResource/Pages/EditUser.php @@ -10,6 +10,16 @@ class EditUser extends EditRecord { protected static string $resource = UserResource::class; + public function hasCombinedRelationManagerTabsWithContent(): bool + { + return true; + } + + public function getContentTabLabel(): ?string + { + return __('Edit User'); + } + protected function getHeaderActions(): array { return [ diff --git a/src/Filament/Resources/UserResource/Pages/ViewUser.php b/src/Filament/Resources/UserResource/Pages/ViewUser.php index d7f450e..39e5d76 100644 --- a/src/Filament/Resources/UserResource/Pages/ViewUser.php +++ b/src/Filament/Resources/UserResource/Pages/ViewUser.php @@ -11,6 +11,16 @@ class ViewUser extends ViewRecord { protected static string $resource = UserResource::class; + public function hasCombinedRelationManagerTabsWithContent(): bool + { + return true; + } + + public function getContentTabLabel(): ?string + { + return __('View User'); + } + protected function getHeaderActions(): array { return [ diff --git a/src/Filament/Resources/UserResource/RelationManagers/AddressesRelationManager.php b/src/Filament/Resources/UserResource/RelationManagers/AddressesRelationManager.php new file mode 100644 index 0000000..b21748d --- /dev/null +++ b/src/Filament/Resources/UserResource/RelationManagers/AddressesRelationManager.php @@ -0,0 +1,197 @@ +address_book ?? false; + + return $isAddressBookEnabled; + } + + public static function getAddressForm(): array + { + return [ + Forms\Components\CheckboxList::make('type') + ->live() + ->default([AddressType::DEFAULT_ADDRESS->value]) + ->options(AddressType::class) + ->columns(2), + Forms\Components\TextInput::make('recipient') + ->maxLength(100) + ->label('Full name') + ->required(), + Forms\Components\TextInput::make('company_name') + ->visible(fn (Get $get): bool => in_array(AddressType::COMPANY_ADDRESS->value, $get('type') ?? [])) + ->required() + ->maxLength(100), + Forms\Components\TextInput::make('company_vat_id') + ->visible(fn (Get $get): bool => in_array(AddressType::COMPANY_ADDRESS->value, $get('type') ?? [])) + ->label('Company VAT ID') + ->maxLength(50), + Forms\Components\Repeater::make('street_address') + ->minItems(1) + ->maxItems(3) + ->required() + ->simple( + Forms\Components\TextInput::make('street_address') + ->maxLength(255) + ->required() + ) + ->addActionLabel(__('Add address line')), + Forms\Components\Split::make([ + Forms\Components\TextInput::make('postal_code') + ->required() + ->maxLength(50), + Forms\Components\TextInput::make('city') + ->required() + ->maxLength(100), + ]), + Forms\Components\Select::make('country_id') + ->required() + ->relationship('country', 'name'), + ]; + } + + public static function getAddressInfolist(): array + { + return [ + Infolists\Components\Grid::make()->schema([ + Infolists\Components\TextEntry::make('type') + ->badge() + ->formatStateUsing(fn ($state) => self::formatAddressTypeLabels($state)), + Infolists\Components\TextEntry::make('recipient'), + Infolists\Components\TextEntry::make('company_name') + ->visible(fn ($record): bool => self::hasCompanyAddress($record->type)), + Infolists\Components\TextEntry::make('company_vat_id') + ->visible(fn ($record): bool => self::hasCompanyAddress($record->type)) + ->default('-') + ->label('Company VAT ID'), + Infolists\Components\TextEntry::make('street_address') + ->listWithLineBreaks(), + Infolists\Components\TextEntry::make('country') + ->formatStateUsing(fn ($state) => "{$state->name} {$state->flag}"), + Infolists\Components\Split::make([ + Infolists\Components\TextEntry::make('postal_code') + ->badge() + ->color('warning'), + Infolists\Components\TextEntry::make('city'), + ])->columnSpanFull(), + ]), + ]; + } + + private static function hasCompanyAddress($types): bool + { + if (! is_array($types)) { + return false; + } + + return collect($types)->contains(function ($type) { + return ($type instanceof AddressType && $type === AddressType::COMPANY_ADDRESS) || + $type === AddressType::COMPANY_ADDRESS->value; + }); + } + + private static function formatAddressTypeLabels($state): string + { + if (is_array($state)) { + return collect($state)->map(function ($type) { + if (is_string($type)) { + return AddressType::from($type)->getLabel(); + } + + return $type->getLabel(); + })->join(', '); + } + + if (is_string($state)) { + return AddressType::from($state)->getLabel(); + } + + return $state->getLabel(); + } + + public function table(Table $table): Table + { + return $table + ->modifyQueryUsing(fn (Builder $query) => $query->with(['country'])) + ->columns([ + Tables\Columns\TextColumn::make('recipient') + ->weight(FontWeight::Bold) + ->description(function ($record) { + $recipient = []; + + if ($record->company_name) { + $recipient[] = $record->company_name; + } + + $recipient[] = implode('
', $record->street_address); + + $recipient[] = "{$record->country->flag} {$record->country->name}"; + + return new HtmlString(implode('
', $recipient)); + }) + ->searchable(['recipient', 'company_name', 'street_address', 'country_id']), + Tables\Columns\TextColumn::make('company_vat_id') + ->placeholder('-') + ->label('Company VAT ID'), + Tables\Columns\TextColumn::make('type') + ->badge() + ->placeholder('-') + ->formatStateUsing(fn ($state) => self::formatAddressTypeLabels($state)), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('type') + ->options(AddressType::class) + ->query(function (Builder $query, array $data): Builder { + if (filled($data['value'])) { + return $query->whereJsonContains('type', $data['value']); + } + + return $query; + }), + Tables\Filters\TrashedFilter::make(), + ]) + ->actions([ + Tables\Actions\ViewAction::make() + ->infolist(self::getAddressInfolist()), + Tables\Actions\EditAction::make() + ->form(self::getAddressForm()), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\ForceDeleteBulkAction::make(), + Tables\Actions\RestoreBulkAction::make(), + ]), + ]) + ->headerActions([ + Tables\Actions\CreateAction::make() + ->label('Add new address') + ->icon('heroicon-o-plus-circle') + ->form(self::getAddressForm()), + ]); + } +} diff --git a/src/Models/User.php b/src/Models/User.php index de62e58..aa067f6 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -3,6 +3,7 @@ namespace Eclipse\Core\Models; use Eclipse\Core\Database\Factories\UserFactory; +use Eclipse\Core\Models\User\Address; use Eclipse\Core\Settings\UserSettings; use Eclipse\World\Models\Country; use Exception; @@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -95,6 +97,11 @@ public function sites() return $this->belongsToMany(Site::class, 'site_has_user'); } + public function addresses(): HasMany + { + return $this->hasMany(Address::class); + } + public function country(): BelongsTo { return $this->belongsTo(Country::class); diff --git a/src/Models/User/Address.php b/src/Models/User/Address.php new file mode 100644 index 0000000..1e2edb8 --- /dev/null +++ b/src/Models/User/Address.php @@ -0,0 +1,102 @@ + + */ + protected $fillable = [ + 'recipient', + 'company_name', + 'company_vat_id', + 'street_address', + 'postal_code', + 'city', + 'type', + 'country_id', + 'user_id', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'street_address' => 'array', + 'type' => 'array', + 'user_id' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function country(): BelongsTo + { + return $this->belongsTo(Country::class); + } + + protected static function newFactory(): AddressFactory + { + return AddressFactory::new(); + } + + protected static function booted(): void + { + static::saving(function (self $address): void { + if (! in_array(AddressType::DEFAULT_ADDRESS->value, $address->type)) { + return; + } + + $otherAddresses = self::where('user_id', $address->user_id) + ->where('id', '!=', $address->id ?? 0) + ->get(['id', 'type']); + + $addressesToUpdate = $otherAddresses->filter( + fn (Model $existingAddress): bool => in_array(AddressType::DEFAULT_ADDRESS->value, $existingAddress->type ?? []) + ); + + $addressesToUpdate->each(function (Model $existingAddress): void { + $newType = array_values(array_diff($existingAddress->type, [AddressType::DEFAULT_ADDRESS->value])); + + $existingAddress->timestamps = false; + $existingAddress->updateQuietly([ + 'type' => $newType, + ]); + }); + }); + + static::deleted(function (self $address): void { + if (! in_array(AddressType::DEFAULT_ADDRESS->value, $address->type)) { + return; + } + + self::where('user_id', $address->user_id) + ->orderBy('created_at', 'asc') + ->first(['id', 'type']) + ?->updateQuietly([ + 'type' => array_merge($address->type ?? [], [AddressType::DEFAULT_ADDRESS->value]), + ]); + }); + } +} diff --git a/src/Settings/EclipseSettings.php b/src/Settings/EclipseSettings.php index 2a65acd..6463a68 100644 --- a/src/Settings/EclipseSettings.php +++ b/src/Settings/EclipseSettings.php @@ -8,6 +8,8 @@ class EclipseSettings extends Settings { public bool $email_verification = false; + public bool $address_book = false; + public static function group(): string { return 'eclipse'; diff --git a/tests/Feature/Filament/RelationManagers/AddressesRelationManagerTest.php b/tests/Feature/Filament/RelationManagers/AddressesRelationManagerTest.php new file mode 100644 index 0000000..53dbe9a --- /dev/null +++ b/tests/Feature/Filament/RelationManagers/AddressesRelationManagerTest.php @@ -0,0 +1,243 @@ +set_up_super_admin_and_tenant(); + $this->undoRepeaterFake = Repeater::fake(); +}); + +afterEach(function () { + ($this->undoRepeaterFake)(); +}); + +function prepareFactoryDataForForm(): array +{ + $data = Address::factory()->definition(); + unset($data['user_id']); + + $data['street_address'] = collect($data['street_address']) + ->map(fn ($address) => ['street_address' => $address]) + ->toArray(); + + return $data; +} + +it('can render address relation manager', function (): void { + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $this->superAdmin, + 'pageClass' => EditUser::class, + ])->assertSuccessful(); +}); + +it('can create address', function (): void { + $data = prepareFactoryDataForForm(); + + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $this->superAdmin, + 'pageClass' => EditUser::class, + ]) + ->callTableAction('create', data: $data) + ->assertHasNoTableActionErrors(); + + expect($this->superAdmin->addresses()->count())->toBe(1); +}); + +it('can edit address', function (): void { + $address = Address::factory()->for($this->superAdmin)->create(); + + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $this->superAdmin, + 'pageClass' => EditUser::class, + ]) + ->callTableAction('edit', $address, data: ['recipient' => 'Updated Name']) + ->assertHasNoTableActionErrors(); + + expect($address->fresh()->recipient)->toBe('Updated Name'); +}); + +it('can delete address', function (): void { + $address = Address::factory()->for($this->superAdmin)->create(); + + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $this->superAdmin, + 'pageClass' => EditUser::class, + ]) + ->callTableAction('delete', $address) + ->assertHasNoTableActionErrors(); + + expect($this->superAdmin->fresh()->addresses)->toHaveCount(0); +}); + +it('can view address', function (): void { + $address = Address::factory()->for($this->superAdmin)->create(); + + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $this->superAdmin, + 'pageClass' => EditUser::class, + ]) + ->callTableAction('view', $address) + ->assertHasNoTableActionErrors(); +}); + +it('can bulk delete addresses', function (): void { + $addresses = Address::factory()->count(3)->for($this->superAdmin)->create(); + + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $this->superAdmin, + 'pageClass' => EditUser::class, + ]) + ->callTableBulkAction('delete', $addresses) + ->assertHasNoTableBulkActionErrors(); + + expect($this->superAdmin->fresh()->addresses)->toHaveCount(0); +}); + +it('can soft delete and restore address', function (): void { + $address = Address::factory()->for($this->superAdmin)->create(); + + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $this->superAdmin, + 'pageClass' => EditUser::class, + ]) + ->callTableBulkAction('delete', [$address]) + ->assertHasNoTableBulkActionErrors(); + + expect($address->fresh()->trashed())->toBeTrue(); + expect($this->superAdmin->addresses()->count())->toBe(0); // Active count + expect($this->superAdmin->addresses()->withTrashed()->count())->toBe(1); // Total count + + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $this->superAdmin, + 'pageClass' => EditUser::class, + ]) + ->filterTable('trashed', 'with') + ->callTableBulkAction('restore', [$address]) + ->assertHasNoTableBulkActionErrors(); + + expect($address->fresh()->trashed())->toBeFalse(); + expect($this->superAdmin->addresses()->count())->toBe(1); +}); + +it('can force delete address', function (): void { + $address = Address::factory()->for($this->superAdmin)->create(); + + $address->delete(); + + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $this->superAdmin, + 'pageClass' => EditUser::class, + ]) + ->filterTable('trashed', 'only') + ->callTableBulkAction('forceDelete', [$address]) + ->assertHasNoTableBulkActionErrors(); + + expect(Address::withTrashed()->find($address->id))->toBeNull(); +}); + +it('can filter addresses by type', function (): void { + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $this->superAdmin, + 'pageClass' => EditUser::class, + ]) + ->filterTable('type', AddressType::DEFAULT_ADDRESS->value) + ->assertSuccessful(); +}); + +it('each user can edit only his own addresses', function (): void { + $otherUser = User::factory()->create(); + $otherUserAddress = Address::factory()->for($otherUser)->create(); + + $userAddress = Address::factory()->for($this->superAdmin)->create(); + + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $this->superAdmin, + 'pageClass' => EditUser::class, + ]) + ->assertCountTableRecords(1) + ->assertSeeText($userAddress->recipient) + ->assertDontSeeText($otherUserAddress->recipient); + + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $otherUser, + 'pageClass' => EditUser::class, + ]) + ->assertCountTableRecords(1) + ->assertSeeText($otherUserAddress->recipient) + ->assertDontSeeText($userAddress->recipient); +}); + +it('admins with user update permission can edit addresses for any user', function (): void { + $regularUser = User::factory()->create(); + $regularUserAddress = Address::factory()->for($regularUser)->create(); + + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $regularUser, + 'pageClass' => EditUser::class, + ]) + ->callTableAction('edit', $regularUserAddress, data: ['recipient' => 'Admin Updated']) + ->assertHasNoTableActionErrors(); + + expect($regularUserAddress->fresh()->recipient)->toBe('Admin Updated'); +}); + +it('only one address can be default - new default unsets old one', function (): void { + $firstAddress = Address::factory()->for($this->superAdmin)->create([ + 'type' => [AddressType::DEFAULT_ADDRESS->value], + ]); + + $secondAddress = Address::factory()->for($this->superAdmin)->create([ + 'type' => [AddressType::COMPANY_ADDRESS->value], + ]); + + $secondAddress->type = [AddressType::DEFAULT_ADDRESS->value]; + $secondAddress->save(); + + $firstRefreshed = $firstAddress->fresh(); + $secondRefreshed = $secondAddress->fresh(); + + $hasDefault = in_array(AddressType::DEFAULT_ADDRESS->value, $secondRefreshed->type ?? []); + + expect($hasDefault)->toBeTrue('Manual check should pass'); + + expect($secondRefreshed->type)->toContain('default_address'); + + assertContains(AddressType::DEFAULT_ADDRESS->value, $secondRefreshed->type, 'PHPUnit assertion should work'); + + expect($firstRefreshed->type)->not->toContain(AddressType::DEFAULT_ADDRESS->value); + + $defaultCount = $this->superAdmin->addresses()->get()->filter(function ($address) { + return in_array(AddressType::DEFAULT_ADDRESS->value, $address->type ?? []); + })->count(); + + expect($defaultCount)->toBe(1, 'Should have exactly one default address'); +}); + +it('when deleting default address the oldest becomes default', function (): void { + $oldestAddress = Address::factory()->for($this->superAdmin)->create([ + 'type' => [AddressType::COMPANY_ADDRESS->value], + 'created_at' => now()->subDays(5), + ]); + + $defaultAddress = Address::factory()->for($this->superAdmin)->create([ + 'type' => [AddressType::DEFAULT_ADDRESS->value], + 'created_at' => now(), + ]); + + livewire(AddressesRelationManager::class, [ + 'ownerRecord' => $this->superAdmin, + 'pageClass' => EditUser::class, + ]) + ->callTableAction('delete', $defaultAddress) + ->assertHasNoTableActionErrors(); + + expect($oldestAddress->fresh()->type)->toContain(AddressType::DEFAULT_ADDRESS->value); +});