Skip to content

Commit 953cce3

Browse files
authored
Merge pull request #2416 from themeum/v4-quiz-attempt-details
✨ Refactor quiz attempt details review and summary
2 parents 121df06 + f2c173f commit 953cce3

43 files changed

Lines changed: 2577 additions & 571 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { AlpineComponentMeta } from '@Core/ts/types';
2+
3+
interface QuizSummarySidebarConfig {
4+
firstQuestionId?: string | number;
5+
}
6+
7+
const QUESTION_ID_PREFIX = 'question-';
8+
const SUMMARY_HEADER_SELECTOR = '.tutor-quiz-summary-header';
9+
const QUESTION_ID_FALLBACK_SELECTOR = '[data-question-id="%s"]';
10+
const QUESTION_ID_PREFIX_FALLBACK_SELECTOR = `[data-question-id="${QUESTION_ID_PREFIX}%s"]`;
11+
const SIDEBAR_ITEM_SELECTOR = '[data-question-id="%s"]';
12+
const HASH_PATTERN = new RegExp(`^#${QUESTION_ID_PREFIX}(\\d+)$`);
13+
const QUESTION_SCROLL_GAP = 16;
14+
15+
const quizSummarySidebar = (config: QuizSummarySidebarConfig = {}) => ({
16+
activeQuestionId: String(config.firstQuestionId ?? ''),
17+
$el: null as HTMLElement | null,
18+
19+
init() {
20+
const hashQuestionId = this.getQuestionIdFromHash(window.location.hash);
21+
22+
if (hashQuestionId && this.hasQuestionItem(hashQuestionId)) {
23+
this.activeQuestionId = hashQuestionId;
24+
}
25+
},
26+
27+
getQuestionIdFromHash(hash: string): string | null {
28+
const hashMatch = hash.match(HASH_PATTERN);
29+
return hashMatch ? hashMatch[1] : null;
30+
},
31+
32+
hasQuestionItem(questionId: string): boolean {
33+
if (!questionId || !this.$el) {
34+
return false;
35+
}
36+
37+
return !!this.$el.querySelector(SIDEBAR_ITEM_SELECTOR.replace('%s', questionId));
38+
},
39+
40+
setActiveQuestion(questionId: string | number) {
41+
const resolvedId = String(questionId || '');
42+
43+
if (!resolvedId) {
44+
return;
45+
}
46+
47+
this.activeQuestionId = resolvedId;
48+
history.replaceState(null, '', `#${QUESTION_ID_PREFIX}${resolvedId}`);
49+
this.scrollToQuestionAnswer(resolvedId);
50+
},
51+
52+
scrollToQuestionAnswer(questionId: string | number) {
53+
const resolvedId = String(questionId || '');
54+
55+
if (!resolvedId) {
56+
return;
57+
}
58+
59+
const answerElement =
60+
document.getElementById(`${QUESTION_ID_PREFIX}${resolvedId}`) ||
61+
document.querySelector(QUESTION_ID_PREFIX_FALLBACK_SELECTOR.replace('%s', resolvedId)) ||
62+
document.querySelector(QUESTION_ID_FALLBACK_SELECTOR.replace('%s', resolvedId));
63+
64+
if (answerElement instanceof HTMLElement) {
65+
const summaryHeader = document.querySelector(SUMMARY_HEADER_SELECTOR);
66+
const headerOffset =
67+
summaryHeader instanceof HTMLElement
68+
? summaryHeader.getBoundingClientRect().top + summaryHeader.offsetHeight
69+
: 0;
70+
const scrollTop = answerElement.getBoundingClientRect().top + window.scrollY - headerOffset - QUESTION_SCROLL_GAP;
71+
72+
window.scrollTo({
73+
top: Math.max(0, scrollTop),
74+
behavior: 'smooth',
75+
});
76+
}
77+
},
78+
});
79+
80+
export const quizSummarySidebarMeta: AlpineComponentMeta = {
81+
name: 'quizSummarySidebar',
82+
component: quizSummarySidebar,
83+
};
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { __ } from '@wordpress/i18n';
2+
3+
import { type MutationState } from '@Core/ts/services/Query';
4+
import { wpAjaxInstance } from '@TutorShared/utils/api';
5+
import endpoints from '@TutorShared/utils/endpoints';
6+
import { convertToErrorMessage } from '@TutorShared/utils/util';
7+
8+
const REVIEW_STATUSES = ['correct', 'incorrect'] as const;
9+
const REVIEW_STATUS_FIELD = 'review_statuses' as const;
10+
11+
type ReviewStatus = (typeof REVIEW_STATUSES)[number];
12+
type ReviewStatusFieldName = `${typeof REVIEW_STATUS_FIELD}[${string}]`;
13+
type ReviewStatusMap = Record<string, ReviewStatus>;
14+
type ReviewStatusesAjaxPayload = Partial<Record<ReviewStatusFieldName, ReviewStatus>>;
15+
16+
interface QuizAttemptFeedbackProps {
17+
attemptId: number;
18+
formId: string;
19+
}
20+
21+
interface QuizAttemptFeedbackPayload {
22+
attempt_id: number;
23+
feedback: string;
24+
review_statuses: ReviewStatusMap;
25+
}
26+
27+
interface QuizAttemptFeedbackResponse<TData = unknown> {
28+
success?: boolean;
29+
message?: string;
30+
data?: TData;
31+
}
32+
33+
interface QuizAttemptSubmitResponse {
34+
reviewResponse: QuizAttemptFeedbackResponse | null;
35+
feedbackResponse: QuizAttemptFeedbackResponse;
36+
}
37+
38+
const quizAttemptFeedback = ({ attemptId, formId }: QuizAttemptFeedbackProps) => {
39+
const query = window.TutorCore.query;
40+
const toast = window.TutorCore.toast;
41+
const reviewStatusFieldPattern = new RegExp(`^${REVIEW_STATUS_FIELD}\\[[^\\]]+\\]$`);
42+
43+
const getReviewStatuses = (data: Record<string, unknown>) => {
44+
return Object.entries(data).reduce<ReviewStatusMap>((acc, [key, value]) => {
45+
if (!reviewStatusFieldPattern.test(key)) {
46+
return acc;
47+
}
48+
49+
if (typeof value !== 'string' || !REVIEW_STATUSES.includes(value as ReviewStatus)) {
50+
return acc;
51+
}
52+
53+
const fieldName = key as ReviewStatusFieldName;
54+
const questionId = fieldName.slice(`${REVIEW_STATUS_FIELD}[`.length, -1);
55+
const reviewStatus = value as ReviewStatus;
56+
57+
acc[questionId] = reviewStatus;
58+
return acc;
59+
}, {});
60+
};
61+
62+
const getReviewStatusesPayload = (reviewStatuses: ReviewStatusMap) => {
63+
return Object.entries(reviewStatuses).reduce<ReviewStatusesAjaxPayload>((acc, [questionId, status]) => {
64+
const fieldName: ReviewStatusFieldName = `${REVIEW_STATUS_FIELD}[${questionId}]`;
65+
acc[fieldName] = status;
66+
return acc;
67+
}, {});
68+
};
69+
70+
return {
71+
formId,
72+
attemptId,
73+
feedbackMutation: null as MutationState<QuizAttemptSubmitResponse, QuizAttemptFeedbackPayload> | null,
74+
75+
init() {
76+
this.feedbackMutation = query.useMutation(this.saveFeedback, {
77+
onSuccess: () => {
78+
toast.success(__('Quiz feedback updated successfully.', 'tutor'));
79+
window.location.reload();
80+
},
81+
onError: (error: Error) => {
82+
toast.error(convertToErrorMessage(error));
83+
},
84+
});
85+
},
86+
87+
async saveFeedback(payload: QuizAttemptFeedbackPayload) {
88+
const reviewStatusesPayload = getReviewStatusesPayload(payload.review_statuses);
89+
const reviewRequest =
90+
Object.keys(reviewStatusesPayload).length > 0
91+
? wpAjaxInstance
92+
.post<QuizAttemptFeedbackResponse>(endpoints.REVIEW_QUIZ_ANSWERS, {
93+
attempt_id: payload.attempt_id,
94+
...reviewStatusesPayload,
95+
})
96+
.then((res) => res.data)
97+
: Promise.resolve(null);
98+
99+
const feedbackRequest = wpAjaxInstance
100+
.post<QuizAttemptFeedbackResponse>(endpoints.INSTRUCTOR_FEEDBACK, {
101+
attempt_id: payload.attempt_id,
102+
feedback: payload.feedback,
103+
})
104+
.then((res) => res.data);
105+
106+
const [reviewResponse, feedbackResponse] = await Promise.all([reviewRequest, feedbackRequest]);
107+
108+
return {
109+
reviewResponse,
110+
feedbackResponse,
111+
};
112+
},
113+
114+
async handleSaveFeedback(data: Record<string, unknown>) {
115+
await this.feedbackMutation?.mutate({
116+
attempt_id: this.attemptId,
117+
feedback: String(data.feedback ?? ''),
118+
review_statuses: getReviewStatuses(data),
119+
});
120+
},
121+
};
122+
};
123+
124+
export const quizAttemptFeedbackMeta = {
125+
name: 'quizAttemptFeedback',
126+
component: quizAttemptFeedback,
127+
};

assets/src/js/frontend/dashboard/pages/quiz-attempts.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
// Quiz Attempts Page
22
import { type MutationState } from '@Core/ts/services/Query';
3+
import { quizSummarySidebarMeta } from '@FrontendComponents/quiz/summary-sidebar';
34
import { tutorConfig } from '@TutorShared/config/config';
45
import { wpAjaxInstance } from '@TutorShared/utils/api';
56
import { convertToErrorMessage } from '@TutorShared/utils/util';
67
import axios from 'axios';
78

9+
import { quizAttemptFeedbackMeta } from './quiz-attempt-feedback';
10+
811
interface RetryAttempt {
912
quizID: string;
1013
redirectURL: string;
@@ -60,12 +63,15 @@ const quizAttemptsPage = () => {
6063
};
6164

6265
export const initializeQuizAttempts = () => {
63-
window.TutorComponentRegistry.register({
64-
type: 'component',
65-
meta: {
66-
name: 'quizAttempts',
67-
component: quizAttemptsPage,
68-
},
66+
window.TutorComponentRegistry.registerAll({
67+
components: [
68+
{
69+
name: 'quizAttempts',
70+
component: quizAttemptsPage,
71+
},
72+
quizAttemptFeedbackMeta,
73+
quizSummarySidebarMeta,
74+
],
6975
});
7076

7177
window.TutorComponentRegistry.initWithAlpine(window.Alpine);

assets/src/js/frontend/learning-area/quiz/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { quizSummarySidebarMeta } from '@FrontendComponents/quiz/summary-sidebar';
12
import { quizAutoStartMeta } from './auto-start';
23
import { quizLayoutMeta } from './layout';
34
import { questionMatchingMeta } from './questions/matching';
@@ -14,6 +15,7 @@ export const initializeQuizInterface = () => {
1415
quizSubmissionMeta,
1516
quizAutoStartMeta,
1617
quizLayoutMeta,
18+
quizSummarySidebarMeta,
1719
],
1820
});
1921

assets/src/js/v3/shared/utils/endpoints.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ const endpoints = {
6262
QUIZ_ABANDON: 'tutor_quiz_abandon',
6363
QUIZ_TIMEOUT: 'tutor_quiz_timeout',
6464
QUIZ_ATTEMPT_SUBMIT: 'tutor_answering_quiz_question',
65+
REVIEW_QUIZ_ANSWERS: 'tutor_review_quiz_answers',
66+
INSTRUCTOR_FEEDBACK: 'tutor_instructor_feedback',
6567

6668
// ZOOM
6769
GET_ZOOM_MEETING_DETAILS: 'tutor_zoom_meeting_details',

assets/src/scss/frontend/components/_index.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@
1313
@forward 'discussion-single.scss';
1414
@forward 'upcoming-lesson-card.scss';
1515
@forward 'quiz-attempts.scss';
16+
@forward 'quiz-attempt-details';
17+
@forward 'quiz-summary';
1618
@forward 'player';

0 commit comments

Comments
 (0)