From 1029b191a9690e41a72e59bff72dfbed870a5ca7 Mon Sep 17 00:00:00 2001 From: Alex Garrett-Smith Date: Fri, 3 Jul 2026 08:20:25 +0100 Subject: [PATCH 1/3] Add emoji reactions behind a Pennant feature flag Users can toggle emoji reactions on ideas. The whole feature is gated behind a Laravel Pennant 'reactions' flag that defaults to off: the route is protected by EnsureFeaturesAreActive, and the reaction bar only renders when the flag is active. Co-Authored-By: Claude Fable 5 --- app/Http/Controllers/IdeaController.php | 5 + app/Http/Controllers/ReactionController.php | 31 +++++ app/Http/Requests/StoreReactionRequest.php | 20 +++ app/Http/Resources/IdeaResource.php | 9 ++ app/Models/Idea.php | 8 ++ app/Models/Reaction.php | 40 ++++++ app/Providers/AppServiceProvider.php | 4 + composer.json | 1 + composer.lock | 79 ++++++++++- config/pennant.php | 44 ++++++ database/factories/ReactionFactory.php | 26 ++++ ...26_07_02_000000_create_reactions_table.php | 32 +++++ ...026_07_03_071705_create_features_table.php | 32 +++++ resources/js/Components/ReactionBar.vue | 126 ++++++++++++++++++ resources/js/Pages/Ideas/Show.vue | 6 + .../App/Http/Controllers/IdeaController.ts | 22 +-- .../Http/Controllers/ReactionController.ts | 60 +++++++++ .../js/actions/App/Http/Controllers/index.ts | 2 + resources/js/routes/feedback/index.ts | 81 +++++++++-- routes/web.php | 5 + tests/Feature/Ideas/ReactToIdeaTest.php | 85 ++++++++++++ .../Migrations/SplitNameBackfillTest.php | 6 +- 22 files changed, 700 insertions(+), 24 deletions(-) create mode 100644 app/Http/Controllers/ReactionController.php create mode 100644 app/Http/Requests/StoreReactionRequest.php create mode 100644 app/Models/Reaction.php create mode 100644 config/pennant.php create mode 100644 database/factories/ReactionFactory.php create mode 100644 database/migrations/2026_07_02_000000_create_reactions_table.php create mode 100644 database/migrations/2026_07_03_071705_create_features_table.php create mode 100644 resources/js/Components/ReactionBar.vue create mode 100644 resources/js/actions/App/Http/Controllers/ReactionController.ts create mode 100644 tests/Feature/Ideas/ReactToIdeaTest.php diff --git a/app/Http/Controllers/IdeaController.php b/app/Http/Controllers/IdeaController.php index c146e49..50bb149 100644 --- a/app/Http/Controllers/IdeaController.php +++ b/app/Http/Controllers/IdeaController.php @@ -8,6 +8,7 @@ use App\Models\Idea; use Illuminate\Http\RedirectResponse; use Inertia\Response; +use Laravel\Pennant\Feature; class IdeaController extends Controller { @@ -15,6 +16,10 @@ public function show(Idea $idea): Response { $idea->load(['user', 'voters:id', 'subscribers:id', 'latestStatusUpdate.user']); + if (Feature::active('reactions')) { + $idea->load('reactions'); + } + return inertia('Ideas/Show', [ 'idea' => new IdeaResource($idea), 'comments' => CommentResource::collection( 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/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5ce9ddd..b6bf858 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,9 +3,11 @@ namespace App\Providers; use App\Models\Idea; +use App\Models\User; use App\Observers\IdeaObserver; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\ServiceProvider; +use Laravel\Pennant\Feature; class AppServiceProvider extends ServiceProvider { @@ -25,5 +27,7 @@ public function boot(): void JsonResource::withoutWrapping(); Idea::observe(IdeaObserver::class); + + Feature::define('reactions', fn (?User $user) => false); } } diff --git a/composer.json b/composer.json index 161f6b9..5c942ab 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "inertiajs/inertia-laravel": "^2.0", "laravel/fortify": "^1.36", "laravel/framework": "^12.0", + "laravel/pennant": "^1.24", "laravel/tinker": "^2.10.1", "laravel/wayfinder": "^0.1.14", "propaganistas/laravel-disposable-email": "^2.4" diff --git a/composer.lock b/composer.lock index 2e36357..55ea989 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f87340931afe3c4b8174e6dde49b2022", + "content-hash": "a066eedf6b47a447542a042a94f7a00f", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1512,6 +1512,83 @@ }, "time": "2026-02-24T14:35:15+00:00" }, + { + "name": "laravel/pennant", + "version": "v1.24.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pennant.git", + "reference": "b99fbc8038eb2c0a2642e9e13077a75e892b61d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pennant/zipball/b99fbc8038eb2c0a2642e9e13077a75e892b61d9", + "reference": "b99fbc8038eb2c0a2642e9e13077a75e892b61d9", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/container": "^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/finder": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/octane": "^1.4|^2.0", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Feature": "Laravel\\Pennant\\Feature" + }, + "providers": [ + "Laravel\\Pennant\\PennantServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php", + "src/functions.php" + ], + "psr-4": { + "Laravel\\Pennant\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "A simple, lightweight library for managing feature flags.", + "homepage": "https://github.com/laravel/pennant", + "keywords": [ + "feature", + "flags", + "laravel", + "pennant" + ], + "support": { + "issues": "https://github.com/laravel/pennant/issues", + "source": "https://github.com/laravel/pennant" + }, + "time": "2026-06-28T17:14:13+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.13", diff --git a/config/pennant.php b/config/pennant.php new file mode 100644 index 0000000..d03ebfb --- /dev/null +++ b/config/pennant.php @@ -0,0 +1,44 @@ + env('PENNANT_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Pennant Stores + |-------------------------------------------------------------------------- + | + | Here you may configure each of the stores that should be available to + | Pennant. These stores shall be used to store resolved feature flag + | values - you may configure as many as your application requires. + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => null, + 'table' => 'features', + ], + + ], +]; 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/database/migrations/2026_07_03_071705_create_features_table.php b/database/migrations/2026_07_03_071705_create_features_table.php new file mode 100644 index 0000000..a64eea2 --- /dev/null +++ b/database/migrations/2026_07_03_071705_create_features_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->string('scope'); + $table->text('value'); + $table->timestamps(); + + $table->unique(['name', 'scope']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('features'); + } +}; 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/IdeaController.ts b/resources/js/actions/App/Http/Controllers/IdeaController.ts index 1a87918..72cda84 100644 --- a/resources/js/actions/App/Http/Controllers/IdeaController.ts +++ b/resources/js/actions/App/Http/Controllers/IdeaController.ts @@ -1,7 +1,7 @@ import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults } from './../../../../wayfinder' /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:26 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback/create' */ export const create = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -16,7 +16,7 @@ create.definition = { /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:26 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback/create' */ create.url = (options?: RouteQueryOptions) => { @@ -25,7 +25,7 @@ create.url = (options?: RouteQueryOptions) => { /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:26 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback/create' */ create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -35,7 +35,7 @@ create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:26 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback/create' */ create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ @@ -45,7 +45,7 @@ create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ /** * @see \App\Http\Controllers\IdeaController::store -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:36 * @route '/feedback' */ export const store = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ @@ -60,7 +60,7 @@ store.definition = { /** * @see \App\Http\Controllers\IdeaController::store -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:36 * @route '/feedback' */ store.url = (options?: RouteQueryOptions) => { @@ -69,7 +69,7 @@ store.url = (options?: RouteQueryOptions) => { /** * @see \App\Http\Controllers\IdeaController::store -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:36 * @route '/feedback' */ store.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ @@ -79,7 +79,7 @@ store.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:14 +* @see app/Http/Controllers/IdeaController.php:15 * @route '/feedback/{idea}' */ export const show = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -94,7 +94,7 @@ show.definition = { /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:14 +* @see app/Http/Controllers/IdeaController.php:15 * @route '/feedback/{idea}' */ show.url = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions) => { @@ -127,7 +127,7 @@ show.url = (args: { idea: number | { id: number } } | [idea: number | { id: numb /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:14 +* @see app/Http/Controllers/IdeaController.php:15 * @route '/feedback/{idea}' */ show.get = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -137,7 +137,7 @@ show.get = (args: { idea: number | { id: number } } | [idea: number | { id: numb /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:14 +* @see app/Http/Controllers/IdeaController.php:15 * @route '/feedback/{idea}' */ show.head = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ 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..913f39f 100644 --- a/resources/js/routes/feedback/index.ts +++ b/resources/js/routes/feedback/index.ts @@ -2,7 +2,7 @@ import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefa import comments from './comments' /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:26 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback/create' */ export const create = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -17,7 +17,7 @@ create.definition = { /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:26 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback/create' */ create.url = (options?: RouteQueryOptions) => { @@ -26,7 +26,7 @@ create.url = (options?: RouteQueryOptions) => { /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:26 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback/create' */ create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -36,7 +36,7 @@ create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:26 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback/create' */ create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ @@ -46,7 +46,7 @@ create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ /** * @see \App\Http\Controllers\IdeaController::store -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:36 * @route '/feedback' */ export const store = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ @@ -61,7 +61,7 @@ store.definition = { /** * @see \App\Http\Controllers\IdeaController::store -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:36 * @route '/feedback' */ store.url = (options?: RouteQueryOptions) => { @@ -70,7 +70,7 @@ store.url = (options?: RouteQueryOptions) => { /** * @see \App\Http\Controllers\IdeaController::store -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:36 * @route '/feedback' */ store.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ @@ -136,9 +136,67 @@ 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 +* @see app/Http/Controllers/IdeaController.php:15 * @route '/feedback/{idea}' */ export const show = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -153,7 +211,7 @@ show.definition = { /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:14 +* @see app/Http/Controllers/IdeaController.php:15 * @route '/feedback/{idea}' */ show.url = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions) => { @@ -186,7 +244,7 @@ show.url = (args: { idea: number | { id: number } } | [idea: number | { id: numb /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:14 +* @see app/Http/Controllers/IdeaController.php:15 * @route '/feedback/{idea}' */ show.get = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -196,7 +254,7 @@ show.get = (args: { idea: number | { id: number } } | [idea: number | { id: numb /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:14 +* @see app/Http/Controllers/IdeaController.php:15 * @route '/feedback/{idea}' */ show.head = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ @@ -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..b6d937c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,9 +8,11 @@ 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; +use Laravel\Pennant\Middleware\EnsureFeaturesAreActive; Route::get('/', LandingController::class)->name('landing'); Route::get('/about', AboutController::class)->name('about'); @@ -29,6 +31,9 @@ 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) + ->middleware(EnsureFeaturesAreActive::using('reactions')) + ->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..68150c6 --- /dev/null +++ b/tests/Feature/Ideas/ReactToIdeaTest.php @@ -0,0 +1,85 @@ + true); +}); + +// --------------------------------------------------------------------------- +// Authorization +// --------------------------------------------------------------------------- + +it('redirects guests to login', function () { + $idea = Idea::factory()->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(); From 3db23f226522abbcbbdfb076ded34bfdc6e432a2 Mon Sep 17 00:00:00 2001 From: Alex Garrett-Smith Date: Fri, 3 Jul 2026 08:44:47 +0100 Subject: [PATCH 2/3] Add QA checklist job to CI after tests pass Co-Authored-By: Claude Fable 5 --- .github/skills/qa-checklist/SKILL.md | 82 ++++++++++++++++++++++++++++ .github/workflows/tests.yml | 46 ++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 .github/skills/qa-checklist/SKILL.md diff --git a/.github/skills/qa-checklist/SKILL.md b/.github/skills/qa-checklist/SKILL.md new file mode 100644 index 0000000..b46ab59 --- /dev/null +++ b/.github/skills/qa-checklist/SKILL.md @@ -0,0 +1,82 @@ +--- +name: qa-checklist +description: Read the diff for a change and produce a short, manual QA checklist to run against the deployed app — only the behaviour this change actually touched, written as plain-English steps a person can click through. Use when the user asks for a "QA checklist", "manual test plan", "what should I test", "smoke test steps", or how to verify a change by hand after deploy. +--- + +# Manual QA checklist from a diff + +Produce a short checklist a human can click through in the **deployed app** to confirm +this change works. The reader is a person with a browser and a test account — not a +developer reading code. Every step must be something they can do and observe through +the UI (or, rarely, an email inbox / API client if the change only surfaces there). + +This is not a regression suite and not a code review. Cover only what **this diff +changed**, plus the one or two adjacent flows most likely to break as a side effect. +If the checklist grows past ~10 steps, it's too broad — cut it back to what the change +actually touched. + +## 1. Get the diff + +Default to comparing the current branch against `main`; respect any base, commit range, +or PR the user names instead. + +``` +git fetch origin +git diff origin/main...HEAD --stat # overview +git diff origin/main...HEAD # read the whole diff +``` + +Use `...` (merge-base) so you see only what this branch adds. Where the diff alone is +ambiguous, open the touched files — you need to understand the user-visible behaviour, +not just the lines that moved. + +## 2. Translate code changes into user-visible behaviour + +For each change, ask: **"what would a user see or do differently because of this?"** +That question decides what goes on the checklist. + +- **Controllers / routes / pages** — the pages and actions to exercise. New route → visit + it; changed store/update logic → submit the form and check the result. +- **Validation & authorization** — the *failure* paths are QA steps too: submit the + invalid input and confirm the inline error; try the action as a user who shouldn't be + allowed and confirm it's blocked. +- **Frontend components** — the interactions to click: toggles, modals, optimistic + updates, empty states, flash messages. +- **Feature flags** — if behaviour is gated (e.g. Pennant), include checking both sides: + flag on shows the feature, flag off hides it cleanly. Say how to identify a flagged + account if the diff makes that clear. +- **Emails / notifications / jobs** — trigger the action, then check the observable + result (email arrives, notification appears). Skip anything with no observable surface. +- **Migrations / data changes** — only if they change what users see (e.g. a renamed + field on a page). Running migrations belongs in a deploy checklist, not here. + +Skip entirely: refactors with no behaviour change, test-only changes, CI/tooling, code +style. If the whole diff is like that, say so — "nothing to QA manually" is a valid +answer. + +## 3. Write the checklist + +Output a single markdown checklist. Rules: + +- **Plain English, imperative steps** — "Open a feedback item and click the 👍 reaction", + not "Verify ReactionController@store persists the reaction". Never reference class + names, routes-as-code, or files. +- **Each step pairs an action with the expected result**: *do X — you should see Y.* +- **Order steps as a walkthrough**, so a person can do them top to bottom in one session + without backtracking (set up state first, then exercise it, then check side effects). +- **Note required preconditions up front** in one line if any exist: a second test + account, a feature flag enabled, an existing record to act on. +- Keep it to roughly **5–10 checkboxes**. One step per behaviour, not per code path. + +Format: + +```markdown +## QA checklist — + +**Before you start:** + +- [ ] +- [ ] ... +``` + +End with nothing else — no summary of the diff, no code commentary. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5151ebd..8b7a8fb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -78,3 +78,49 @@ jobs: with: header: preflight message: ${{ steps.body.outputs.message }} + + qa-checklist: + needs: tests + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run QA checklist skill + id: qa + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + Read `.github/skills/qa-checklist/SKILL.md` and follow it exactly, + comparing against `origin/${{ github.base_ref }}` as the base branch. + Return ONLY the checklist as the `body` field of the structured output. + You MUST always populate `body`. + claude_args: | + --json-schema '{"type":"object","properties":{"body":{"type":"string"}},"required":["body"]}' + --allowedTools "Bash(git diff:*),Bash(git log:*),Bash(git merge-base:*),Bash(git rev-parse:*),Read,Grep,Glob" + + - name: Build comment body + id: body + env: + STRUCTURED: ${{ steps.qa.outputs.structured_output }} + run: | + message=$(echo "$STRUCTURED" | jq -r '.body') + { + echo 'message<> "$GITHUB_OUTPUT" + + - name: Post sticky comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: qa-checklist + message: ${{ steps.body.outputs.message }} From 7f3ec1aa2e2bc7e8b138fc114d364d13ee39666b Mon Sep 17 00:00:00 2001 From: Alex Garrett-Smith Date: Fri, 3 Jul 2026 08:53:08 +0100 Subject: [PATCH 3/3] Remove the reactions feature flag and ship reactions unconditionally --- app/Http/Controllers/IdeaController.php | 7 +- app/Providers/AppServiceProvider.php | 4 - composer.json | 1 - composer.lock | 79 +------------------ config/pennant.php | 44 ----------- ...026_07_03_071705_create_features_table.php | 32 -------- .../App/Http/Controllers/IdeaController.ts | 22 +++--- resources/js/routes/feedback/index.ts | 22 +++--- routes/web.php | 5 +- tests/Feature/Ideas/ReactToIdeaTest.php | 5 -- 10 files changed, 25 insertions(+), 196 deletions(-) delete mode 100644 config/pennant.php delete mode 100644 database/migrations/2026_07_03_071705_create_features_table.php diff --git a/app/Http/Controllers/IdeaController.php b/app/Http/Controllers/IdeaController.php index 50bb149..c47b40a 100644 --- a/app/Http/Controllers/IdeaController.php +++ b/app/Http/Controllers/IdeaController.php @@ -8,17 +8,12 @@ use App\Models\Idea; use Illuminate\Http\RedirectResponse; use Inertia\Response; -use Laravel\Pennant\Feature; class IdeaController extends Controller { public function show(Idea $idea): Response { - $idea->load(['user', 'voters:id', 'subscribers:id', 'latestStatusUpdate.user']); - - if (Feature::active('reactions')) { - $idea->load('reactions'); - } + $idea->load(['user', 'voters:id', 'subscribers:id', 'latestStatusUpdate.user', 'reactions']); return inertia('Ideas/Show', [ 'idea' => new IdeaResource($idea), diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b6bf858..5ce9ddd 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,11 +3,9 @@ namespace App\Providers; use App\Models\Idea; -use App\Models\User; use App\Observers\IdeaObserver; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\ServiceProvider; -use Laravel\Pennant\Feature; class AppServiceProvider extends ServiceProvider { @@ -27,7 +25,5 @@ public function boot(): void JsonResource::withoutWrapping(); Idea::observe(IdeaObserver::class); - - Feature::define('reactions', fn (?User $user) => false); } } diff --git a/composer.json b/composer.json index 5c942ab..161f6b9 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,6 @@ "inertiajs/inertia-laravel": "^2.0", "laravel/fortify": "^1.36", "laravel/framework": "^12.0", - "laravel/pennant": "^1.24", "laravel/tinker": "^2.10.1", "laravel/wayfinder": "^0.1.14", "propaganistas/laravel-disposable-email": "^2.4" diff --git a/composer.lock b/composer.lock index 55ea989..2e36357 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a066eedf6b47a447542a042a94f7a00f", + "content-hash": "f87340931afe3c4b8174e6dde49b2022", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1512,83 +1512,6 @@ }, "time": "2026-02-24T14:35:15+00:00" }, - { - "name": "laravel/pennant", - "version": "v1.24.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/pennant.git", - "reference": "b99fbc8038eb2c0a2642e9e13077a75e892b61d9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/pennant/zipball/b99fbc8038eb2c0a2642e9e13077a75e892b61d9", - "reference": "b99fbc8038eb2c0a2642e9e13077a75e892b61d9", - "shasum": "" - }, - "require": { - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/container": "^10.0|^11.0|^12.0|^13.0", - "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", - "illuminate/database": "^10.0|^11.0|^12.0|^13.0", - "illuminate/queue": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "php": "^8.1", - "symfony/console": "^6.0|^7.0|^8.0", - "symfony/finder": "^6.0|^7.0|^8.0" - }, - "require-dev": { - "laravel/octane": "^1.4|^2.0", - "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", - "phpstan/phpstan": "^1.10" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Feature": "Laravel\\Pennant\\Feature" - }, - "providers": [ - "Laravel\\Pennant\\PennantServiceProvider" - ] - }, - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "files": [ - "src/helpers.php", - "src/functions.php" - ], - "psr-4": { - "Laravel\\Pennant\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "A simple, lightweight library for managing feature flags.", - "homepage": "https://github.com/laravel/pennant", - "keywords": [ - "feature", - "flags", - "laravel", - "pennant" - ], - "support": { - "issues": "https://github.com/laravel/pennant/issues", - "source": "https://github.com/laravel/pennant" - }, - "time": "2026-06-28T17:14:13+00:00" - }, { "name": "laravel/prompts", "version": "v0.3.13", diff --git a/config/pennant.php b/config/pennant.php deleted file mode 100644 index d03ebfb..0000000 --- a/config/pennant.php +++ /dev/null @@ -1,44 +0,0 @@ - env('PENNANT_STORE', 'database'), - - /* - |-------------------------------------------------------------------------- - | Pennant Stores - |-------------------------------------------------------------------------- - | - | Here you may configure each of the stores that should be available to - | Pennant. These stores shall be used to store resolved feature flag - | values - you may configure as many as your application requires. - | - */ - - 'stores' => [ - - 'array' => [ - 'driver' => 'array', - ], - - 'database' => [ - 'driver' => 'database', - 'connection' => null, - 'table' => 'features', - ], - - ], -]; diff --git a/database/migrations/2026_07_03_071705_create_features_table.php b/database/migrations/2026_07_03_071705_create_features_table.php deleted file mode 100644 index a64eea2..0000000 --- a/database/migrations/2026_07_03_071705_create_features_table.php +++ /dev/null @@ -1,32 +0,0 @@ -id(); - $table->string('name'); - $table->string('scope'); - $table->text('value'); - $table->timestamps(); - - $table->unique(['name', 'scope']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('features'); - } -}; diff --git a/resources/js/actions/App/Http/Controllers/IdeaController.ts b/resources/js/actions/App/Http/Controllers/IdeaController.ts index 72cda84..1a87918 100644 --- a/resources/js/actions/App/Http/Controllers/IdeaController.ts +++ b/resources/js/actions/App/Http/Controllers/IdeaController.ts @@ -1,7 +1,7 @@ import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefaults } from './../../../../wayfinder' /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:26 * @route '/feedback/create' */ export const create = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -16,7 +16,7 @@ create.definition = { /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:26 * @route '/feedback/create' */ create.url = (options?: RouteQueryOptions) => { @@ -25,7 +25,7 @@ create.url = (options?: RouteQueryOptions) => { /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:26 * @route '/feedback/create' */ create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -35,7 +35,7 @@ create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:26 * @route '/feedback/create' */ create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ @@ -45,7 +45,7 @@ create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ /** * @see \App\Http\Controllers\IdeaController::store -* @see app/Http/Controllers/IdeaController.php:36 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback' */ export const store = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ @@ -60,7 +60,7 @@ store.definition = { /** * @see \App\Http\Controllers\IdeaController::store -* @see app/Http/Controllers/IdeaController.php:36 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback' */ store.url = (options?: RouteQueryOptions) => { @@ -69,7 +69,7 @@ store.url = (options?: RouteQueryOptions) => { /** * @see \App\Http\Controllers\IdeaController::store -* @see app/Http/Controllers/IdeaController.php:36 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback' */ store.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ @@ -79,7 +79,7 @@ store.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:15 +* @see app/Http/Controllers/IdeaController.php:14 * @route '/feedback/{idea}' */ export const show = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -94,7 +94,7 @@ show.definition = { /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:15 +* @see app/Http/Controllers/IdeaController.php:14 * @route '/feedback/{idea}' */ show.url = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions) => { @@ -127,7 +127,7 @@ show.url = (args: { idea: number | { id: number } } | [idea: number | { id: numb /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:15 +* @see app/Http/Controllers/IdeaController.php:14 * @route '/feedback/{idea}' */ show.get = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -137,7 +137,7 @@ show.get = (args: { idea: number | { id: number } } | [idea: number | { id: numb /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:15 +* @see app/Http/Controllers/IdeaController.php:14 * @route '/feedback/{idea}' */ show.head = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ diff --git a/resources/js/routes/feedback/index.ts b/resources/js/routes/feedback/index.ts index 913f39f..166d7cc 100644 --- a/resources/js/routes/feedback/index.ts +++ b/resources/js/routes/feedback/index.ts @@ -2,7 +2,7 @@ import { queryParams, type RouteQueryOptions, type RouteDefinition, applyUrlDefa import comments from './comments' /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:26 * @route '/feedback/create' */ export const create = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -17,7 +17,7 @@ create.definition = { /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:26 * @route '/feedback/create' */ create.url = (options?: RouteQueryOptions) => { @@ -26,7 +26,7 @@ create.url = (options?: RouteQueryOptions) => { /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:26 * @route '/feedback/create' */ create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -36,7 +36,7 @@ create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ /** * @see \App\Http\Controllers\IdeaController::create -* @see app/Http/Controllers/IdeaController.php:31 +* @see app/Http/Controllers/IdeaController.php:26 * @route '/feedback/create' */ create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ @@ -46,7 +46,7 @@ create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ /** * @see \App\Http\Controllers\IdeaController::store -* @see app/Http/Controllers/IdeaController.php:36 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback' */ export const store = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ @@ -61,7 +61,7 @@ store.definition = { /** * @see \App\Http\Controllers\IdeaController::store -* @see app/Http/Controllers/IdeaController.php:36 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback' */ store.url = (options?: RouteQueryOptions) => { @@ -70,7 +70,7 @@ store.url = (options?: RouteQueryOptions) => { /** * @see \App\Http\Controllers\IdeaController::store -* @see app/Http/Controllers/IdeaController.php:36 +* @see app/Http/Controllers/IdeaController.php:31 * @route '/feedback' */ store.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({ @@ -196,7 +196,7 @@ react.post = (args: { idea: number | { id: number } } | [idea: number | { id: nu /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:15 +* @see app/Http/Controllers/IdeaController.php:14 * @route '/feedback/{idea}' */ export const show = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -211,7 +211,7 @@ show.definition = { /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:15 +* @see app/Http/Controllers/IdeaController.php:14 * @route '/feedback/{idea}' */ show.url = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions) => { @@ -244,7 +244,7 @@ show.url = (args: { idea: number | { id: number } } | [idea: number | { id: numb /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:15 +* @see app/Http/Controllers/IdeaController.php:14 * @route '/feedback/{idea}' */ show.get = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'get'> => ({ @@ -254,7 +254,7 @@ show.get = (args: { idea: number | { id: number } } | [idea: number | { id: numb /** * @see \App\Http\Controllers\IdeaController::show -* @see app/Http/Controllers/IdeaController.php:15 +* @see app/Http/Controllers/IdeaController.php:14 * @route '/feedback/{idea}' */ show.head = (args: { idea: number | { id: number } } | [idea: number | { id: number } ] | number | { id: number }, options?: RouteQueryOptions): RouteDefinition<'head'> => ({ diff --git a/routes/web.php b/routes/web.php index b6d937c..522d216 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,7 +12,6 @@ use App\Http\Controllers\UnsubscribeController; use App\Http\Controllers\VoteController; use Illuminate\Support\Facades\Route; -use Laravel\Pennant\Middleware\EnsureFeaturesAreActive; Route::get('/', LandingController::class)->name('landing'); Route::get('/about', AboutController::class)->name('about'); @@ -31,9 +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) - ->middleware(EnsureFeaturesAreActive::using('reactions')) - ->name('feedback.react'); + 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 index 68150c6..833414a 100644 --- a/tests/Feature/Ideas/ReactToIdeaTest.php +++ b/tests/Feature/Ideas/ReactToIdeaTest.php @@ -2,14 +2,9 @@ use App\Models\Idea; use App\Models\User; -use Laravel\Pennant\Feature; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); -beforeEach(function () { - Feature::define('reactions', fn () => true); -}); - // --------------------------------------------------------------------------- // Authorization // ---------------------------------------------------------------------------