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

This commit is contained in:
Andrew Gioia 2026-02-04 15:56:28 -05:00
parent 9957e8d8a1
commit 7ba5041ba6
Signed by: andrew
GPG Key ID: FC09694A000800C8
12 changed files with 175 additions and 91 deletions

View File

@ -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');
}

View File

@ -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));

View File

@ -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
*/

View File

@ -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();
}
};

View File

@ -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');
}
});
}
};

View File

@ -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;

View File

@ -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;

View File

@ -1,38 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Profile') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.update-profile-information-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.addresses-form', [
'home' => $home ?? null,
'billing' => $billing ?? null,
])
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.update-password-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.delete-user-form')
</div>
</div>
</div>
</div>
</x-app-layout>

View File

@ -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'] }};

View File

@ -28,9 +28,6 @@
<!-- bottom -->
<section class="bottom">
<x-button.icon type="anchor" :href="route('settings')">
<x-icon-settings class="w-7 h-7" />
</x-button.icon>
<x-button.icon type="anchor" :href="route('account.index')">
<x-icon-user-circle class="w-7 h-7" />
</x-button.icon>

View File

@ -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());
});

View File

@ -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');
});