add changes
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, FormArray, Validators, ReactiveFormsModule, AbstractControl, ValidationErrors } from '@angular/forms';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule, MatChipInputEvent } from '@angular/material/chips';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { AdminService } from '../../../core/services/admin.service';
|
||||
import { CategoryService } from '../../../core/services/category.service';
|
||||
import { Question, QuestionFormData } from '../../../core/models/question.model';
|
||||
import { QuestionType, Difficulty } from '../../../core/models/category.model';
|
||||
|
||||
/**
|
||||
* AdminQuestionFormComponent
|
||||
*
|
||||
* Comprehensive form for creating new quiz questions.
|
||||
*
|
||||
* Features:
|
||||
* - Dynamic form based on question type
|
||||
* - Real-time validation
|
||||
* - Question preview panel
|
||||
* - Tag input with chips
|
||||
* - Dynamic options for MCQ
|
||||
* - Correct answer validation
|
||||
* - Category selection
|
||||
* - Difficulty levels
|
||||
* - Guest accessibility toggle
|
||||
*
|
||||
* Question Types:
|
||||
* - Multiple Choice: Radio options with dynamic add/remove
|
||||
* - True/False: Pre-defined boolean options
|
||||
* - Written: Text-based answer
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-admin-question-form',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatCheckboxModule,
|
||||
MatRadioModule,
|
||||
MatDividerModule,
|
||||
MatTooltipModule
|
||||
],
|
||||
templateUrl: './admin-question-form.component.html',
|
||||
styleUrl: './admin-question-form.component.scss'
|
||||
})
|
||||
export class AdminQuestionFormComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly adminService = inject(AdminService);
|
||||
private readonly categoryService = inject(CategoryService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
// Form state
|
||||
questionForm!: FormGroup;
|
||||
isSubmitting = signal(false);
|
||||
isEditMode = signal(false);
|
||||
questionId = signal<string | null>(null);
|
||||
isLoadingQuestion = signal(false);
|
||||
|
||||
// Categories from service
|
||||
readonly categories = this.categoryService.categories;
|
||||
readonly isLoadingCategories = this.categoryService.isLoading;
|
||||
|
||||
// Chip input config
|
||||
readonly separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
|
||||
// Available options
|
||||
readonly questionTypes = [
|
||||
{ value: 'multiple_choice', label: 'Multiple Choice' },
|
||||
{ value: 'true_false', label: 'True/False' },
|
||||
{ value: 'written', label: 'Written Answer' }
|
||||
];
|
||||
|
||||
readonly difficultyLevels = [
|
||||
{ value: 'easy', label: 'Easy' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'hard', label: 'Hard' }
|
||||
];
|
||||
|
||||
// Computed properties
|
||||
readonly selectedQuestionType = computed(() => {
|
||||
return this.questionForm?.get('questionType')?.value as QuestionType;
|
||||
});
|
||||
|
||||
readonly showOptions = computed(() => {
|
||||
const type = this.selectedQuestionType();
|
||||
return type === 'multiple_choice';
|
||||
});
|
||||
|
||||
readonly showTrueFalse = computed(() => {
|
||||
const type = this.selectedQuestionType();
|
||||
return type === 'true_false';
|
||||
});
|
||||
|
||||
readonly isFormValid = computed(() => {
|
||||
return this.questionForm?.valid ?? false;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initialize form
|
||||
this.initializeForm();
|
||||
|
||||
// Load categories
|
||||
this.categoryService.getCategories().subscribe();
|
||||
|
||||
// Check if we're in edit mode
|
||||
this.route.params
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(params => {
|
||||
const id = params['id'];
|
||||
if (id) {
|
||||
this.isEditMode.set(true);
|
||||
this.questionId.set(id);
|
||||
this.loadQuestion(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for question type changes
|
||||
this.questionForm.get('questionType')?.valueChanges
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((type: QuestionType) => {
|
||||
this.onQuestionTypeChange(type);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing question data
|
||||
*/
|
||||
private loadQuestion(id: string): void {
|
||||
this.isLoadingQuestion.set(true);
|
||||
|
||||
this.adminService.getQuestion(id)
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.isLoadingQuestion.set(false);
|
||||
this.populateForm(response.data);
|
||||
},
|
||||
error: (error) => {
|
||||
this.isLoadingQuestion.set(false);
|
||||
console.error('Error loading question:', error);
|
||||
// Redirect back if question not found
|
||||
this.router.navigate(['/admin/questions']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate form with existing question data
|
||||
*/
|
||||
private populateForm(question: Question): void {
|
||||
// Clear existing options
|
||||
this.optionsArray.clear();
|
||||
|
||||
// Populate basic fields
|
||||
this.questionForm.patchValue({
|
||||
questionText: question.questionText,
|
||||
questionType: question.questionType,
|
||||
categoryId: question.categoryId,
|
||||
difficulty: question.difficulty,
|
||||
correctAnswer: Array.isArray(question.correctAnswer) ? question.correctAnswer[0] : question.correctAnswer,
|
||||
explanation: question.explanation,
|
||||
points: question.points,
|
||||
tags: question.tags || [],
|
||||
isPublic: question.isPublic,
|
||||
isGuestAccessible: question.isPublic // Map isPublic to isGuestAccessible
|
||||
});
|
||||
|
||||
// Populate options for multiple choice
|
||||
if (question.questionType === 'multiple_choice' && question.options) {
|
||||
question.options.forEach((option: string) => {
|
||||
this.optionsArray.push(this.createOption(option));
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger question type change to update form state
|
||||
this.onQuestionTypeChange(question.questionType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize form with all fields
|
||||
*/
|
||||
private initializeForm(): void {
|
||||
this.questionForm = this.fb.group({
|
||||
questionText: ['', [Validators.required, Validators.minLength(10)]],
|
||||
questionType: ['multiple_choice', Validators.required],
|
||||
categoryId: ['', Validators.required],
|
||||
difficulty: ['medium', Validators.required],
|
||||
options: this.fb.array([
|
||||
this.createOption(''),
|
||||
this.createOption(''),
|
||||
this.createOption(''),
|
||||
this.createOption('')
|
||||
]),
|
||||
correctAnswer: ['', Validators.required],
|
||||
explanation: ['', [Validators.required, Validators.minLength(10)]],
|
||||
points: [10, [Validators.required, Validators.min(1), Validators.max(100)]],
|
||||
tags: [[] as string[]],
|
||||
isPublic: [true],
|
||||
isGuestAccessible: [false]
|
||||
});
|
||||
|
||||
// Add custom validator for correct answer
|
||||
this.questionForm.setValidators(this.correctAnswerValidator.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create option form control
|
||||
*/
|
||||
private createOption(value: string = ''): FormGroup {
|
||||
return this.fb.group({
|
||||
text: [value, Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get options form array
|
||||
*/
|
||||
get optionsArray(): FormArray {
|
||||
return this.questionForm.get('options') as FormArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags array
|
||||
*/
|
||||
get tagsArray(): string[] {
|
||||
return this.questionForm.get('tags')?.value || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle question type change
|
||||
*/
|
||||
private onQuestionTypeChange(type: QuestionType): void {
|
||||
const correctAnswerControl = this.questionForm.get('correctAnswer');
|
||||
|
||||
if (type === 'multiple_choice') {
|
||||
// Ensure at least 2 options
|
||||
while (this.optionsArray.length < 2) {
|
||||
this.addOption();
|
||||
}
|
||||
correctAnswerControl?.setValidators([Validators.required]);
|
||||
} else if (type === 'true_false') {
|
||||
// Clear options for True/False
|
||||
this.optionsArray.clear();
|
||||
correctAnswerControl?.setValidators([Validators.required]);
|
||||
// Set default to True if empty
|
||||
if (!correctAnswerControl?.value) {
|
||||
correctAnswerControl?.setValue('true');
|
||||
}
|
||||
} else {
|
||||
// Written answer
|
||||
this.optionsArray.clear();
|
||||
correctAnswerControl?.setValidators([Validators.required, Validators.minLength(1)]);
|
||||
}
|
||||
|
||||
correctAnswerControl?.updateValueAndValidity();
|
||||
this.questionForm.updateValueAndValidity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new option
|
||||
*/
|
||||
addOption(): void {
|
||||
if (this.optionsArray.length < 10) {
|
||||
this.optionsArray.push(this.createOption(''));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove option at index
|
||||
*/
|
||||
removeOption(index: number): void {
|
||||
if (this.optionsArray.length > 2) {
|
||||
this.optionsArray.removeAt(index);
|
||||
|
||||
// Clear correct answer if it matches the removed option
|
||||
const correctAnswer = this.questionForm.get('correctAnswer')?.value;
|
||||
const removedOption = this.optionsArray.at(index)?.get('text')?.value;
|
||||
if (correctAnswer === removedOption) {
|
||||
this.questionForm.get('correctAnswer')?.setValue('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tag
|
||||
*/
|
||||
addTag(event: MatChipInputEvent): void {
|
||||
const value = (event.value || '').trim();
|
||||
const tags = this.tagsArray;
|
||||
|
||||
if (value && !tags.includes(value)) {
|
||||
this.questionForm.get('tags')?.setValue([...tags, value]);
|
||||
}
|
||||
|
||||
event.chipInput!.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tag
|
||||
*/
|
||||
removeTag(tag: string): void {
|
||||
const tags = this.tagsArray;
|
||||
const index = tags.indexOf(tag);
|
||||
|
||||
if (index >= 0) {
|
||||
tags.splice(index, 1);
|
||||
this.questionForm.get('tags')?.setValue([...tags]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator for correct answer
|
||||
*/
|
||||
private correctAnswerValidator(control: AbstractControl): ValidationErrors | null {
|
||||
const formGroup = control as FormGroup;
|
||||
const questionType = formGroup.get('questionType')?.value;
|
||||
const correctAnswer = formGroup.get('correctAnswer')?.value;
|
||||
const options = formGroup.get('options') as FormArray;
|
||||
|
||||
if (questionType === 'multiple_choice' && correctAnswer && options) {
|
||||
const optionTexts = options.controls.map(opt => opt.get('text')?.value);
|
||||
const isValid = optionTexts.includes(correctAnswer);
|
||||
|
||||
if (!isValid) {
|
||||
return { correctAnswerMismatch: true };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get option text values
|
||||
*/
|
||||
getOptionTexts(): string[] {
|
||||
return this.optionsArray.controls.map(opt => opt.get('text')?.value).filter(text => text.trim() !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit form
|
||||
*/
|
||||
onSubmit(): void {
|
||||
if (this.questionForm.invalid || this.isSubmitting()) {
|
||||
this.markFormGroupTouched(this.questionForm);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting.set(true);
|
||||
|
||||
const formValue = this.questionForm.value;
|
||||
const questionData: QuestionFormData = {
|
||||
questionText: formValue.questionText,
|
||||
questionType: formValue.questionType,
|
||||
difficulty: formValue.difficulty,
|
||||
categoryId: formValue.categoryId,
|
||||
correctAnswer: formValue.correctAnswer,
|
||||
explanation: formValue.explanation,
|
||||
points: formValue.points || 10,
|
||||
tags: formValue.tags || [],
|
||||
isPublic: formValue.isPublic,
|
||||
isGuestAccessible: formValue.isGuestAccessible
|
||||
};
|
||||
|
||||
// Add options for multiple choice
|
||||
if (formValue.questionType === 'multiple_choice') {
|
||||
questionData.options = this.getOptionTexts();
|
||||
}
|
||||
|
||||
// Determine if create or update
|
||||
const serviceCall = this.isEditMode() && this.questionId()
|
||||
? this.adminService.updateQuestion(this.questionId()!, questionData)
|
||||
: this.adminService.createQuestion(questionData);
|
||||
|
||||
serviceCall
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.isSubmitting.set(false);
|
||||
this.router.navigate(['/admin/questions']);
|
||||
},
|
||||
error: (error) => {
|
||||
this.isSubmitting.set(false);
|
||||
console.error(`Error ${this.isEditMode() ? 'updating' : 'creating'} question:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel and go back
|
||||
*/
|
||||
onCancel(): void {
|
||||
this.router.navigate(['/admin/questions']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all fields as touched to show validation errors
|
||||
*/
|
||||
private markFormGroupTouched(formGroup: FormGroup | FormArray): void {
|
||||
Object.keys(formGroup.controls).forEach(key => {
|
||||
const control = formGroup.get(key);
|
||||
control?.markAsTouched();
|
||||
|
||||
if (control instanceof FormGroup || control instanceof FormArray) {
|
||||
this.markFormGroupTouched(control);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message for field
|
||||
*/
|
||||
getErrorMessage(fieldName: string): string {
|
||||
const control = this.questionForm.get(fieldName);
|
||||
|
||||
if (!control || !control.errors || !control.touched) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (control.errors['required']) {
|
||||
return `${this.getFieldLabel(fieldName)} is required`;
|
||||
}
|
||||
if (control.errors['minlength']) {
|
||||
return `${this.getFieldLabel(fieldName)} must be at least ${control.errors['minlength'].requiredLength} characters`;
|
||||
}
|
||||
if (control.errors['min']) {
|
||||
return `${this.getFieldLabel(fieldName)} must be at least ${control.errors['min'].min}`;
|
||||
}
|
||||
if (control.errors['max']) {
|
||||
return `${this.getFieldLabel(fieldName)} must be at most ${control.errors['max'].max}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field label for error messages
|
||||
*/
|
||||
private getFieldLabel(fieldName: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
questionText: 'Question text',
|
||||
questionType: 'Question type',
|
||||
categoryId: 'Category',
|
||||
difficulty: 'Difficulty',
|
||||
correctAnswer: 'Correct answer',
|
||||
explanation: 'Explanation',
|
||||
points: 'Points'
|
||||
};
|
||||
return labels[fieldName] || fieldName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get form-level error message
|
||||
*/
|
||||
getFormError(): string | null {
|
||||
if (this.questionForm.errors?.['correctAnswerMismatch']) {
|
||||
return 'Correct answer must match one of the options';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user