add changes

This commit is contained in:
AD2025
2025-11-14 21:48:47 +02:00
parent 6f23890407
commit 37b4d565b1
72 changed files with 17104 additions and 246 deletions

View File

@@ -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;
}
}