Skip to content

Commit

Permalink
fix: Improved feature banner persistence
Browse files Browse the repository at this point in the history
  • Loading branch information
lewislarsen committed Sep 6, 2024
1 parent 2fb9a74 commit d84a232
Show file tree
Hide file tree
Showing 6 changed files with 320 additions and 40 deletions.
39 changes: 21 additions & 18 deletions app/Livewire/Other/NewFeatureBanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

namespace App\Livewire\Other;

use App\Models\UserDismissal;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
use Illuminate\View\View;
use Livewire\Component;

Expand All @@ -15,18 +16,11 @@
*
* This Livewire component fetches the latest feature from cache,
* displays it to the user, and allows them to dismiss it. The dismissal
* state is stored in the user's session to prevent the same feature
* state is stored using the UserDismissal model to prevent the same feature
* from being shown repeatedly.
*/
class NewFeatureBanner extends Component
{
/**
* The session key used to store the dismissed feature version.
*
* @var string
*/
private const string SESSION_KEY = 'dismissed_feature_version';

/**
* The latest feature to be displayed in the banner.
*
Expand Down Expand Up @@ -59,13 +53,20 @@ public function render(): View
* Dismiss the currently displayed feature.
*
* This method is called when the user chooses to dismiss the feature banner.
* It stores the dismissed version in the session and clears the latestFeature property.
* It creates a new UserDismissal record and clears the latestFeature property.
*/
public function dismiss(): void
{
if ($this->latestFeature) {
Session::put(self::SESSION_KEY, $this->latestFeature['version'] ?? 'unknown');
$this->latestFeature = null;
if ($this->latestFeature && Auth::check()) {
$userId = Auth::id();
if (is_int($userId)) {
UserDismissal::dismiss(
$userId,
'feature',
$this->latestFeature['version'] ?? 'unknown'
);
$this->latestFeature = null;
}
}
$this->dispatch('featureDismissed');
}
Expand All @@ -74,8 +75,8 @@ public function dismiss(): void
* Load the latest feature from cache if it hasn't been dismissed.
*
* This method checks the cache for the latest feature and compares it
* against the dismissed version stored in the session. If the feature
* is new or hasn't been dismissed, it's loaded into the component state.
* against the user's dismissals. If the feature is new or hasn't been
* dismissed, it's loaded into the component state.
*/
private function loadLatestFeature(): void
{
Expand All @@ -89,11 +90,13 @@ private function loadLatestFeature(): void
return;
}

$dismissedVersion = Session::get(self::SESSION_KEY);
$currentVersion = $cachedFeature['version'] ?? 'unknown';

if ($dismissedVersion !== $currentVersion) {
$this->latestFeature = $cachedFeature;
if (Auth::check()) {
$userId = Auth::id();
if (is_int($userId) && ! UserDismissal::isDismissed($userId, 'feature', $currentVersion)) {
$this->latestFeature = $cachedFeature;
}
}
}
}
106 changes: 106 additions & 0 deletions app/Models/UserDismissal.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

namespace App\Models;

use Database\Factories\UserDismissalFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
* Represents a user's dismissal or completion of various features or guides.
*
* This model can be used to track when a user has dismissed a feature,
* completed an intro guide, or any similar action that should be remembered.
*
* @method static Builder|UserDismissal ofType(string $type)
* @method static UserDismissalFactory factory(...$parameters)
*/
class UserDismissal extends Model
{
/** @use HasFactory<UserDismissalFactory> */
use HasFactory;

/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'dismissable_type',
'dismissable_id',
'dismissed_at',
];

/**
* Check if a specific item has been dismissed by the user.
*/
public static function isDismissed(int $userId, string $type, string|int $id): bool
{
return static::where('user_id', $userId)
->where('dismissable_type', $type)
->where('dismissable_id', $id)
->exists();
}

/**
* Dismiss a specific item for a user.
*/
public static function dismiss(int $userId, string $type, string|int $id): static
{
/** @var static $dismissal */
$dismissal = static::create([
'user_id' => $userId,
'dismissable_type' => $type,
'dismissable_id' => $id,
'dismissed_at' => now(),
]);

return $dismissal;
}

/**
* Create a new factory instance for the model.
*/
protected static function newFactory(): UserDismissalFactory
{
return UserDismissalFactory::new();
}

/**
* Get the user that owns the dismissal.
*
* @return BelongsTo<User, UserDismissal>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

/**
* Scope a query to only include dismissals of a specific type.
*
* @param Builder<UserDismissal> $builder
* @return Builder<UserDismissal>
*/
public function scopeOfType(Builder $builder, string $type): Builder
{
return $builder->where('dismissable_type', $type);
}

/**
* The attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'dismissed_at' => 'datetime',
];
}
}
48 changes: 48 additions & 0 deletions database/factories/UserDismissalFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Database\Factories;

use App\Models\User;
use App\Models\UserDismissal;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserDismissal>
*/
class UserDismissalFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = UserDismissal::class;

/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'dismissable_type' => $this->faker->randomElement(['feature']),
'dismissable_id' => $this->faker->word(),
'dismissed_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
];
}

/**
* Indicate that the dismissal is for a feature.
*/
public function feature(): Factory
{
return $this->state(function (array $attributes) {
return [
'dismissable_type' => 'feature',
'dismissable_id' => $this->faker->randomElement(['new_backup_system', 'dark_mode', 'api_integration']),
];
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('user_dismissals', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('dismissable_type');
$table->string('dismissable_id');
$table->timestamp('dismissed_at');
$table->timestamps();

$table->unique(['user_id', 'dismissable_type', 'dismissable_id']);
$table->index(['dismissable_type', 'dismissable_id']);
});
}

public function down(): void
{
Schema::dropIfExists('user_dismissals');
}
};
Loading

0 comments on commit d84a232

Please sign in to comment.