diff --git a/app/Http/Controllers/IdeaController.php b/app/Http/Controllers/IdeaController.php index c146e49..c47b40a 100644 --- a/app/Http/Controllers/IdeaController.php +++ b/app/Http/Controllers/IdeaController.php @@ -13,7 +13,7 @@ class IdeaController extends Controller { public function show(Idea $idea): Response { - $idea->load(['user', 'voters:id', 'subscribers:id', 'latestStatusUpdate.user']); + $idea->load(['user', 'voters:id', 'subscribers:id', 'latestStatusUpdate.user', 'reactions']); return inertia('Ideas/Show', [ 'idea' => new IdeaResource($idea), diff --git a/app/Http/Controllers/ReactionController.php b/app/Http/Controllers/ReactionController.php new file mode 100644 index 0000000..983c1fe --- /dev/null +++ b/app/Http/Controllers/ReactionController.php @@ -0,0 +1,31 @@ +user(); + $emoji = $request->validated('emoji'); + + $existing = $idea->reactions() + ->where('user_id', $user->id) + ->where('emoji', $emoji) + ->first(); + + if ($existing) { + $existing->delete(); + } else { + $reaction = $idea->reactions()->make(['emoji' => $emoji]); + $reaction->user()->associate($user); + $reaction->save(); + } + + return redirect()->back(); + } +} diff --git a/app/Http/Requests/StoreReactionRequest.php b/app/Http/Requests/StoreReactionRequest.php new file mode 100644 index 0000000..2bcd93e --- /dev/null +++ b/app/Http/Requests/StoreReactionRequest.php @@ -0,0 +1,20 @@ + + */ + public function rules(): array + { + return [ + 'emoji' => ['required', 'string', Rule::in(Reaction::EMOJIS)], + ]; + } +} diff --git a/app/Http/Resources/IdeaResource.php b/app/Http/Resources/IdeaResource.php index e08c284..9109848 100644 --- a/app/Http/Resources/IdeaResource.php +++ b/app/Http/Resources/IdeaResource.php @@ -3,6 +3,7 @@ namespace App\Http\Resources; use App\Models\Idea; +use App\Models\Reaction; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -23,6 +24,14 @@ public function toArray(Request $request): array 'status' => $this->status, 'votes' => $this->votes, 'has_voted' => $user ? $this->voters->contains('id', $user->id) : false, + 'reactions' => $this->when( + $this->relationLoaded('reactions'), + fn () => collect(Reaction::EMOJIS)->map(fn (string $emoji) => [ + 'emoji' => $emoji, + 'count' => $this->reactions->where('emoji', $emoji)->count(), + 'reacted' => $user ? $this->reactions->where('emoji', $emoji)->contains('user_id', $user->id) : false, + ])->values(), + ), 'is_subscribed' => $user ? $this->subscribers->contains('id', $user->id) : false, 'user' => new UserResource($this->whenLoaded('user')), 'comments_count' => $this->whenCounted('comments'), diff --git a/app/Models/Idea.php b/app/Models/Idea.php index a176042..c030a11 100644 --- a/app/Models/Idea.php +++ b/app/Models/Idea.php @@ -65,6 +65,14 @@ public function comments(): HasMany return $this->hasMany(Comment::class); } + /** + * @return HasMany + */ + public function reactions(): HasMany + { + return $this->hasMany(Reaction::class); + } + /** * @return HasMany */ diff --git a/app/Models/Reaction.php b/app/Models/Reaction.php new file mode 100644 index 0000000..40cbd43 --- /dev/null +++ b/app/Models/Reaction.php @@ -0,0 +1,40 @@ + */ + use HasFactory; + + /** + * The emoji people can react with. + * + * @var list + */ + public const EMOJIS = ['👍', '❤️', '🎉', '🚀', '👀']; + + protected $fillable = [ + 'emoji', + ]; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function idea(): BelongsTo + { + return $this->belongsTo(Idea::class); + } +} diff --git a/database/factories/ReactionFactory.php b/database/factories/ReactionFactory.php new file mode 100644 index 0000000..064c60f --- /dev/null +++ b/database/factories/ReactionFactory.php @@ -0,0 +1,26 @@ + + */ +class ReactionFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'idea_id' => Idea::factory(), + 'user_id' => User::factory(), + 'emoji' => fake()->randomElement(Reaction::EMOJIS), + ]; + } +} diff --git a/database/migrations/2026_07_02_000000_create_reactions_table.php b/database/migrations/2026_07_02_000000_create_reactions_table.php new file mode 100644 index 0000000..6ade52b --- /dev/null +++ b/database/migrations/2026_07_02_000000_create_reactions_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('idea_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('emoji'); + $table->timestamps(); + + $table->unique(['idea_id', 'user_id', 'emoji']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('reactions'); + } +}; diff --git a/resources/js/Components/ReactionBar.vue b/resources/js/Components/ReactionBar.vue new file mode 100644 index 0000000..00ab246 --- /dev/null +++ b/resources/js/Components/ReactionBar.vue @@ -0,0 +1,126 @@ + + + diff --git a/resources/js/Pages/Ideas/Show.vue b/resources/js/Pages/Ideas/Show.vue index f595b94..ee0f5b1 100644 --- a/resources/js/Pages/Ideas/Show.vue +++ b/resources/js/Pages/Ideas/Show.vue @@ -3,6 +3,7 @@ import { computed } from 'vue'; import { usePage, useForm } from '@inertiajs/vue3'; import AppLayout from '@/Layouts/AppLayout.vue'; import VoteButton from '@/Components/VoteButton.vue'; +import ReactionBar from '@/Components/ReactionBar.vue'; import StatusBadge from '@/Components/StatusBadge.vue'; import CommentCard from '@/Components/CommentCard.vue'; import CommentForm from '@/Components/CommentForm.vue'; @@ -84,6 +85,11 @@ const formatDate = (dateString) => {

{{ idea.description }}

+ +
+ + Sign in to react +
diff --git a/resources/js/actions/App/Http/Controllers/ReactionController.ts b/resources/js/actions/App/Http/Controllers/ReactionController.ts new file mode 100644 index 0000000..9104739 --- /dev/null +++ b/resources/js/actions/App/Http/Controllers/ReactionController.ts @@ -0,0 +1,60 @@ +import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults } from './../../../../wayfinder' +/** +* @see \App\Http\Controllers\ReactionController::__invoke +* @see app/Http/Controllers/ReactionController.php:11 +* @route '/feedback/{idea}/reactions' +*/ +const ReactionController = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'post'> => ({ + url: ReactionController.url(args, options), + method: 'post', +}) + +ReactionController.definition = { + methods: ["post"], + url: '/feedback/{idea}/reactions', +} satisfies RouteDefinition<["post"]> + +/** +* @see \App\Http\Controllers\ReactionController::__invoke +* @see app/Http/Controllers/ReactionController.php:11 +* @route '/feedback/{idea}/reactions' +*/ +ReactionController.url = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { idea: args } + } + + if (typeof args === 'object' && !Array.isArray(args) && 'id' in args) { + args = { idea: args.id } + } + + if (Array.isArray(args)) { + args = { + idea: args[0], + } + } + + args = applyUrlDefaults(args) + + const parsedArgs = { + idea: typeof args.idea === 'object' + ? args.idea.id + : args.idea, + } + + return ReactionController.definition.url + .replace('{idea}', parsedArgs.idea.toString()) + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see \App\Http\Controllers\ReactionController::__invoke +* @see app/Http/Controllers/ReactionController.php:11 +* @route '/feedback/{idea}/reactions' +*/ +ReactionController.post = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'post'> => ({ + url: ReactionController.url(args, options), + method: 'post', +}) + +export default ReactionController \ No newline at end of file diff --git a/resources/js/actions/App/Http/Controllers/index.ts b/resources/js/actions/App/Http/Controllers/index.ts index 627b233..bca5786 100644 --- a/resources/js/actions/App/Http/Controllers/index.ts +++ b/resources/js/actions/App/Http/Controllers/index.ts @@ -5,6 +5,7 @@ import AccountSettingsController from './AccountSettingsController' import AccountNotificationsController from './AccountNotificationsController' import IdeaController from './IdeaController' import VoteController from './VoteController' +import ReactionController from './ReactionController' import CommentController from './CommentController' import UnsubscribeController from './UnsubscribeController' import Internal from './Internal' @@ -17,6 +18,7 @@ const Controllers = { AccountNotificationsController: Object.assign(AccountNotificationsController, AccountNotificationsController), IdeaController: Object.assign(IdeaController, IdeaController), VoteController: Object.assign(VoteController, VoteController), + ReactionController: Object.assign(ReactionController, ReactionController), CommentController: Object.assign(CommentController, CommentController), UnsubscribeController: Object.assign(UnsubscribeController, UnsubscribeController), Internal: Object.assign(Internal, Internal), diff --git a/resources/js/routes/feedback/index.ts b/resources/js/routes/feedback/index.ts index 238eb4a..166d7cc 100644 --- a/resources/js/routes/feedback/index.ts +++ b/resources/js/routes/feedback/index.ts @@ -136,6 +136,64 @@ vote.post = (args: { idea: number | { id: number } } | [idea: number | { id: num method: 'post', }) +/** +* @see \App\Http\Controllers\ReactionController::__invoke +* @see app/Http/Controllers/ReactionController.php:11 +* @route '/feedback/{idea}/reactions' +*/ +export const react = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'post'> => ({ + url: react.url(args, options), + method: 'post', +}) + +react.definition = { + methods: ["post"], + url: '/feedback/{idea}/reactions', +} satisfies RouteDefinition<["post"]> + +/** +* @see \App\Http\Controllers\ReactionController::__invoke +* @see app/Http/Controllers/ReactionController.php:11 +* @route '/feedback/{idea}/reactions' +*/ +react.url = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions) => { + if (typeof args === 'string' || typeof args === 'number') { + args = { idea: args } + } + + if (typeof args === 'object' && !Array.isArray(args) && 'id' in args) { + args = { idea: args.id } + } + + if (Array.isArray(args)) { + args = { + idea: args[0], + } + } + + args = applyUrlDefaults(args) + + const parsedArgs = { + idea: typeof args.idea === 'object' + ? args.idea.id + : args.idea, + } + + return react.definition.url + .replace('{idea}', parsedArgs.idea.toString()) + .replace(/\/+$/, '') + queryParams(options) +} + +/** +* @see \App\Http\Controllers\ReactionController::__invoke +* @see app/Http/Controllers/ReactionController.php:11 +* @route '/feedback/{idea}/reactions' +*/ +react.post = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'post'> => ({ + url: react.url(args, options), + method: 'post', +}) + /** * @see \App\Http\Controllers\IdeaController::show * @see app/Http/Controllers/IdeaController.php:14 @@ -273,6 +331,7 @@ const feedback = { create: Object.assign(create, create), store: Object.assign(store, store), vote: Object.assign(vote, vote), + react: Object.assign(react, react), comments: Object.assign(comments, comments), show: Object.assign(show, show), unsubscribe: Object.assign(unsubscribe, unsubscribe), diff --git a/routes/web.php b/routes/web.php index 6dc5faa..522d216 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use App\Http\Controllers\IdeaController; use App\Http\Controllers\Internal; use App\Http\Controllers\LandingController; +use App\Http\Controllers\ReactionController; use App\Http\Controllers\UnsubscribeController; use App\Http\Controllers\VoteController; use Illuminate\Support\Facades\Route; @@ -29,6 +30,7 @@ Route::post('/feedback', [IdeaController::class, 'store'])->name('feedback.store'); Route::post('/feedback/{idea}/vote', VoteController::class)->name('feedback.vote'); + Route::post('/feedback/{idea}/reactions', ReactionController::class)->name('feedback.react'); Route::post('/feedback/{idea}/comments', [CommentController::class, 'store'])->name('feedback.comments.store'); }); diff --git a/tests/Feature/Ideas/ReactToIdeaTest.php b/tests/Feature/Ideas/ReactToIdeaTest.php new file mode 100644 index 0000000..833414a --- /dev/null +++ b/tests/Feature/Ideas/ReactToIdeaTest.php @@ -0,0 +1,80 @@ +for(User::factory())->create(); + + $this->post(route('feedback.react', $idea), ['emoji' => '🎉']) + ->assertRedirect(route('login')); +}); + +// --------------------------------------------------------------------------- +// Reacting +// --------------------------------------------------------------------------- + +it('allows an authenticated user to react', function () { + $user = User::factory()->create(); + $idea = Idea::factory()->for(User::factory())->create(); + + $this->actingAs($user) + ->post(route('feedback.react', $idea), ['emoji' => '🚀']); + + $this->assertDatabaseHas('reactions', [ + 'idea_id' => $idea->id, + 'user_id' => $user->id, + 'emoji' => '🚀', + ]); +}); + +it('toggles the same reaction off on second click', function () { + $user = User::factory()->create(); + $idea = Idea::factory()->for(User::factory())->create(); + + $this->actingAs($user)->post(route('feedback.react', $idea), ['emoji' => '❤️']); + $this->actingAs($user)->post(route('feedback.react', $idea), ['emoji' => '❤️']); + + $this->assertDatabaseMissing('reactions', [ + 'idea_id' => $idea->id, + 'user_id' => $user->id, + 'emoji' => '❤️', + ]); +}); + +it('lets a user add more than one different reaction', function () { + $user = User::factory()->create(); + $idea = Idea::factory()->for(User::factory())->create(); + + $this->actingAs($user)->post(route('feedback.react', $idea), ['emoji' => '👍']); + $this->actingAs($user)->post(route('feedback.react', $idea), ['emoji' => '🎉']); + + expect($idea->reactions()->where('user_id', $user->id)->count())->toBe(2); +}); + +it('rejects an emoji that is not in the allowed set', function () { + $user = User::factory()->create(); + $idea = Idea::factory()->for(User::factory())->create(); + + $this->actingAs($user) + ->post(route('feedback.react', $idea), ['emoji' => '💩']) + ->assertSessionHasErrors('emoji'); + + $this->assertDatabaseCount('reactions', 0); +}); + +it('redirects back after reacting', function () { + $user = User::factory()->create(); + $idea = Idea::factory()->for(User::factory())->create(); + + $this->actingAs($user) + ->from(route('feedback.show', $idea)) + ->post(route('feedback.react', $idea), ['emoji' => '👀']) + ->assertRedirect(route('feedback.show', $idea)); +}); diff --git a/tests/Feature/Migrations/SplitNameBackfillTest.php b/tests/Feature/Migrations/SplitNameBackfillTest.php index 4d58f55..a80357f 100644 --- a/tests/Feature/Migrations/SplitNameBackfillTest.php +++ b/tests/Feature/Migrations/SplitNameBackfillTest.php @@ -7,7 +7,11 @@ uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); beforeEach(function () { - $this->artisan('migrate:rollback', ['--step' => 1])->run(); + // Roll back until the split-name migration is undone, regardless of how + // many later migrations sit on top of it. + do { + $this->artisan('migrate:rollback', ['--step' => 1])->run(); + } while (! Schema::hasColumn('users', 'name')); expect(Schema::hasColumn('users', 'name'))->toBeTrue(); expect(Schema::hasColumn('users', 'first_name'))->toBeFalse();