Skip to content

Commit

Permalink
Merge branch 'issue/242' into next
Browse files Browse the repository at this point in the history
  • Loading branch information
sei-bstein committed Nov 28, 2023
2 parents 5c252f6 + 5eea08c commit 707bdde
Show file tree
Hide file tree
Showing 22 changed files with 251 additions and 200 deletions.
2 changes: 1 addition & 1 deletion projects/gameboard-ui/src/app/api/board.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { GameSessionService } from '../services/game-session.service';
import { ConfigService } from '../utility/config.service';
import { BoardPlayer, BoardSpec, Challenge, ChallengeResult, ChallengeSummary, ChangedChallenge, ConsoleActor, NewChallenge, ObserveChallenge, SectionSubmission, VmConsole } from './board-models';
import { BoardPlayer, BoardSpec, Challenge, ChallengeSummary, ChangedChallenge, ConsoleActor, NewChallenge, ObserveChallenge, SectionSubmission, VmConsole } from './board-models';

@Injectable({ providedIn: 'root' })
export class BoardService {
Expand Down
2 changes: 1 addition & 1 deletion projects/gameboard-ui/src/app/api/challenges.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class ChallengesService {

public grade(model: SectionSubmission): Observable<Challenge> {
return this.http.put<Challenge>(this.apiUrl.build("challenge/grade"), model).pipe(
tap(challenge => this._challengeGraded$.next(challenge))
tap(challenge => this._challengeGraded$.next(challenge)),
);
}

Expand Down
10 changes: 3 additions & 7 deletions projects/gameboard-ui/src/app/api/feedback.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ import { Observable } from 'rxjs';
import { ConfigService } from '../utility/config.service';
import { Feedback, FeedbackReportDetails, FeedbackSubmission } from './feedback-models';

@Injectable({
providedIn: 'root'
})
@Injectable({ providedIn: 'root' })
export class FeedbackService {
url = '';

Expand All @@ -21,16 +19,14 @@ export class FeedbackService {
}

public list(search: any): Observable<FeedbackReportDetails[]> {
return this.http.get<FeedbackReportDetails[]>(`${this.url}/feedback/list`, {params: search});
return this.http.get<FeedbackReportDetails[]>(`${this.url}/feedback/list`, { params: search });
}

public retrieve(search: any): Observable<Feedback> {
return this.http.get<Feedback>(`${this.url}/feedback`, {params: search});
return this.http.get<Feedback>(`${this.url}/feedback`, { params: search });
}

public submit(model: FeedbackSubmission): Observable<Feedback> {
return this.http.put<Feedback>(`${this.url}/feedback/submit`, model);
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<ng-container *ngIf="!isForceHidden">
<div class="d-flex align-items-center" tabindex="0" (click)="toggleShow()">
<fa-icon class="d-inline" [icon]="show ? faCaretDown : faCaretRight" size="lg"></fa-icon>
<span>
<h3 *ngIf="type == 'challenge'" class="d-inline">{{title}}</h3>
<h2 *ngIf="type == 'game'" class="d-inline">{{title}}</h2>
</span>
</div>

<form #form="ngForm" [ngFormOptions]="{updateOn: 'change'}" [hidden]="!show" class="pop-light p-4 mt-4">

<app-error-div [errors]="errors"></app-error-div>

<div *ngIf="game?.feedbackTemplate?.message as message"
class="mb-3 bg-light p-2 rounded d-flex justify-content-left">
<fa-icon [icon]="faExclamationCircle" class="pr-1"></fa-icon>
<small><span class="font-weight-bold align-middle">{{message}}</span></small>
</div>

<div *ngFor="let q of feedbackForm.questions; let i = index" class="mb-4">
<label for="{{q.id}}">
{{i+1}}. {{q.prompt}}
<span *ngIf="q.required" class="required">*</span>
</label>

<!-- Text -->
<ng-container *ngIf="q.type == 'text'">
<textarea rows="1" type="text" class="form-control" id={{q.id}} name={{q.id}} [(ngModel)]="q.answer"
[disabled]="feedbackForm.submitted!" maxlength={{characterLimit}}
style="white-space: pre; overflow-wrap: normal; overflow-x: scroll; resize: none"></textarea>
<small class="d-flex justify-content-end"
[class]="q.answer?.length == characterLimit ? 'text-warning' : 'text-muted'">
{{characterLimit - (q.answer?.length ?? 0)}}
</small>
</ng-container>

<!-- Likert -->
<div *ngIf="q.type == 'likert'">
<small class="pr-2 text-dark">{{q.minLabel}}</small>
<div id={{q.id}} class="btn-group" btnRadioGroup name={{q.id}} tabindex="0" [(ngModel)]="q.answer"
[disabled]="feedbackForm.submitted!">
<label *ngFor="let i of options(q.min ?? 1, q.max ?? 1)" class="btn btn-outline-dark btn-sm m-0"
btnRadio="{{i}}">{{i}}
</label>
</div>
<small class="pl-2 text-dark">{{q.maxLabel}}</small>
</div>

<!-- Select one -->
<ng-container *ngIf="q.type == 'selectOne'">
<!-- Dropdown -->
<div *ngIf="q.display == 'dropdown'" id={{q.id}}>
<select id="dropdown-{{q.id}}" class="btn btn-secondary" [(ngModel)]="q.answer" [name]="q.id">
<option ngDefaultControl [value]="''" class="dropdown-item" selected>---</option>
<option *ngFor="let option of q.options" class="dropdown-item px-3" [disabled]="feedbackForm.submitted!"
ngDefaultControl [value]="option" [selected]="q.answer == option">{{option}}</option>
</select>
</div>
<!-- Radio buttons -->
<div *ngIf="q.display != 'dropdown'" id={{q.id}}>
<ng-container *ngFor="let option of q.options">
<div class="form-check">
<input class="form-check-input" type="radio" (change)="modifyMultipleAnswer(q, option, $event, true)"
[checked]="q.answer && q.answer!.indexOf(option) > -1" [disabled]="feedbackForm.submitted!"
id="check-{{q.id}}-{{option}}" [name]="q.id" [value]="option">
<label class="form-check-label" for="{{option}}">{{option}}{{q.specify && q.specify.key == option ? ": " +
(q.specify.prompt ? q.specify.prompt : "") : "" }}</label>
<br>
<textarea *ngIf="q.specify && q.specify.key == option" rows="1" type="text"
[disabled]="feedbackForm.submitted!"
style="white-space: pre; overflow-wrap: normal; overflow-x: scroll; resize: none"
(change)="modifySpecifiedAnswer(q, option, $event)" id="input-{{q.id}}-{{option}}"></textarea>
</div>
</ng-container>
</div>
</ng-container>

<!-- Select all that apply -->
<ng-container *ngIf="q.type == 'selectMany'">
<div id={{q.id}}>
<ng-container *ngFor="let option of q.options">
<div class="form-check">
<input class="form-check-input" type="checkbox" (change)="modifyMultipleAnswer(q, option, $event)"
[checked]="q.answer && q.answer!.indexOf(option) > -1" [disabled]="feedbackForm.submitted!"
id="check-{{q.id}}-{{option}}" [name]="option" [value]="option">
<label class="form-check-label" for="{{option}}">{{option}}{{q.specify && q.specify.key == option ? ": " +
(q.specify.prompt ? q.specify.prompt : "") : "" }}</label>
<br>
<textarea *ngIf="q.specify && q.specify.key == option" rows="1" type="text"
[disabled]="feedbackForm.submitted!"
style="white-space: pre; overflow-wrap: normal; overflow-x: scroll; resize: none"
(change)="modifySpecifiedAnswer(q, option, $event)" id="input-{{q.id}}-{{option}}"></textarea>
</div>
</ng-container>
</div>
</ng-container>
</div>

<app-confirm-button btnClass="btn btn-sm btn-secondary" (confirm)="submit()"
[disabled]="feedbackForm.submitted! || !feedbackForm.questions.length">
<fa-icon [icon]="faSubmit"></fa-icon>
<span>{{feedbackForm.submitted ? "Submitted" : "Submit"}}</span>
</app-confirm-button>
<div class="mt-3 d-flex justify-content-between">
<small class="">Responses cannot be changed after clicking submit.</small>
<small class="">{{status}}</small>
</div>
</form>

<p [hidden]="show || !feedbackForm.submitted" class="p-4 text-center">
Thank you for submitting feedback!
</p>

</ng-container>
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
import { Component, OnInit, ViewChild, Input, AfterViewInit, OnDestroy, OnChanges, SimpleChanges } from '@angular/core';
import { Component, ViewChild, Input, AfterViewInit, OnDestroy, OnChanges, SimpleChanges } from '@angular/core';
import { FormGroup, NgForm } from '@angular/forms';
import { BoardPlayer, BoardSpec } from '../../api/board-models';
import { firstValueFrom, Observable, of, Subject, Subscription } from 'rxjs';
import { catchError, debounceTime, delay, filter, first, tap } from 'rxjs/operators';
import { faCaretDown, faCaretRight, faCloudUploadAlt, faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
import { Observable, of, Subject, Subscription } from 'rxjs';
import { FeedbackService } from '../../api/feedback.service';
import { Feedback, FeedbackSubmission, FeedbackQuestion } from '../../api/feedback-models';
import { BoardGame } from '../../api/board-models';
import { catchError, combineAll, debounceTime, delay, filter, first, map, mergeAll, switchMap, takeUntil, takeWhile, tap } from 'rxjs/operators';
import { BoardGame, BoardPlayer, BoardSpec } from '@/api/board-models';
import { FeedbackService } from '@/api/feedback.service';
import { Feedback, FeedbackSubmission, FeedbackQuestion } from '@/api/feedback-models';

export type MiniBoardSpec = {
id: string,
instance?: {
id: string,
state: { isActive: boolean }
},
}

@Component({
selector: 'app-feedback-form',
templateUrl: './feedback-form.component.html',
styleUrls: ['./feedback-form.component.scss']
})
export class FeedbackFormComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy {
export class FeedbackFormComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() boardPlayer?: BoardPlayer;
@Input() spec?: BoardSpec;
@Input() hideIfFeedbackProvidedPreviously = false;
@Input() spec?: MiniBoardSpec;
@Input() title!: string;
@Input() type!: 'challenge' | 'game';
@ViewChild(NgForm) form!: FormGroup;
Expand All @@ -30,8 +38,9 @@ export class FeedbackFormComponent implements OnInit, AfterViewInit, OnChanges,

errors: any[] = [];
game?: BoardGame;
isForceHidden = false;
show = false;
submitPending: boolean = false;
show: boolean = false;

refreshSpec$ = new Subject<BoardSpec>();
status: string = "";
Expand All @@ -48,16 +57,13 @@ export class FeedbackFormComponent implements OnInit, AfterViewInit, OnChanges,
};
}

ngOnInit(): void {
this.load(this.boardPlayer, this.spec, this.type);
}

ngAfterViewInit(): void {
this.autosaveInit();
}

ngOnChanges(changes: SimpleChanges): void {
this.load(this.boardPlayer, this.spec, this.type);
async ngOnChanges(changes: SimpleChanges): Promise<void> {
await this.load(this.boardPlayer, this.spec, this.type);
this.isForceHidden = (this.feedbackForm?.submitted || false) && this.hideIfFeedbackProvidedPreviously;
}

ngOnDestroy(): void {
Expand All @@ -70,7 +76,7 @@ export class FeedbackFormComponent implements OnInit, AfterViewInit, OnChanges,
// swap in the answers from user submitted response object, but only answers
feedback.questions.forEach((question) => {
// templateMap holds references to feedback form question objects, mapped by id
let questionTemplate = this.templateMap.get(question.id);
const questionTemplate = this.templateMap.get(question.id);
if (questionTemplate)
questionTemplate.answer = question.answer;
});
Expand Down Expand Up @@ -205,70 +211,75 @@ export class FeedbackFormComponent implements OnInit, AfterViewInit, OnChanges,
return Array.from(new Array(max), (x, i) => i + min);
}

private load(boardPlayer?: BoardPlayer, spec?: BoardSpec, type?: "challenge" | "game") {
private async load(boardPlayer?: BoardPlayer, spec?: MiniBoardSpec, type?: "challenge" | "game") {
// clear form
this.form?.reset();
this.status = "";
this.errors = [];

if (!boardPlayer || !spec || !type)
if (!boardPlayer || !type)
return;

this.game = boardPlayer.game;
if (type == "challenge") {
if (!spec) {
throw new Error("Can't load challenge-level feedback without a spec.");
}

if (type === "challenge") {
this.loadChallenge(boardPlayer, spec);
await this.loadChallenge(boardPlayer, spec);
return;
}

this.loadGame(boardPlayer, spec);
await this.loadGame(boardPlayer);
}

private loadChallenge(boardPlayer: BoardPlayer, spec: BoardSpec) {
private async loadChallenge(boardPlayer: BoardPlayer, spec: MiniBoardSpec) {
this.setTemplate(boardPlayer.game.feedbackTemplate.challenge);

if (!spec) {
return;
}

this.api.retrieve({
challengeSpecId: spec.id,
challengeId: spec.instance?.id,
gameId: boardPlayer.gameId
}).pipe(
first(),
tap(feedback => {
if (feedback)
this.updateFeedback(feedback);

// behavior for whether to hide form on load based on challenge status or already submitted
this.show = !this.spec?.instance?.state.isActive;

if (feedback?.submitted) {
this.show = false;
}
})
).subscribe();
try {
const feedback = await firstValueFrom(
this.api.retrieve({
challengeSpecId: spec.id,
challengeId: spec.instance?.id,
gameId: boardPlayer.gameId
})
);

if (feedback)
this.updateFeedback(feedback);

// behavior for whether to hide form on load based on challenge status or already submitted
this.show = !this.spec?.instance?.state.isActive;

if (feedback?.submitted) {
this.show = false;
}
}
catch (err: any) {
this.errors.push(err);
}
}

private loadGame(boardPlayer: BoardPlayer, spec: BoardSpec) {
private async loadGame(boardPlayer: BoardPlayer) {
this.setTemplate(boardPlayer.game.feedbackTemplate.game);

if (!boardPlayer) {
return;
}

this.api.retrieve({ gameId: boardPlayer.gameId })
.pipe(first()).subscribe(
feedback => {
this.updateFeedback(feedback);

if (boardPlayer.session.isAfter)
this.show = true;
if (feedback?.submitted)
this.show = false;
},
(err: any) => { }
);
try {
const feedback = await firstValueFrom(this.api.retrieve({ gameId: boardPlayer.gameId }));

if (boardPlayer.session.isAfter)
this.show = true;
if (feedback?.submitted)
this.show = false;
}
catch (err: any) {
this.errors.push(err);
}
}
}
6 changes: 6 additions & 0 deletions projects/gameboard-ui/src/app/core/core.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import { CountdownPipe } from './pipes/countdown.pipe';
import { CumulativeTimeClockComponent } from './components/cumulative-time-clock/cumulative-time-clock.component';
import { DoughnutChartComponent } from './components/doughnut-chart/doughnut-chart.component';
import { DropzoneComponent } from './components/dropzone/dropzone.component';
import { ErrorDivComponent } from './components/error-div/error-div.component';
import { FeedbackFormComponent } from './components/feedback-form/feedback-form.component';
import { FriendlyDateAndTimePipe } from './pipes/friendly-date-and-time.pipe';
import { FriendlyTimePipe } from './pipes/friendly-time.pipe';
import { GameboardPerformanceSummaryComponent } from './components/gameboard-performance-summary/gameboard-performance-summary.component';
Expand Down Expand Up @@ -86,11 +88,13 @@ import { UrlRewritePipe } from './pipes/url-rewrite.pipe';
import { WhitespacePipe } from './pipes/whitespace.pipe';
import { YamlBlockComponent } from './components/yaml-block/yaml-block.component';
import { YamlPipe } from './pipes/yaml.pipe';
import { CamelspacePipe } from './pipes/camelspace.pipe';

const PUBLIC_DECLARATIONS = [
GbProgressBarComponent,
ApiUrlPipe,
AssetPathPipe,
CamelspacePipe,
ChallengeResultColorPipe,
ChallengeResultPrettyPipe,
ChallengeSolutionGuideComponent,
Expand All @@ -99,6 +103,8 @@ const PUBLIC_DECLARATIONS = [
CumulativeTimeClockComponent,
DoughnutChartComponent,
DropzoneComponent,
ErrorDivComponent,
FeedbackFormComponent,
FriendlyDateAndTimePipe,
GameboardPerformanceSummaryComponent,
GameCardImageComponent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,5 @@ <h3>Challenge Questions</h3>
</app-clipspan>
</div>
</div>

<div
*ngIf="legacyContext.boardSpec?.instance?.state?.challenge && legacyContext.boardPlayer?.game?.feedbackTemplate?.challenge?.length">
<app-feedback-form [boardPlayer]="legacyContext.boardPlayer!" [title]="'Challenge Feedback'"
[spec]="legacyContext.boardSpec!" [type]="'challenge'"></app-feedback-form>
</div>
</ng-container>
</ng-container>
Loading

0 comments on commit 707bdde

Please sign in to comment.