Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
6627353
refactor: remove embedded PNG image data from `quiz-intro.svg`.
b-l-i-n-d Jan 29, 2026
05930d5
refactor: Update conditional rendering logic
b-l-i-n-d Jan 29, 2026
351e2df
feat: Enhance learning area page detection using URL path segments an…
b-l-i-n-d Jan 29, 2026
09027d6
refactor: improve code formatting and remove unused data attribute in…
b-l-i-n-d Jan 31, 2026
1d9bd5e
refactor: Standardize quiz question rendering with a new common heade…
b-l-i-n-d Feb 2, 2026
4de8d8b
refactor: reformat quiz submission button code for improved readability
b-l-i-n-d Feb 2, 2026
68cbe76
feat: Conditionally display correct answers in quiz questions based o…
b-l-i-n-d Feb 2, 2026
8577067
refactor: Refactor quiz question rendering to use global attempt stat…
b-l-i-n-d Feb 2, 2026
c786856
refactor: change quiz question options from div to label elements
b-l-i-n-d Feb 2, 2026
fb4617f
feat: standardize and correct quiz answer input naming across various…
b-l-i-n-d Feb 2, 2026
519e314
fix: Fix quiz question array access and index init
b-l-i-n-d Feb 2, 2026
f356b01
feat: Add x-bind registration to quiz attempt inputs
b-l-i-n-d Feb 2, 2026
0285787
feat: Support checkbox-array fields in form
b-l-i-n-d Feb 2, 2026
65da46b
refactor: Use flat underscore names for quiz inputs
b-l-i-n-d Feb 2, 2026
cdbca6d
refactor: Standardize quiz question input field names to use array sy…
b-l-i-n-d Feb 3, 2026
d5fde5e
fix: Use bracket notation for dynamic property access in form compone…
b-l-i-n-d Feb 3, 2026
83bd246
fix: Ensure unique input naming for fill-in-the-blank and image answe…
b-l-i-n-d Feb 3, 2026
d6e23ee
feat: Implement dynamic answer field names and event callbacks for qu…
b-l-i-n-d Feb 3, 2026
0bd7f74
refactor: centralize quiz-related string literals into a new `QUIZ_CO…
b-l-i-n-d Feb 3, 2026
55562c0
refactor: Streamline quiz ordering and matching event emission by rem…
b-l-i-n-d Feb 3, 2026
d641df1
refactor: update matching question's clear drop zone logic to use eve…
b-l-i-n-d Feb 3, 2026
dd3c298
refactor: replace hardcoded clear button with Button component
b-l-i-n-d Feb 3, 2026
01c3d82
fix(input-field): use bracket access for alpine values/errors
b-l-i-n-d Feb 3, 2026
36d4655
refactor(quiz): split learning-area quiz modules
b-l-i-n-d Feb 3, 2026
c983d64
feat(quiz): align question validation and errors
b-l-i-n-d Feb 5, 2026
572b04c
feat(quiz): improve ordering defaults and submit errors
b-l-i-n-d Feb 5, 2026
e9b8456
feat(quiz): wire submit error toast
b-l-i-n-d Feb 5, 2026
76689ac
Show toast on quiz form errors
b-l-i-n-d Feb 5, 2026
9090310
Merge branch 'learning-area-quiz' into v4-quiz
b-l-i-n-d Feb 5, 2026
1b38b21
fix(quiz): align template validation output
b-l-i-n-d Feb 5, 2026
d8da2ab
fix(quiz): correct submission typings
b-l-i-n-d Feb 5, 2026
a61dd16
Fix quiz.ts typing mismatch
b-l-i-n-d Feb 5, 2026
151bdea
Add radius and column styles to quiz
b-l-i-n-d Feb 5, 2026
1b320e5
Fix quiz header rendering
b-l-i-n-d Feb 5, 2026
f39f5e1
Add quiz auto-start flow
b-l-i-n-d Feb 5, 2026
0c5996e
feat(quiz): handle timer expiry
b-l-i-n-d Feb 5, 2026
13d243a
feat(quiz): wire auto-start and submit
b-l-i-n-d Feb 5, 2026
12a4a5e
fix(quiz): normalize question index
b-l-i-n-d Feb 5, 2026
393a175
style(quiz): tighten layout spacing
b-l-i-n-d Feb 5, 2026
3a1af9c
Update quiz auto start logic
b-l-i-n-d Feb 5, 2026
ddd2320
Adjust quiz abandon handling
b-l-i-n-d Feb 6, 2026
f7980b8
Add total marks to quiz summary
b-l-i-n-d Feb 6, 2026
af28c0e
Tidy quiz templates
b-l-i-n-d Feb 6, 2026
bdce4f9
Wire quiz abandon request event
b-l-i-n-d Feb 6, 2026
ec98c5c
Tidy quiz templates
b-l-i-n-d Feb 6, 2026
8d400d0
Fix quiz timeout submission
b-l-i-n-d Feb 6, 2026
7926964
Fix ordering answer capture
b-l-i-n-d Feb 6, 2026
ebb5955
Add quit confirmation modal
b-l-i-n-d Feb 6, 2026
e0106a3
Make quiz progress sticky
b-l-i-n-d Feb 6, 2026
a89d245
Fix quiz header position
b-l-i-n-d Feb 6, 2026
9b6c2a9
Update quiz footer layout and styles
b-l-i-n-d Feb 8, 2026
141c28a
Refine quiz layout logic
b-l-i-n-d Feb 8, 2026
faaa4b4
Fix fill-in-the-blank error handling
b-l-i-n-d Feb 8, 2026
5734e9f
Add quiz reveal mode config and disable actions
b-l-i-n-d Feb 8, 2026
08eb557
Improve reveal option checkbox/radio styles
b-l-i-n-d Feb 8, 2026
cde2826
Trigger timeout immediately on expired attempts
b-l-i-n-d Feb 8, 2026
99f4ad6
Add reveal-only answer explanation accordion
b-l-i-n-d Feb 8, 2026
4f19636
Update quiz explanation handling
b-l-i-n-d Feb 8, 2026
1cb762b
Wire legacy quiz hooks into learning area
b-l-i-n-d Feb 9, 2026
3d42cac
Style quiz answer explanation panel
b-l-i-n-d Feb 9, 2026
d1c11d7
Update quiz abandon modal
b-l-i-n-d Feb 9, 2026
b232a19
Refactor reveal logic and pass abandon modal id
b-l-i-n-d Feb 9, 2026
8b27be5
Update quiz attempt logic
b-l-i-n-d Feb 9, 2026
03833a3
Fix quiz summary modal timing
b-l-i-n-d Feb 9, 2026
83afc65
refactor: Extract quiz actions to Quiz::render_quiz_actions
b-l-i-n-d Feb 9, 2026
54169db
refactor: Refactor quiz module into separate components
b-l-i-n-d Feb 9, 2026
b39bc23
refator: Move quiz explanation logic to PRO
b-l-i-n-d Feb 9, 2026
ed47c90
Enforce max four columns
b-l-i-n-d Feb 10, 2026
d41a0db
feat: Update quiz attempts badge and table styles
b-l-i-n-d Feb 10, 2026
31046db
refactor(quiz): centralize question wrapper
b-l-i-n-d Feb 10, 2026
32d7895
style(quiz): adjust intro and options layout
b-l-i-n-d Feb 10, 2026
f05db41
Merge branch 'learning-area-quiz' into v4-quiz
b-l-i-n-d Feb 16, 2026
9adcec2
style: Update announcement modal title styles
b-l-i-n-d Feb 16, 2026
9bec367
fix(quiz): avoid firing ordering callback on init
b-l-i-n-d Feb 18, 2026
1d014b8
Merge branch '4.0.0-dev' into v4-quiz
b-l-i-n-d Feb 18, 2026
57d2552
Removed unsed import statements
shewa12 Feb 19, 2026
260eb28
Quiz attempt details page loading mechanism added
shewa12 Feb 20, 2026
3256a00
refactor(quiz): centralize question defaults and validation context
b-l-i-n-d Feb 20, 2026
c7e9ae8
refactor(quiz): compose field names from shared base
b-l-i-n-d Feb 20, 2026
dda2d9a
fix(quiz): prevent duplicate auto-start requests in v4
b-l-i-n-d Feb 20, 2026
72b73a2
refactor(quiz): move student attempt row to shared template
b-l-i-n-d Feb 23, 2026
a75e006
refactor(quiz): adjust start quiz form markup
b-l-i-n-d Feb 24, 2026
5ec8bc9
fix(quiz): keep popover trigger visible while open
b-l-i-n-d Feb 24, 2026
3793ee0
Merge branch '4.0.0-dev' into v4-quiz
b-l-i-n-d Mar 2, 2026
2b706fb
Merge branch '4.0.0-dev' into v4-quiz
b-l-i-n-d Mar 5, 2026
78f50cd
fix(quiz): pass quiz post object to question hook
b-l-i-n-d Mar 5, 2026
a106be7
Merge branch '4.0.0-dev' into v4-quiz
b-l-i-n-d Mar 9, 2026
9d5a1c6
chore: Update `@dnd-kit/dom` version
b-l-i-n-d Mar 9, 2026
1d6a62d
refactor: Remove unused codes
b-l-i-n-d Mar 10, 2026
ce5614f
style: Update quiz into illustration
b-l-i-n-d Mar 10, 2026
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
2 changes: 2 additions & 0 deletions assets/core/scss/mixins/_inputs.scss
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
background-color: $tutor-surface-l1;
cursor: pointer;
position: relative;
flex-shrink: 0;
@include tutor-transition((
background-color,
border-color,
Expand Down Expand Up @@ -155,6 +156,7 @@
background-color: $tutor-surface-l1;
cursor: pointer;
position: relative;
flex-shrink: 0;
@include tutor-transition((
background-color,
border-color,
Expand Down
10 changes: 7 additions & 3 deletions assets/core/scss/mixins/_layout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,16 @@
grid-template-columns: repeat($min, 1fr);

@for $i from ($min + 1) through $max {
// Count all children, then subtract if any are dragging
// Count all children to increase column count; ignore drag clones.
&:has(> #{$selector}:nth-child(#{$i})) {
grid-template-columns: repeat($i, 1fr);
}
}

// If one child is dragging, reduce column count by 1
&:has(> #{$selector}[data-dnd-dragging='true']) {
// If a drag clone/placeholder is present, keep columns based on item count minus one.
&:has(> #{$selector}[data-dnd-dragging='true']) {
@for $i from ($min + 1) through ($max + 1) {
&:has(> #{$selector}:nth-child(#{$i})) {
grid-template-columns: repeat(#{$i - 1}, 1fr);
}
}
Expand Down
123 changes: 110 additions & 13 deletions assets/core/ts/components/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface FieldConfig {
defaultValue?: unknown;
ref?: HTMLInputElement;
type?: string;
isCheckboxArray?: boolean;
}

export interface ValidationRules {
Expand Down Expand Up @@ -418,42 +419,110 @@ export const form = (config: FormControlConfig & { id?: string } = {}) => {
const isCheckbox = type === 'checkbox';
const isFile = type === 'file';

const defaultValue = this.values[name] ?? (isCheckbox ? (element?.checked ?? false) : '');
// Check if a checkbox with this name is already registered (indicates multiple checkboxes)
const isCheckboxArray = isCheckbox && this.fields[name]?.type === 'checkbox';

let defaultValue: unknown;

if (isCheckboxArray) {
// Ensure array type for checkbox groups
const currentValue = this.values[name];
defaultValue = Array.isArray(currentValue) ? currentValue : [];
} else if (isCheckbox) {
defaultValue = this.values[name] ?? element?.checked ?? false;
} else {
defaultValue = this.values[name] ?? '';
}

this.fields[name] = {
name,
rules,
defaultValue,
ref: element,
type,
isCheckboxArray,
};

this.values[name] ??= defaultValue;
// Force upgrade value to array if a collision is detected
if (isCheckboxArray && !Array.isArray(this.values[name])) {
this.values[name] = defaultValue;
} else {
this.values[name] ??= defaultValue;
}

const valueExpression = isCheckbox ? '$event.target.checked' : '$event.target.value';
const valueExpression = isCheckboxArray
? '$event.target.value'
: isCheckbox
? '$event.target.checked'
: '$event.target.value';

const bindings: Record<string, unknown> = {
name,
'x-ref': name,
':aria-invalid': `!!errors.${name}`,
':aria-invalid': `!!errors["${name}"]`,
':class': `{
'tutor-input-error': errors.${name},
'tutor-input-touched': touchedFields.${name},
'tutor-input-dirty': dirtyFields.${name}
'tutor-input-error': errors["${name}"],
'tutor-input-touched': touchedFields["${name}"],
'tutor-input-dirty': dirtyFields["${name}"]
}`,
};

if (!isFile) {
bindings['x-model'] = `values.${name}`;
bindings['@input'] = `handleFieldInput('${name}', ${valueExpression})`;
bindings['x-model'] = `values["${name}"]`;

bindings['@input'] = `handleFieldInput('${name}', ${valueExpression}, $event.target)`;

bindings['@blur'] = `handleFieldBlur('${name}', ${valueExpression})`;
}

return bindings;
},

handleFieldInput(name: string, value: unknown): void {
handleCheckboxArrayInput(name: string, element?: HTMLInputElement): void {
const field = this.fields[name];
const currentValue = this.values[name] as string[];
const valueArray = Array.isArray(currentValue) ? [...currentValue] : [];

// Use the passed element (from $event.target) or try to get from $refs
const checkbox = element || ((this as unknown as AlpineComponent).$refs[name] as HTMLInputElement);

if (!checkbox) return;

const checkboxValue = checkbox.value;
const isChecked = checkbox.checked;

let newValue: string[];
if (isChecked) {
newValue = valueArray.includes(checkboxValue) ? valueArray : [...valueArray, checkboxValue];
} else {
newValue = valueArray.filter((v) => v !== checkboxValue);
}

const defaultArray = Array.isArray(field.defaultValue) ? field.defaultValue : [];
// Sort to compare content regardless of order
const isActuallyChanged = JSON.stringify(newValue.sort()) !== JSON.stringify(defaultArray.sort());

this.values[name] = newValue;
this.dirtyFields[name] = isActuallyChanged;

const shouldValidate = this.config.mode === 'onChange' || this.touchedFields[name];

if (shouldValidate) {
this.validateField(name, newValue);
} else {
this.dispatchStateChange();
}
},

handleFieldInput(name: string, value: unknown, element?: HTMLInputElement): void {
const field = this.fields[name];

if (field?.isCheckboxArray) {
this.handleCheckboxArrayInput(name, element);
return;
}

// Original logic for non-checkbox-array fields
const isNumber = field?.rules?.numberOnly;
const allowNegative = typeof isNumber === 'object' && isNumber.allowNegative;
const whole = typeof isNumber === 'object' && isNumber.whole;
Expand Down Expand Up @@ -508,12 +577,22 @@ export const form = (config: FormControlConfig & { id?: string } = {}) => {
if (shouldTouch) this.touchedFields[name] = true;
if (shouldDirty) {
const field = this.fields[name];
this.dirtyFields[name] = String(value) !== String(field?.defaultValue ?? '');
// Handle array comparison for checkbox arrays
if (Array.isArray(value) && Array.isArray(field?.defaultValue)) {
this.dirtyFields[name] = JSON.stringify(value.sort()) !== JSON.stringify(field.defaultValue.sort());
} else {
this.dirtyFields[name] = String(value) !== String(field?.defaultValue ?? '');
}
}

const fieldElement = this.fields[name]?.ref;
if (fieldElement && this.fields[name].type !== 'file') {
DOMUtils.updateElementValue(fieldElement, value);
// For checkbox arrays, we need to update all checkboxes with this name
if (Array.isArray(value) && fieldElement.type === 'checkbox') {
this.syncCheckboxArray(name, value as string[]);
} else {
DOMUtils.updateElementValue(fieldElement, value);
}
}

if (shouldValidate) {
Expand Down Expand Up @@ -814,11 +893,29 @@ export const form = (config: FormControlConfig & { id?: string } = {}) => {
for (const [name, value] of Object.entries(this.values)) {
const fieldRef = this.fields[name]?.ref;
if (fieldRef) {
DOMUtils.updateElementValue(fieldRef, value);
// Handle checkbox arrays specially
if (Array.isArray(value) && fieldRef.type === 'checkbox') {
this.syncCheckboxArray(name, value as string[]);
} else {
DOMUtils.updateElementValue(fieldRef, value);
}
}
}
},

syncCheckboxArray(name: string, values: string[]): void {
const component = this as unknown as AlpineComponent;
const formElement = component.$el.closest('form') || component.$el.parentElement;
const checkboxes = formElement?.querySelectorAll(`input[type="checkbox"][name="${name}"]`);

if (checkboxes) {
checkboxes.forEach((checkbox) => {
const input = checkbox as HTMLInputElement;
input.checked = values.includes(input.value);
});
}
},

clearAllState(): void {
this.fields = {};
this.values = {};
Expand Down
2 changes: 2 additions & 0 deletions assets/core/ts/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ export const TUTOR_CUSTOM_EVENTS = {
TUTOR_PLAYER_READY: 'tutor-player-ready',
COMMENT_REPLIED: 'tutor:comment:replied',
LESSON_PLAYER_READY: 'tutorLessonPlayerReady',
QUIZ_TIME_EXPIRED: 'tutor-quiz-time-expired',
QUIZ_ABANDON_REQUESTED: 'tutor-quiz-abandon-requested',
};
12 changes: 1 addition & 11 deletions assets/images/quiz-intro.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 27 additions & 4 deletions assets/src/js/frontend/learning-area/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,51 @@
import { initializeLesson } from './lesson';
import { initializeAssignmentView } from './pages/assignment-view';
import { initializeQna } from './pages/qna';
import { initializeQuizInterface } from './pages/quiz';
import { initializeQuizInterface } from './quiz';

const initializeLearningArea = () => {
const params = new URLSearchParams(window.location.search);
const currentPage = params.get('subpage');
const { pathname, search } = window.location;

// Normalize path segments
const pathSegments = pathname.split('/').filter(Boolean);

let currentPage = null;

if (pathSegments.includes('assignments')) {
currentPage = 'assignment-view';
} else if (pathSegments.includes('lessons')) {
currentPage = 'lesson';
} else if (pathSegments.includes('quizzes')) {
currentPage = 'quiz';
} else {
// fallback to query param (older behavior)
const params = new URLSearchParams(search);
currentPage = params.get('subpage');
}

switch (currentPage) {
case 'quiz':
initializeQuizInterface();
break;

case 'assignment-view':
initializeAssignmentView();
break;

case 'lesson':
initializeLesson();
break;

case 'qna':
initializeQna();
break;

default:
// eslint-disable-next-line no-console
console.warn('Unknown learning area page:', currentPage);
}

// Initialized lesson contents
// Initialize lesson contents (shared)
const lessonContentWrapper = document.querySelector('.tutor-lesson-content');
if (lessonContentWrapper) {
initializeLesson();
Expand Down
Loading
Loading