-
Notifications
You must be signed in to change notification settings - Fork 0
Resource edits tab #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…mputerScienceResources.com into resource-edits-tab
…mputerScienceResources.com into resource-edits-tab
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds a new Resource Edits index page and refactors the polymorphic relationship handling by replacing the custom ModelResolverService with Laravel's built-in morph map functionality. It also implements automatic upvoting for newly created content (resources, reviews, edits, and comments).
Key changes:
- Introduces a Resource Edits index page with sorting and pagination functionality
- Replaces custom ModelResolverService with Laravel's native
Relation::enforceMorphMap()for cleaner polymorphic relationship handling - Implements auto-upvoting feature for all newly created upvotable content
- Adds comprehensive test coverage for auto-upvote functionality
Reviewed changes
Copilot reviewed 37 out of 38 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| app/Providers/AppServiceProvider.php | Configures Laravel's morph map with aliases for polymorphic relationships |
| app/Services/ModelResolverService.php | Removed - replaced by Laravel's native morph map functionality |
| app/Providers/ModelResolverServiceProvider.php | Removed - no longer needed after service removal |
| app/Services/ResourceEditsService.php | Added methods for creating edits, merging edits, and retrieving index data with sorting |
| app/Services/ComputerScienceResourceService.php | Removed database transaction wrapping (now handled in controller) |
| app/Services/CommentService.php | Updated to use Relation::getMorphedModel() instead of ModelResolverService |
| app/Services/UpvoteService.php | Updated to use morph map for model resolution |
| app/Traits/HasVotes.php | Updated to use getMorphClass() for consistent morph type handling |
| app/Traits/HasComments.php | Updated to use getMorphClass() for polymorphic relationships |
| app/SortingStrategies/VoteSortingStrategy.php | Updated to use morph class instead of full class name |
| app/Http/Controllers/ResourceEditsController.php | Refactored to delegate business logic to service layer and added index method |
| app/Http/Controllers/ComputerScienceResourceController.php | Added transaction handling and auto-upvote call after resource creation |
| app/Http/Controllers/ResourceReviewController.php | Added auto-upvote call after review creation |
| app/Http/Controllers/CommentController.php | Added auto-upvote call after comment creation |
| app/Http/Requests/StoreCommentRequest.php | Removed unnecessary ModelResolverService dependency |
| database/migrations/2025_12_25_000000_convert_morph_types_to_aliases.php | Migration to convert existing database records from full class names to morph aliases |
| database/factories/CommentFactory.php | Updated to use morph map for polymorphic type generation |
| bootstrap/providers.php | Removed ModelResolverServiceProvider registration |
| routes/web.php | Added route for resource edits index page |
| resources/js/Pages/ResourceEdits/Index.vue | New page component for browsing and sorting resource edits |
| resources/js/Pages/AboutUs.vue | Removed FAQ about open source status and added link to rules page |
| resources/js/Components/Resources/ResourceEdit/ResourceEditsTable.vue | New component displaying resource edits with pagination and voting |
| resources/js/Components/Upvote/UpvoteSorting.vue | Refactored to be reusable across different pages with configurable routes |
| resources/js/Components/Resources/ResourceTabs.vue | Updated to use refactored UpvoteSorting component |
| resources/js/Components/Resources/FilterBar.vue | Fixed indentation inconsistencies |
| resources/js/Components/Navigation/Navbar.vue | Added Resource Edits to navigation menu with dropdown |
| resources/js/Components/FrequentlyAskedQuestion.vue | Changed heading level from h3 to h2 for better semantic structure |
| resources/js/Components/ApplicationTitleLogo.vue | New component for displaying application logo |
| app/Events/CommentService.php | Duplicate CommentService file (should be removed) |
| tests/Feature/* | Updated all tests to use morph aliases instead of full class names and added auto-upvote tests |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
app/Events/CommentService.php
Outdated
| <?php | ||
|
|
||
| namespace App\Services; | ||
|
|
||
| use App\Http\Resources\CommentResource; | ||
| use App\Http\Resources\UserResource; | ||
| use App\Models\Comment; | ||
| use App\Services\SortingManagers\GeneralVotesSortingManager; | ||
| use Exception; | ||
| use Illuminate\Database\Eloquent\Collection; | ||
| use Illuminate\Database\Eloquent\Relations\Relation; | ||
| use Illuminate\Support\Facades\Auth; | ||
| use Illuminate\Support\Facades\DB; | ||
| use Illuminate\Support\Facades\Log; | ||
| use Illuminate\Support\Facades\Validator; | ||
| use Illuminate\Validation\Rule; | ||
| use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; | ||
|
|
||
| class CommentService | ||
| { | ||
| /** | ||
| * Get paginated comments with custom logic. | ||
| * | ||
| * @param string $commentableType | ||
| */ | ||
| public function getPaginatedComments(string $commentableKey, int $commentableId, int $index, int $paginationLimit = -1, string $sortBy = 'top'): array | ||
| { | ||
| if ($paginationLimit == -1) { | ||
| $paginationLimit = config('comment.default_pagination_limit'); | ||
| } | ||
|
|
||
| Validator::validate([ | ||
| 'index' => $index, | ||
| 'commentable_key' => $commentableKey, | ||
| 'pagination_limit' => $paginationLimit, | ||
| 'sort_by' => $sortBy, | ||
| ], [ | ||
| 'index' => ['required', 'integer', 'min:0'], | ||
| 'commentable_key' => ['required', Rule::in(config('comment.commentable_keys'))], | ||
| 'pagination_limit' => ['required', 'integer', 'max:'.config('comment.pagination_limit')], | ||
| 'sort_by' => ['required', 'string'], | ||
| ]); | ||
|
|
||
| $commentableType = Relation::getMorphedModel($commentableKey); | ||
| Log::debug('Getting paginated comments', [ | ||
| 'commentable_type' => $commentableType, | ||
| 'commentable_id' => $commentableId, | ||
| 'index' => $index, | ||
| 'sort_by' => $sortBy, | ||
| 'pagination_limit' => $paginationLimit, | ||
| ]); | ||
|
|
||
| // Get the root comments: | ||
| $query = Comment::where([ | ||
| 'commentable_type' => $commentableKey, | ||
| 'commentable_id' => $commentableId, | ||
| 'depth' => 1, | ||
| ]); | ||
|
|
||
| // Apply sorting on the comments | ||
| $query = app(GeneralVotesSortingManager::class)->applySort($query, $sortBy); | ||
|
|
||
| $rootComments = $query->get(); | ||
| Log::debug('Root comments retrieved', [ | ||
| 'count' => $rootComments->count(), | ||
| 'commentable_type' => $commentableType, | ||
| 'commentable_id' => $commentableId, | ||
| ]); | ||
|
|
||
| // Initialize variables | ||
| $currentCommentsSum = 0; | ||
| $resultingPaginatedComments = []; | ||
| $currentIndex = 0; | ||
| $hasMoreComments = false; | ||
|
|
||
| foreach ($rootComments as $comment) { | ||
| $childrenCount = $comment->children_count + 1; | ||
|
|
||
| // Handle comments that exceed MAX when alone in a page | ||
| if ($currentCommentsSum + $childrenCount > $paginationLimit) { | ||
| if ($currentCommentsSum === 0) { | ||
| // Force include oversized comment | ||
| if ($currentIndex === $index) { | ||
| $resultingPaginatedComments[] = $comment; | ||
| $currentCommentsSum += $childrenCount; | ||
| } | ||
| $currentIndex++; | ||
|
|
||
| continue; | ||
| } | ||
|
|
||
| $currentIndex++; | ||
| $currentCommentsSum = 0; | ||
| } | ||
|
|
||
| // Now we know that there exists more comments to load later | ||
| if ($currentIndex > $index) { | ||
| $hasMoreComments = true; | ||
| break; | ||
| } | ||
| // Only add comments for the desired index | ||
| elseif ($currentIndex === $index) { | ||
| $resultingPaginatedComments[] = $comment; | ||
| } | ||
| $currentCommentsSum += $childrenCount; | ||
| } | ||
|
|
||
| $nestedComments = new Collection($resultingPaginatedComments); | ||
| // Lazy eager load the user for the root comment and for each reply. | ||
| $nestedComments->load(['user', 'replies.user']); | ||
|
|
||
| // Flatten the comments and replies into the desired format. | ||
| $flattenedComments = collect(); | ||
|
|
||
| foreach ($nestedComments as $comment) { | ||
| // Transform the root comment. | ||
| $flattenedComments->push( | ||
| new CommentResource($comment) | ||
| ); | ||
|
|
||
| // Transform any loaded replies. | ||
| if ($comment->relationLoaded('replies')) { | ||
| foreach ($comment->replies as $reply) { | ||
| $flattenedComments->push( | ||
| new CommentResource($reply) | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Extract unique users into a separate collection. | ||
| $users = collect(); | ||
|
|
||
| foreach ($flattenedComments as $comment) { | ||
| if ($comment->relationLoaded('user') && $comment->user) { | ||
| $users->put($comment->user->id, new UserResource($comment->user)); | ||
| } | ||
| } | ||
|
|
||
| Log::debug('Returning paginated comments', [ | ||
| 'comments_count' => $flattenedComments->count(), | ||
| 'users_count' => $users->count(), | ||
| 'has_more_comments' => $hasMoreComments, | ||
| 'current_index' => $index, | ||
| ]); | ||
|
|
||
| return [ | ||
| 'comments' => $flattenedComments, | ||
| 'users' => $users->values(), | ||
| 'has_more_comments' => $hasMoreComments, | ||
| ]; | ||
| } | ||
|
|
||
| /** | ||
| * Create and save a comment | ||
| * | ||
| * @throws Exception | ||
| */ | ||
| public function createComment(array $validatedData): Comment | ||
| { | ||
| DB::beginTransaction(); | ||
|
|
||
| $comment = new Comment; | ||
| $comment->content = $validatedData['content']; | ||
| $comment->user_id = Auth::id(); | ||
|
|
||
| $commentableKey = $validatedData['commentable_key']; | ||
| $commentableModel = Relation::getMorphedModel($commentableKey); | ||
| $commentableId = $validatedData['commentable_id']; | ||
|
|
||
| // Ensure that the model exists | ||
| $model = $commentableModel::find($commentableId); | ||
| if (! $model) { | ||
| throw new NotFoundHttpException; | ||
| } | ||
|
|
||
| // Set the commentable type | ||
| $comment->commentable_type = $commentableKey; | ||
| $comment->commentable_id = $commentableId; | ||
|
|
||
| // Top level comment | ||
| $parentCommentId = $validatedData['parent_comment_id']; | ||
| if (! $parentCommentId) { | ||
| $comment->parent_comment_id = null; | ||
| $comment->depth = 1; | ||
| $comment->children_count = 0; | ||
| } | ||
| // Is reply to a comment | ||
| else { | ||
| $parent = Comment::find($parentCommentId); | ||
| $new_comment_depth = $parent->depth + 1; | ||
|
|
||
| // Check if the parent is the root comment | ||
| if ($parent->depth == 1) { | ||
| $root_comment = $parent; | ||
| $root_comment_id = $parent->id; | ||
| } else { | ||
| // If not, fetch the root comment | ||
| $root_comment_id = $parent->root_comment_id; | ||
| $root_comment = Comment::find($root_comment_id); | ||
| } | ||
|
|
||
| $replies_count = $root_comment->children_count ?? 0; | ||
|
|
||
| // Ensure that they are commenting to the same root | ||
| // And the depth is not exceeded | ||
| Validator::validate( | ||
| [ | ||
| 'commentable_id' => $commentableId, | ||
| 'commentable_type' => $commentableKey, | ||
| 'depth' => $new_comment_depth, | ||
| 'replies_count' => $replies_count, | ||
| ], | ||
| [ | ||
| 'commentable_id' => [ | ||
| 'required', | ||
| Rule::in([$parent->commentable_id]), | ||
| ], | ||
| 'commentable_type' => [ | ||
| 'required', | ||
| Rule::in([$parent->commentable_type]), | ||
| ], | ||
| // Cannot exceed the max depth | ||
| 'depth' => [ | ||
| 'required', | ||
| 'integer', | ||
| 'lte:'.(config('comment.max_depth')), | ||
| ], | ||
| // Cannot exceed max replies | ||
| 'replies_count' => [ | ||
| 'required', | ||
| 'integer', | ||
| 'lt:'.(config('comment.max_replies')), | ||
| ], | ||
| ] | ||
| ); | ||
|
|
||
| // Set the parent id | ||
| $comment->parent_comment_id = $parentCommentId; | ||
|
|
||
| // Set the parent's root as this comment's root, unless it is the root itself. | ||
| $comment->root_comment_id = $root_comment_id; | ||
|
|
||
| // Set the new depth | ||
| $comment->depth = $new_comment_depth; | ||
|
|
||
| // Update the children count for root | ||
| $root_comment->children_count = $root_comment->children_count + 1; | ||
| $root_comment->save(); | ||
| } | ||
|
|
||
| $comment->save(); | ||
|
|
||
| Log::debug('New comment saved', [ | ||
| 'comment_id' => $comment->id, | ||
| 'user_id' => $comment->user_id, | ||
| 'commentable_type' => $comment->commentable_type, | ||
| 'commentable_id' => $comment->commentable_id, | ||
| 'depth' => $comment->depth, | ||
| ]); | ||
|
|
||
| DB::commit(); | ||
|
|
||
| return $comment; | ||
| } | ||
| } |
Copilot
AI
Dec 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A CommentService.php file has been created in the app/Events directory, but this appears to be a duplicate of the existing CommentService in app/Services. Event classes should be placed in the Events directory, while service classes belong in the Services directory. This file should be removed as it's in the wrong location.
|
|
||
| DB::commit(); | ||
|
|
||
| $this->upvoteService->upvote('resource', $resource->id); |
Copilot
AI
Dec 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The upvote call is made outside of the database transaction. If the upvote operation fails, the resource will still be created but without its automatic upvote. Consider moving the upvote call inside the transaction before the commit, or handle the failure scenario appropriately.
| preserve-state | ||
| prefetch | ||
| cache-for="10s" | ||
| cache-for="2s" |
Copilot
AI
Dec 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cache duration has been reduced from "10s" to "2s". This change could impact performance by causing more frequent server requests. Consider whether this aggressive cache invalidation is necessary or if it could be optimized differently.
| cache-for="2s" | |
| cache-for="10s" |
| <ResponsiveNavLink | ||
| :href="route('resources.index', { tab: 'edits' })" | ||
| :active="route().current('resources.index') && route().params.tab === 'edits'" | ||
| class="text-primaryDark dark:text-primary hover:text-primary dark:hover:text-primaryLight" | ||
| > | ||
| <Icon icon="mdi:pencil-box-multiple" class="mr-2" /> | ||
| Resource Edits | ||
| </ResponsiveNavLink> |
Copilot
AI
Dec 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mobile navigation menu links to a different route than the desktop navigation. The desktop navigation links to route('resource_edits.index') while the mobile navigation links to route('resources.index', { tab: 'edits' }). This inconsistency could confuse users when switching between devices.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot open a new pull request to apply changes based on this feedback
| DB::commit(); | ||
|
|
||
| $this->upvoteService->upvote('comment', $comment->id); | ||
|
|
Copilot
AI
Dec 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The upvote call is made outside of the database transaction. If the upvote operation fails, the comment will still be created but without its automatic upvote. Consider moving the upvote call inside the transaction before the commit, or handle the failure scenario appropriately.
| DB::commit(); | |
| $this->upvoteService->upvote('comment', $comment->id); | |
| $this->upvoteService->upvote('comment', $comment->id); | |
| DB::commit(); |
| 'review_id' => $review->id, | ||
| ]); | ||
|
|
||
| $this->upvoteService->upvote('review', $review->id); |
Copilot
AI
Dec 26, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The upvote call is made outside of any database transaction. If the upvote operation fails, the review will still be created but without its automatic upvote. Consider wrapping both operations in a transaction or handling the failure scenario appropriately.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@AllanKoder I've opened a new pull request, #53, to work on those changes. Once the pull request is ready, I'll request review from you. |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…mputerScienceResources.com into resource-edits-tab
* Initial plan * Fix mobile navigation to use consistent route for Resource Edits Co-authored-by: AllanKoder <74692833+AllanKoder@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: AllanKoder <74692833+AllanKoder@users.noreply.github.com>
No description provided.