From 7ba5041ba68367274e249cbd8a203743149b4f59 Mon Sep 17 00:00:00 2001 From: Andrew Gioia Date: Wed, 4 Feb 2026 15:56:28 -0500 Subject: [PATCH] Adds account settings tests and feature test fixes; purges old Breeze profile remnants in favor of account; slight tweaks to event display in time views --- .../Controllers/Auth/PasswordController.php | 4 +- .../Auth/RegisteredUserController.php | 10 ++-- app/Models/User.php | 57 +++++++++++++++++++ .../2025_07_15_000000_create_sabre_schema.php | 29 +++++++--- ..._000000_add_indexes_to_locations_table.php | 18 ++++-- resources/css/etc/layout.css | 23 +++++++- resources/css/lib/calendar.css | 43 +++++++++++--- resources/views/account/edit.blade.php | 38 ------------- .../components/calendar/time/event.blade.php | 1 + resources/views/layouts/navigation.blade.php | 3 - ...rofileTest.php => AccountSettingsTest.php} | 32 ++++++----- tests/Feature/Auth/PasswordUpdateTest.php | 8 +-- 12 files changed, 175 insertions(+), 91 deletions(-) delete mode 100644 resources/views/account/edit.blade.php rename tests/Feature/{ProfileTest.php => AccountSettingsTest.php} (67%) diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php index 6916409..6712835 100644 --- a/app/Http/Controllers/Auth/PasswordController.php +++ b/app/Http/Controllers/Auth/PasswordController.php @@ -20,9 +20,9 @@ class PasswordController extends Controller 'password' => ['required', Password::defaults(), 'confirmed'], ]); - $request->user()->update([ + $request->user()->forceFill([ 'password' => Hash::make($validated['password']), - ]); + ])->save(); return back()->with('status', 'password-updated'); } diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 0739e2e..90fc357 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -35,11 +35,11 @@ class RegisteredUserController extends Controller 'password' => ['required', 'confirmed', Rules\Password::defaults()], ]); - $user = User::create([ - 'name' => $request->name, - 'email' => $request->email, - 'password' => Hash::make($request->password), - ]); + $user = new User(); + $user->name = $request->name; + $user->email = $request->email; + $user->password = $request->password; + $user->save(); event(new Registered($user)); diff --git a/app/Models/User.php b/app/Models/User.php index 5f66828..72e7101 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -30,6 +30,7 @@ class User extends Authenticatable 'firstname', 'lastname', 'displayname', + 'name', 'email', 'timezone', 'phone', @@ -59,6 +60,62 @@ class User extends Authenticatable ]; } + /** + * Expose a Breeze-compatible "name" attribute without a physical column. + * Preference: displayname (explicit override), then first + last, then email. + */ + public function getNameAttribute(): string + { + $displayname = is_string($this->displayname) ? trim($this->displayname) : ''; + if ($displayname !== '') { + return $displayname; + } + + $first = is_string($this->firstname) ? trim($this->firstname) : ''; + $last = is_string($this->lastname) ? trim($this->lastname) : ''; + $full = trim($first . ' ' . $last); + + if ($full !== '') { + return $full; + } + + return (string) ($this->email ?? ''); + } + + /** + * Map "name" writes to first/last names, keeping displayname optional. + */ + public function setNameAttribute(?string $value): void + { + $incoming = trim((string) $value); + + $currentFirst = is_string($this->attributes['firstname'] ?? null) + ? trim((string) $this->attributes['firstname']) + : ''; + $currentLast = is_string($this->attributes['lastname'] ?? null) + ? trim((string) $this->attributes['lastname']) + : ''; + $currentGenerated = trim($currentFirst . ' ' . $currentLast); + + if ($incoming === '') { + $this->attributes['firstname'] = null; + $this->attributes['lastname'] = null; + return; + } + + $parts = preg_split('/\s+/', $incoming, 2); + $this->attributes['firstname'] = $parts[0] ?? null; + $this->attributes['lastname'] = $parts[1] ?? null; + + $displayname = is_string($this->attributes['displayname'] ?? null) + ? trim((string) $this->attributes['displayname']) + : ''; + + if ($displayname !== '' && $displayname === $currentGenerated) { + $this->attributes['displayname'] = $incoming; + } + } + /** * user can own many calendars */ diff --git a/database/migrations/2025_07_15_000000_create_sabre_schema.php b/database/migrations/2025_07_15_000000_create_sabre_schema.php index 3901e95..4043bfa 100644 --- a/database/migrations/2025_07_15_000000_create_sabre_schema.php +++ b/database/migrations/2025_07_15_000000_create_sabre_schema.php @@ -3,6 +3,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Schema; return new class extends Migration { @@ -12,26 +13,38 @@ return new class extends Migration return base_path("vendor/sabre/dav/examples/sql/{$file}"); } + private function prefix(): string + { + $driver = DB::connection()->getDriverName(); + + return match ($driver) { + 'sqlite' => 'sqlite', + 'pgsql' => 'pgsql', + default => 'mysql', + }; + } + public function up(): void { - // Disable FK checks for smooth batch execution - DB::statement('SET FOREIGN_KEY_CHECKS = 0'); + $prefix = $this->prefix(); + Schema::disableForeignKeyConstraints(); // Principals (users & groups) - DB::unprepared(File::get($this->sql('mysql.principals.sql'))); + DB::unprepared(File::get($this->sql("{$prefix}.principals.sql"))); // CalDAV calendars + objects - DB::unprepared(File::get($this->sql('mysql.calendars.sql'))); + DB::unprepared(File::get($this->sql("{$prefix}.calendars.sql"))); // CardDAV address books + cards - DB::unprepared(File::get($this->sql('mysql.addressbooks.sql'))); + DB::unprepared(File::get($this->sql("{$prefix}.addressbooks.sql"))); - DB::statement('SET FOREIGN_KEY_CHECKS = 1'); + Schema::enableForeignKeyConstraints(); } public function down(): void { - DB::statement('SET FOREIGN_KEY_CHECKS = 0'); + $this->prefix(); + Schema::disableForeignKeyConstraints(); // Drop in reverse dependency order DB::statement('DROP TABLE IF EXISTS @@ -47,6 +60,6 @@ return new class extends Migration groupmembers '); - DB::statement('SET FOREIGN_KEY_CHECKS = 1'); + Schema::enableForeignKeyConstraints(); } }; diff --git a/database/migrations/2025_08_20_000000_add_indexes_to_locations_table.php b/database/migrations/2025_08_20_000000_add_indexes_to_locations_table.php index 466f90d..ab0a667 100644 --- a/database/migrations/2025_08_20_000000_add_indexes_to_locations_table.php +++ b/database/migrations/2025_08_20_000000_add_indexes_to_locations_table.php @@ -9,7 +9,9 @@ return new class extends Migration // add composite + geo + optional fulltext indexes to locations public function up(): void { - Schema::table('locations', function (Blueprint $table) { + $driver = Schema::getConnection()->getDriverName(); + + Schema::table('locations', function (Blueprint $table) use ($driver) { // composite btree index for common lookups $table->index( ['display_name', 'city', 'state', 'postal', 'country'], @@ -21,17 +23,23 @@ return new class extends Migration // optional: fulltext index for free-form text searching // note: requires mysql/mariadb version with innodb fulltext support - $table->fullText('raw_address', 'locations_raw_address_fulltext'); + if (in_array($driver, ['mysql', 'pgsql'], true)) { + $table->fullText('raw_address', 'locations_raw_address_fulltext'); + } }); } // drop the indexes added in up() public function down(): void { - Schema::table('locations', function (Blueprint $table) { + $driver = Schema::getConnection()->getDriverName(); + + Schema::table('locations', function (Blueprint $table) use ($driver) { $table->dropIndex('locations_name_city_idx'); $table->dropIndex('locations_lat_lon_idx'); - $table->dropFullText('locations_raw_address_fulltext'); + if (in_array($driver, ['mysql', 'pgsql'], true)) { + $table->dropFullText('locations_raw_address_fulltext'); + } }); } -}; \ No newline at end of file +}; diff --git a/resources/css/etc/layout.css b/resources/css/etc/layout.css index 8cd949f..0d7df8e 100644 --- a/resources/css/etc/layout.css +++ b/resources/css/etc/layout.css @@ -188,9 +188,9 @@ main { container: content / inline-size; /* main content title and actions */ - > header { + header { @apply flex flex-row items-center justify-between w-full; - @apply bg-white sticky top-0 z-10; + @apply bg-white sticky top-0 z-20; /* app hedar; if h1 exists it means there's no aside, so force the width from that */ h1 { @@ -210,7 +210,9 @@ main { /* header menu */ menu { - @apply flex flex-row items-center justify-end gap-4; + @apply fixed right-0 top-2 flex flex-col bg-gray-100 gap-6 p-6 rounded-l-xl; + height: calc(100dvh - 0.5rem); + width: 33dvw; } } @@ -250,6 +252,21 @@ main { } } +/* container sizing */ +@container content (width >= 64rem) +{ + main { + article { + header { + menu { + @apply relative top-auto right-auto h-auto w-auto rounded-none bg-transparent; + @apply flex flex-row items-center justify-end gap-4; + } + } + } + } +} + /* app logo */ .logo { @apply w-10 h-10 flex; diff --git a/resources/css/lib/calendar.css b/resources/css/lib/calendar.css index 146479a..b7afa09 100644 --- a/resources/css/lib/calendar.css +++ b/resources/css/lib/calendar.css @@ -158,7 +158,7 @@ --event-fg: var(--color-primary); li.event { - @apply flex rounded-md relative; + @apply flex rounded-md relative border border-white; background-color: var(--event-bg); color: var(--event-fg); grid-row-start: var(--event-row); @@ -166,22 +166,21 @@ grid-column-start: var(--event-col); grid-column-end: calc(var(--event-col) + 1); top: 0.6rem; - transition: translate 100ms ease-in; - > a { - @apply flex flex-col grow px-3 py-2 gap-2px; + a.event { + @apply flex flex-col grow px-3 py-2 gap-2px text-sm; > span { @apply font-semibold leading-none break-all; } > time { - @apply text-sm; + @apply text-xs; } } &:hover { - @apply -translate-y-2px; + animation: event-hover 125ms ease forwards; } } } @@ -216,19 +215,36 @@ } /* step handling */ -.calendar.time[data-density="30"] { +.calendar.time[data-density="30"] { /* half-hourly */ --row-height: 2rem; ol.time li:nth-child(2n) { visibility: hidden; /* preserves space + row alignment */ } } -.calendar.time[data-density="60"] { +.calendar.time[data-density="60"] { /* hourly */ --row-height: 1.25rem; ol.time li:not(:nth-child(4n + 1)) { visibility: hidden; /* preserves space + row alignment */ } + + &.week { + ol.events { + li.event[data-span="1"] { + a.event > span, + a.event > time { + @apply text-xs; + } + } + li.event[data-span="1"], + li.event[data-span="2"] { + > a.event { + @apply flex-row items-center gap-3; + } + } + } + } } /** @@ -378,7 +394,16 @@ transform: translateX(0); } } - +@keyframes event-hover { + from { + transform: translateY(0); + z-index: 1; + } + to { + transform: translateY(-2px); + z-index: 2; + } +} @keyframes header-slide { from { opacity: 0; diff --git a/resources/views/account/edit.blade.php b/resources/views/account/edit.blade.php deleted file mode 100644 index 1a1cc24..0000000 --- a/resources/views/account/edit.blade.php +++ /dev/null @@ -1,38 +0,0 @@ - - -

- {{ __('Profile') }} -

-
- -
-
-
-
- @include('account.partials.update-profile-information-form') -
-
- -
-
- @include('account.partials.addresses-form', [ - 'home' => $home ?? null, - 'billing' => $billing ?? null, - ]) -
-
- -
-
- @include('account.partials.update-password-form') -
-
- -
-
- @include('account.partials.delete-user-form') -
-
-
-
-
diff --git a/resources/views/components/calendar/time/event.blade.php b/resources/views/components/calendar/time/event.blade.php index 18273ed..fcfdd59 100644 --- a/resources/views/components/calendar/time/event.blade.php +++ b/resources/views/components/calendar/time/event.blade.php @@ -7,6 +7,7 @@ data-calendar-id="{{ $event['calendar_slug'] }}" data-start="{{ $event['start_ui'] }}" data-duration="{{ $event['duration'] }}" + data-span="{{ $event['row_span'] }}" style=" --event-row: {{ $event['start_row'] }}; --event-end: {{ $event['end_row'] }}; diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 701476a..e1e981f 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -28,9 +28,6 @@
- - - diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/AccountSettingsTest.php similarity index 67% rename from tests/Feature/ProfileTest.php rename to tests/Feature/AccountSettingsTest.php index 1536458..aed343c 100644 --- a/tests/Feature/ProfileTest.php +++ b/tests/Feature/AccountSettingsTest.php @@ -2,32 +2,35 @@ use App\Models\User; -test('profile page is displayed', function () { +test('account info page is displayed', function () { $user = User::factory()->create(); $response = $this ->actingAs($user) - ->get('/profile'); + ->get('/account/info'); $response->assertOk(); }); -test('profile information can be updated', function () { +test('account information can be updated', function () { $user = User::factory()->create(); $response = $this ->actingAs($user) - ->patch('/profile', [ - 'name' => 'Test User', + ->patch('/account/info', [ + 'firstname' => 'Test', + 'lastname' => 'User', 'email' => 'test@example.com', ]); $response ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); + ->assertRedirect('/account/info'); $user->refresh(); + $this->assertSame('Test', $user->firstname); + $this->assertSame('User', $user->lastname); $this->assertSame('Test User', $user->name); $this->assertSame('test@example.com', $user->email); $this->assertNull($user->email_verified_at); @@ -38,14 +41,15 @@ test('email verification status is unchanged when the email address is unchanged $response = $this ->actingAs($user) - ->patch('/profile', [ - 'name' => 'Test User', + ->patch('/account/info', [ + 'firstname' => 'Test', + 'lastname' => 'User', 'email' => $user->email, ]); $response ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); + ->assertRedirect('/account/info'); $this->assertNotNull($user->refresh()->email_verified_at); }); @@ -55,13 +59,13 @@ test('user can delete their account', function () { $response = $this ->actingAs($user) - ->delete('/profile', [ + ->delete('/account', [ 'password' => 'password', ]); $response ->assertSessionHasNoErrors() - ->assertRedirect('/'); + ->assertRedirect('/dashboard'); $this->assertGuest(); $this->assertNull($user->fresh()); @@ -72,14 +76,14 @@ test('correct password must be provided to delete account', function () { $response = $this ->actingAs($user) - ->from('/profile') - ->delete('/profile', [ + ->from('/account/delete/confirm') + ->delete('/account', [ 'password' => 'wrong-password', ]); $response ->assertSessionHasErrorsIn('userDeletion', 'password') - ->assertRedirect('/profile'); + ->assertRedirect('/account/delete/confirm'); $this->assertNotNull($user->fresh()); }); diff --git a/tests/Feature/Auth/PasswordUpdateTest.php b/tests/Feature/Auth/PasswordUpdateTest.php index e3d1278..bbc5d38 100644 --- a/tests/Feature/Auth/PasswordUpdateTest.php +++ b/tests/Feature/Auth/PasswordUpdateTest.php @@ -8,7 +8,7 @@ test('password can be updated', function () { $response = $this ->actingAs($user) - ->from('/profile') + ->from('/account/password') ->put('/password', [ 'current_password' => 'password', 'password' => 'new-password', @@ -17,7 +17,7 @@ test('password can be updated', function () { $response ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); + ->assertRedirect('/account/password'); $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); }); @@ -27,7 +27,7 @@ test('correct password must be provided to update password', function () { $response = $this ->actingAs($user) - ->from('/profile') + ->from('/account/password') ->put('/password', [ 'current_password' => 'wrong-password', 'password' => 'new-password', @@ -36,5 +36,5 @@ test('correct password must be provided to update password', function () { $response ->assertSessionHasErrorsIn('updatePassword', 'current_password') - ->assertRedirect('/profile'); + ->assertRedirect('/account/password'); });