Skip to content

Commit

Permalink
Markdown Syntax to create a quiz within a scenario (hobbyfarm#195)
Browse files Browse the repository at this point in the history
* Add quiz-checkbox component

It is now possible to define questions in markdown within scenario steps

* Refactor helper text and validation

* Add question type "radio"

* Resolve linting warnings and errors (code quality)

* Run prettier on new html and scss files

* Fix linting errors

* Fix linting errors

* Make validation optional

* Improve checkbox validation

* Fix linting errors

* Refactor quiz

* Remove empty constructor

* Remove unused variable

* Change update method

* Disable input for quiz questions after submission

* remove ngSubmit

* Remove now unused variable

* submit forms

* Fix validation

FormGroup.disable() not only disables the form but also validation.

Therefore, we can not rely on FormGroup.valid anymore.

* Only submit enabled forms

* Validate radio buttons after(!) hitting submit

* Fix linting error/warning

* Remove commented out code

* Rm console.log

* Add form typings

* Run prettier

* Remove unneccessary code

* Add error/success message customization

* Fix radio quiz validation

* Fix validation config

* Fix linting errors/warnings

* Add optional reset button for quiz component

* Run prettier

* Fix linting error

* Add detailed validation for checkbox component

* Make helper/error/success message optional

* Add detailed validation for radio quiz type

* Fix linting error

* Run prettier

* Disable submit button if a quiz was submitted and not reset.

* Add default success/error msg for validation standard mode

* Refactoring

* Remove unused imports

* Fix radio validation

* Remove empty constructor

---------

Co-authored-by: Jan-Gerrit Goebel <[email protected]>
  • Loading branch information
PhilipAB and jggoebel authored Apr 15, 2024
1 parent 0ff5cf6 commit edb8665
Show file tree
Hide file tree
Showing 23 changed files with 705 additions and 22 deletions.
15 changes: 14 additions & 1 deletion src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ import { AngularSplitModule } from 'angular-split';
import { HfMarkdownComponent } from './hf-markdown/hf-markdown.component';
import { PrintableComponent } from './printable/printable.component';
import { GargantuaClientFactory } from './services/gargantua.service';
import { QuizCheckboxComponent } from './quiz/quiz-checkbox.component';
import { QuizRadioComponent } from './quiz/quiz-radio.component';
import { QuizBodyComponent } from './quiz/quiz-body.component';
import { QuizComponent } from './quiz/quiz.component';
import { GuacTerminalComponent } from './scenario/guacTerminal.component';
import { IdeWindowComponent } from './scenario/ideWindow.component';
import { ContextService } from './services/context.service';
Expand Down Expand Up @@ -69,6 +73,7 @@ import {
eyeHideIcon,
clockIcon,
} from '@cds/core/icon';
import { QuizLabelComponent } from './quiz/quiz-label.component';

ClarityIcons.addIcons(
layersIcon,
Expand Down Expand Up @@ -132,6 +137,11 @@ export function jwtOptionsFactory() {
ScenarioCardComponent,
StepComponent,
CtrComponent,
QuizCheckboxComponent,
QuizRadioComponent,
QuizBodyComponent,
QuizComponent,
QuizLabelComponent,
VMClaimComponent,
AtobPipe,
HfMarkdownComponent,
Expand All @@ -155,7 +165,10 @@ export function jwtOptionsFactory() {
sanitize: false,
convertHTMLEntities: false,
},
globalParsers: [{ component: CtrComponent }],
globalParsers: [
{ component: CtrComponent },
{ component: QuizComponent },
],
}),
JwtModule.forRoot({
jwtOptionsProvider: {
Expand Down
13 changes: 13 additions & 0 deletions src/app/hf-markdown/hf-markdown.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@ export class HfMarkdownComponent implements OnChanges {
`;
},

quiz(code: string, quizTitle: string, allowedAttempts?: string) {
const tempAtts = Number(allowedAttempts);
const allowedAtts = isNaN(tempAtts) || tempAtts < 1 ? 1 : tempAtts;
return `
<quiz
quizTitle="${quizTitle}"
questionsRaw="${code}"
[allowedAtts]="${allowedAtts}"
>
</quiz>
`;
},

note(code: string, type: string, message: string) {
return `
<div class="note ${type}">
Expand Down
19 changes: 19 additions & 0 deletions src/app/quiz/QuestionParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { QuestionType } from './QuestionType';
import { Validation } from './Validation';

export interface QuestionParams {
questionTitle: string;
helperText: string;
questionType: QuestionType;
validation: Validation;
successMsg: string;
errorMsg: string;
}

export type QuestionParam =
| 'title'
| 'info'
| 'type'
| 'validation'
| 'successMsg'
| 'errorMsg';
6 changes: 6 additions & 0 deletions src/app/quiz/QuestionType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type QuestionType = 'radio' | 'checkbox';

export function isQuestionType(value: string): value is QuestionType {
const validValues: string[] = ['radio', 'checkbox'];
return validValues.includes(value);
}
9 changes: 9 additions & 0 deletions src/app/quiz/QuizFormGroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { FormArray, FormControl, FormGroup } from '@angular/forms';

export type QuizCheckboxFormGroup = FormGroup<{
quiz: FormArray<FormControl<boolean>>;
}>;

export type QuizRadioFormGroup = FormGroup<{
quiz: FormControl<number | null>;
}>;
6 changes: 6 additions & 0 deletions src/app/quiz/Validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type Validation = 'none' | 'standard' | 'detailed';

export function isValidation(value: string): value is Validation {
const validValues: string[] = ['none', 'standard', 'detailed'];
return validValues.includes(value);
}
86 changes: 86 additions & 0 deletions src/app/quiz/quiz-base.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { FormGroup } from '@angular/forms';
import { ClrForm } from '@clr/angular';
import { Validation } from './Validation';
import { Component, Input, OnInit, ViewChild } from '@angular/core';

@Component({
template: '',
})
export abstract class QuizBaseComponent implements OnInit {
@Input()
public options: string;
@Input()
public helperText: string;
@Input()
public title: string;
@Input()
public validation: Validation;
@Input()
public errMsg: string;
@Input()
public successMsg: string;

@ViewChild(ClrForm, { static: true })
clrForm: ClrForm;
abstract quizForm: FormGroup;

public optionTitles: string[] = [];
public isSubmitted = false;
public validSubmission = false;
public validationEnabled: boolean;

ngOnInit(): void {
this.validationEnabled = this.validation != 'none';
this.extractQuizOptions();
this.createQuizForm();
}

// This function extracts the different possible answers to a quiz question and identifies correct answers
protected abstract extractQuizOptions(): void;

// Create the quiz form group
protected abstract createQuizForm(): void;

public submit() {
this.isSubmitted = true;
if (this.quizForm.invalid) {
this.clrForm.markAsTouched();
} else {
this.validSubmission = true;
}
this.quizForm.disable();
}

public reset() {
this.isSubmitted = false;
this.validSubmission = false;
this.quizForm.reset();
this.quizForm.enable();
}

// returns if the option at the specified index is selected
protected abstract isSelectedOption(index: number): boolean;

// returns if the option at the specified index is correct
protected abstract isCorrectOption(index: number): boolean;

// funtion for a label to determine if it should be styled as correctly selected option
public hasCorrectOptionClass(index: number): boolean {
return (
this.validation == 'detailed' &&
this.isSubmitted &&
this.isCorrectOption(index)
);
}

// funtion for a label to determine if it should be styled as incorrectly selected option
public hasIncorrectOptionClass(index: number): boolean {
return (
this.validation == 'detailed' &&
this.isSubmitted &&
!this.validSubmission &&
this.isSelectedOption(index) &&
!this.isCorrectOption(index)
);
}
}
25 changes: 25 additions & 0 deletions src/app/quiz/quiz-body.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<ng-container>
<span class="clr-subtext" *ngIf="!isSubmitted && helperText !== ''">{{
helperText
}}</span>
<clr-alert
[clrAlertType]="'danger'"
[clrAlertClosable]="false"
[clrAlertSizeSmall]="true"
*ngIf="validationEnabled && isSubmitted && !isValid && errMsg !== ''"
>
<clr-alert-item>
<span class="alert-text">{{ errMsg }}</span>
</clr-alert-item>
</clr-alert>
<clr-alert
[clrAlertType]="'success'"
[clrAlertClosable]="false"
[clrAlertSizeSmall]="true"
*ngIf="validationEnabled && isSubmitted && isValid && successMsg !== ''"
>
<clr-alert-item>
<span class="alert-text">{{ successMsg }}</span>
</clr-alert-item>
</clr-alert>
</ng-container>
3 changes: 3 additions & 0 deletions src/app/quiz/quiz-body.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.clr-subtext {
margin-bottom: 0.3rem;
}
22 changes: 22 additions & 0 deletions src/app/quiz/quiz-body.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Component, Input } from '@angular/core';

@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'quiz-body',
templateUrl: 'quiz-body.component.html',
styleUrls: ['quiz-body.component.scss'],
})
export class QuizBodyComponent {
@Input()
public helperText = '';
@Input()
public isValid: boolean;
@Input()
public isSubmitted: boolean;
@Input()
public validationEnabled: boolean;
@Input()
public errMsg = '';
@Input()
public successMsg = '';
}
26 changes: 26 additions & 0 deletions src/app/quiz/quiz-checkbox.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<form class="quiz-form" clrForm [formGroup]="quizForm">
<clr-checkbox-container [class.clr-error]="quizForm.invalid && isSubmitted">
<label>{{ title }}</label>
<clr-checkbox-wrapper
formArrayName="quiz"
*ngFor="let optionTitle of optionTitles; let i = index"
>
<input type="checkbox" clrCheckbox [formControlName]="i" />
<label>
<quiz-label
[optionTitle]="optionTitle"
[hasCorrectOptionClass]="hasCorrectOptionClass(i)"
[hasIncorrectOptionClass]="hasIncorrectOptionClass(i)"
></quiz-label>
</label>
</clr-checkbox-wrapper>
</clr-checkbox-container>
<quiz-body
[helperText]="helperText"
[isValid]="validSubmission"
[isSubmitted]="isSubmitted"
[validationEnabled]="validationEnabled"
[errMsg]="errMsg"
[successMsg]="successMsg"
></quiz-body>
</form>
12 changes: 12 additions & 0 deletions src/app/quiz/quiz-checkbox.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
pre {
display: flex;
flex-direction: column;
}
form {
display: inherit;
flex-direction: column;
}
clr-checkbox-container {
flex-direction: column !important;
margin-top: 0;
}
92 changes: 92 additions & 0 deletions src/app/quiz/quiz-checkbox.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Component } from '@angular/core';
import {
AbstractControl,
FormArray,
NonNullableFormBuilder,
FormControl,
ValidatorFn,
} from '@angular/forms';
import { QuizCheckboxFormGroup } from './QuizFormGroup';
import { QuizBaseComponent } from './quiz-base.component';

@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'quiz-checkbox',
templateUrl: 'quiz-checkbox.component.html',
styleUrls: ['quiz-checkbox.component.scss'],
})
export class QuizCheckboxComponent extends QuizBaseComponent {
public override quizForm: QuizCheckboxFormGroup;
public requiredValues: boolean[] = [];

constructor(private fb: NonNullableFormBuilder) {
super();
}

protected override extractQuizOptions() {
this.options.split('\n- ').forEach((option: string) => {
this.optionTitles.push(option.split(':(')[0]);
const requiredValue = option.split(':(')[1].toLowerCase() === 'x)';
this.requiredValues.push(requiredValue);
});
}

protected override createQuizForm() {
if (this.validationEnabled) {
this.quizForm = this.fb.group(
{
quiz: new FormArray<FormControl<boolean>>(
[],
this.validateCheckboxes(),
),
},
{ updateOn: 'change' },
);
} else {
this.quizForm = this.fb.group(
{
quiz: new FormArray<FormControl<boolean>>([]),
},
{ updateOn: 'change' },
);
}
this.addCheckboxes();
}

private addCheckboxes() {
this.optionTitles.forEach(() =>
this.optionsFormArray.push(this.fb.control(false)),
);
}

private get optionsFormArray(): FormArray<FormControl<boolean>> {
return this.quizForm.controls.quiz;
}

private validateCheckboxes(): ValidatorFn {
return (control: AbstractControl) => {
const formArray = control as FormArray<FormControl<boolean>>;
let validatedCheckboxes = true;
formArray.controls.forEach(
(control: FormControl<boolean>, index: number) => {
validatedCheckboxes =
validatedCheckboxes && control.value === this.requiredValues[index];
},
);
if (!validatedCheckboxes) {
return {
checkboxesValidated: true,
};
}
return null;
};
}

protected override isSelectedOption(index: number): boolean {
return this.optionsFormArray.at(index).value;
}

protected override isCorrectOption(index: number): boolean {
return this.requiredValues[index];
}
}
19 changes: 19 additions & 0 deletions src/app/quiz/quiz-label.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<span
[ngClass]="{
'correct-option': hasCorrectOptionClass,
'incorrect-option': hasIncorrectOptionClass,
}"
>{{ optionTitle }}</span
>
<cds-icon
*ngIf="hasCorrectOptionClass"
shape="success-standard"
status="success"
solid="true"
></cds-icon
><cds-icon
*ngIf="hasIncorrectOptionClass"
shape="exclamation-circle"
status="danger"
solid="true"
></cds-icon>
Loading

0 comments on commit edb8665

Please sign in to comment.