Skip to content

CC-1717: Improved voting flow for community code examples #2816

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

Open
wants to merge 23 commits into
base: update-course-page-code-examples-test
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cbd98c3
refactor: update voting logic to use tasks and improve user feedback …
ryan-gang May 1, 2025
0a5d427
feat: enhance CommunitySolutionCard to include metadata for upvote an…
ryan-gang May 1, 2025
76f5788
refactor: streamline voting tasks in CommunitySolutionCard and remove…
ryan-gang May 1, 2025
81d63fe
chore: lint fix
ryan-gang May 1, 2025
58ecdd8
refactor: remove upvote and downvote button components from Community…
ryan-gang May 1, 2025
f5d17a0
test: update test page attribute and update test expectations
ryan-gang May 1, 2025
20740f8
chore: lint fix
ryan-gang May 1, 2025
f767103
test: add downvote functionality test for code examples with user int…
ryan-gang May 2, 2025
06f8e9d
fix: ensure records are unloaded only when not in-flight during unvot…
ryan-gang May 5, 2025
146df35
refactor: update voting tasks to be restartable (always interactive)
ryan-gang May 5, 2025
709cd3f
refactor: improve feedback message visibility and button interactivit…
ryan-gang May 5, 2025
d507041
refactor: implement optimistic user action handling for upvote and do…
ryan-gang May 6, 2025
73ef41c
refactor: enhance optimistic UI feedback for upvote and downvote butt…
ryan-gang May 6, 2025
57e61ab
refactor: add 'unvote' functionality to user action handling in feedb…
ryan-gang May 6, 2025
e5d32bb
refactor: simplify button class handling for upvote and downvote acti…
ryan-gang May 6, 2025
22915ac
refactor: handle 'unvote' action correctly by returning null in feedb…
ryan-gang May 6, 2025
bdaac87
refactor: ensure success message is displayed after upvote and downvo…
ryan-gang May 6, 2025
95e6751
refactor: change flash success message task to keep the latest messag…
ryan-gang May 6, 2025
36108fe
refactor: reduce timeout duration for flash success message to enhanc…
ryan-gang May 6, 2025
8fbdabb
refactor: add 'unvote' endpoint to handle removal of user votes on co…
ryan-gang May 6, 2025
4db3d72
test: enhance vote button interactivity by adding inactive state asse…
ryan-gang May 6, 2025
198b0b1
chore: fix lint
ryan-gang May 6, 2025
4f3f8b2
refactor: streamline unvote logic by removing in-flight record checks
ryan-gang May 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,9 @@
</FileContentsCard>
{{/each}}

<CoursePage::CourseStageStep::CommunitySolutionCard::FeedbackSection class="pt-6 pb-3" />
<CoursePage::CourseStageStep::CommunitySolutionCard::FeedbackSection
@metadataForDownvote={{@metadataForDownvote}}
@metadataForUpvote={{@metadataForUpvote}}
@solution={{@solution}}
class="pt-6 pb-3"
/>
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface Signature {
Element: HTMLDivElement;

Args: {
metadataForDownvote?: Record<string, unknown>;
metadataForUpvote?: Record<string, unknown>;
solution: CommunityCourseStageSolutionModel;
fileComparisons: FileComparison[];
onPublishToGithubButtonClick?: () => void;
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div class="flex flex-col items-center gap-2" ...attributes>
<AnimatedContainer class="w-full">
<div class="text-sm text-gray-700 dark:text-gray-300 text-center w-full">
{{#animated-if this.tempHasVotedRecently use=this.transition duration=200}}
{{#animated-if this.flashSuccessMessageTask.isRunning use=this.transition duration=200}}
<p class="text-teal-500 font-medium">
Thanks for your feedback!
</p>
Expand All @@ -15,27 +15,32 @@

<div class="flex items-center gap-1.5 flex-wrap">
<TertiaryButton
{{on "click" this.handleUpvoteButtonClick}}
class={{if this.tempHasDownvoted "opacity-50 hover:opacity-100 transition-opacity duration-200"}}
{{on "click" (fn this.handleClick "upvote")}}
class={{if this.optimisticValueForUserActionIsDownvote "opacity-50 hover:opacity-100 transition-opacity duration-200"}}
data-test-solution-card-upvote-button
>
<div class="flex items-center gap-1">
{{svg-jar "thumb-up" class=(concat "w-5 " (if this.tempHasUpvoted "text-teal-500" "text-gray-400 dark:text-gray-600"))}}
{{svg-jar "thumb-up" class=(concat "w-5 " (if this.optimisticValueForUserActionIsUpvote "text-teal-500" "text-gray-400 dark:text-gray-600"))}}

<span class={{if this.tempHasUpvoted "text-teal-500" "text-gray-500"}}>
<span class={{if this.optimisticValueForUserActionIsUpvote "text-teal-500" "text-gray-500"}}>
Helpful
</span>
</div>

<EmberTooltip @text="Your feedback helps us surface better examples." @side="bottom" @delay={{250}} />
</TertiaryButton>
<TertiaryButton
{{on "click" this.handleDownvoteButtonClick}}
class={{if this.tempHasUpvoted "opacity-50 hover:opacity-100 transition-opacity duration-200"}}
{{on "click" (fn this.handleClick "downvote")}}
class={{if this.optimisticValueForUserActionIsUpvote "opacity-50 hover:opacity-100 transition-opacity duration-100"}}
data-test-solution-card-downvote-button
>
<div class="flex items-center gap-1">
{{svg-jar "thumb-down" class=(concat "w-5 " (if this.tempHasDownvoted "text-red-600" "text-gray-400 dark:text-gray-600"))}}
{{svg-jar
"thumb-down"
class=(concat "w-5 " (if this.optimisticValueForUserActionIsDownvote "text-red-600" "text-gray-400 dark:text-gray-600"))
}}

<span class={{if this.tempHasDownvoted "text-red-600" "text-gray-500"}}>
<span class={{if this.optimisticValueForUserActionIsDownvote "text-red-600" "text-gray-500"}}>
Not helpful
</span>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,55 +1,83 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task, timeout } from 'ember-concurrency';
import fade from 'ember-animated/transitions/fade';
import CommunityCourseStageSolutionModel from 'codecrafters-frontend/models/community-course-stage-solution';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

interface Signature {
Element: HTMLDivElement;

Args: {
metadataForDownvote?: Record<string, unknown>;
metadataForUpvote?: Record<string, unknown>;
solution: CommunityCourseStageSolutionModel;
};
}

export default class CommunitySolutionCardFeedbackSectionComponent extends Component<Signature> {
transition = fade;

@tracked tempHasVotedRecently = false;
@tracked tempHasUpvoted = false;
@tracked tempHasDownvoted = false;
@tracked unsavedUserActionValue: 'upvote' | 'downvote' | 'unvote' | null = null;

@action
handleDownvoteButtonClick() {
// No feedback if the user is "reversing" their vote
if (this.tempHasDownvoted) {
this.tempHasDownvoted = false;
this.tempHasVotedRecently = false;

return;
}
get currentUserHasDownvoted() {
return this.args.solution.currentUserDownvotes.length > 0;
}

this.flashSuccessMessageTask.perform();
this.tempHasUpvoted = false;
this.tempHasDownvoted = true;
get currentUserHasUpvoted() {
return this.args.solution.currentUserUpvotes.length > 0;
}

@action
handleUpvoteButtonClick() {
// No feedback if the user is "reversing" their vote
if (this.tempHasUpvoted) {
this.tempHasUpvoted = false;
this.tempHasVotedRecently = false;
get optimisticValueForUserAction() {
if (this.unsavedUserActionValue !== 'unvote' && this.unsavedUserActionValue !== null) {
return this.unsavedUserActionValue;
}

return;
if (this.unsavedUserActionValue === 'unvote') {
return null;
}

this.flashSuccessMessageTask.perform();
this.tempHasDownvoted = false;
this.tempHasUpvoted = true;
return this.currentUserHasDownvoted ? 'downvote' : this.currentUserHasUpvoted ? 'upvote' : null;
}

get optimisticValueForUserActionIsDownvote() {
return this.optimisticValueForUserAction === 'downvote';
}

get optimisticValueForUserActionIsUpvote() {
return this.optimisticValueForUserAction === 'upvote';
}

flashSuccessMessageTask = task({ keepLatest: true }, async () => {
this.tempHasVotedRecently = true;
await timeout(1500);
this.tempHasVotedRecently = false;
await timeout(500);
});

syncUserAction = task({ keepLatest: true }, async () => {
const toggleUpvote = this.unsavedUserActionValue === 'upvote';
const toggleDownvote = this.unsavedUserActionValue === 'downvote';
const toggleUnvote = this.unsavedUserActionValue === 'unvote';

if (toggleUpvote) {
this.flashSuccessMessageTask.perform();
await this.args.solution.upvote(this.args.metadataForUpvote || {});
} else if (toggleDownvote) {
this.flashSuccessMessageTask.perform();
await this.args.solution.downvote(this.args.metadataForDownvote || {});
} else if (toggleUnvote) {
await this.args.solution.unvote({});
}

Check warning on line 68 in app/components/course-page/course-stage-step/community-solution-card/feedback-section.ts

View check run for this annotation

Codecov / codecov/patch

app/components/course-page/course-stage-step/community-solution-card/feedback-section.ts#L68

Added line #L68 was not covered by tests
});

@action
async handleClick(action: 'upvote' | 'downvote'): Promise<void> {
if (this.unsavedUserActionValue === action) {
this.unsavedUserActionValue = 'unvote';
} else {

Check warning on line 75 in app/components/course-page/course-stage-step/community-solution-card/feedback-section.ts

View check run for this annotation

Codecov / codecov/patch

app/components/course-page/course-stage-step/community-solution-card/feedback-section.ts#L75

Added line #L75 was not covered by tests
this.unsavedUserActionValue = action;
}

this.syncUserAction.perform();
}
}

declare module '@glint/environment-ember-loose/registry' {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,6 @@
</div>

<div class="flex items-center shrink-0">
{{! Remove, replace with buttons at the bottom of the card }}
{{!-- {{#unless (eq @solution.user this.authenticator.currentUser)}}
<CoursePage::CourseStageStep::CommunitySolutionCard::UpvoteButton @solution={{@solution}} @metadata={{@metadataForUpvote}} class="mr-2" />
<CoursePage::CourseStageStep::CommunitySolutionCard::DownvoteButton @solution={{@solution}} @metadata={{@metadataForDownvote}} />
<div class="w-px h-3 bg-gray-200 dark:bg-white/5 mx-2"></div>
{{/unless}} --}}

{{#if (gt @solution.approvedCommentsCount 0)}}
<div class="flex items-center">
{{svg-jar "chat-alt" class="w-4 text-gray-400 dark:text-gray-600 mr-1"}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
<div>
{{#if @isExpanded}}
<CoursePage::CourseStageStep::CommunitySolutionCard::Content
@metadataForDownvote={{@metadataForDownvote}}
@metadataForUpvote={{@metadataForUpvote}}
@solution={{@solution}}
@fileComparisons={{this.fileComparisons}}
@onPublishToGithubButtonClick={{@onPublishToGithubButtonClick}}
Expand Down

This file was deleted.

This file was deleted.

17 changes: 17 additions & 0 deletions mirage/handlers/community-course-stage-solutions.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ export default function (server) {
server.get('/community-course-stage-solutions/:id');
server.patch('/community-course-stage-solutions/:id');

server.post('/community-course-stage-solutions/:id/unvote', function (schema, request) {
const solutionId = request.params.id;
const solution = schema.communityCourseStageSolutions.find(solutionId);

if (!solution) {
return new Response(404, {}, { errors: [{ detail: 'Solution not found' }] });
}

// Destroy any existing upvotes by the current user for this solution
solution.currentUserUpvotes.models.forEach((vote) => vote.destroy());

// Destroy any existing downvotes by the current user for this solution
solution.currentUserDownvotes.models.forEach((vote) => vote.destroy());

return solution.reload();
});

server.get('/community-course-stage-solutions/:id/file-comparisons', function (schema, request) {
const solution = schema.communityCourseStageSolutions.find(request.params.id);

Expand Down
Loading