Skip to content

Commit

Permalink
v3.29.0 (#227)
Browse files Browse the repository at this point in the history
* Fix broken practicea area url builder (base href)

* Add team challenge management modal

* Move context menu item down and mark it danger
  • Loading branch information
sei-bstein authored Jan 30, 2025
1 parent 34b5b69 commit 7e26017
Show file tree
Hide file tree
Showing 23 changed files with 299 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,23 @@
</button>
</li>
<li class="divider dropdown-divider"></li>
<li role="menuitem">
<a class="dropdown-item btn" *ngIf="observeTeamUrl" [href]="observeTeamUrl">Observe Team</a>
</li>
<li role="menuitem" *ngIf="hasStartedSession && !hasEndedSession">
<button class="dropdown-item btn" (click)="handleExtend(team)">Extend Session</button>
</li>
<li role="menuitem">
<button class="dropdown-item btn" (click)="handleManageBonuses(team)">
Manual Bonuses
</button>
<a class="dropdown-item btn" *ngIf="observeTeamUrl" [href]="observeTeamUrl">Observe Team</a>
</li>

<li role="menuitem" *ngIf="hasEndedSession">
<a class="dropdown-item btn" target="_blank" [href]="certificateUrl">
View Certificate
</a>
</li>
<li role="menuitem">
<button class="dropdown-item btn" (click)="handleManageBonuses(team)">
Manual Bonuses
</button>
</li>

<ng-container *ngIf="!hasStartedSession">
<li class="divider dropdown-divider"></li>
Expand Down Expand Up @@ -72,6 +73,9 @@
</ng-container>

<li class="divider dropdown-divider"></li>
<li role="menuitem">
<button class="dropdown-item btn btn-danger" (click)="handleViewChallenges(team)">Manage Challenges</button>
</li>
<ng-container *ngIf="hasStartedSession; else unenroll">
<li role="menuitem">
<button class="dropdown-item btn btn-danger"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ExtendTeamsModalComponent } from '../../extend-teams-modal/extend-teams
import { ApiUser } from '@/api/user-models';
import { RouterService } from '@/services/router.service';
import { PlayerMode } from '@/api/player-models';
import { TeamChallengesModalComponent } from '../../team-challenges-modal/team-challenges-modal.component';

export interface TeamSessionResetRequest {
teamId: string;
Expand Down Expand Up @@ -210,4 +211,14 @@ export class GameCenterTeamContextMenuComponent {
modalClasses: ["modal-xl"]
});
}

handleViewChallenges(team: GameCenterTeamsResultsTeam) {
this.modalService.openComponent({
content: TeamChallengesModalComponent,
context: {
teamId: team.id
},
modalClasses: ["modal-xl"]
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,19 +91,9 @@ <h4 class="px-3 mr-1">Timeline</h4>
</ng-container>

<div class="other-tools-container mt-4">
<h4 class="mb-2 px-3">Other tools</h4>
<h4 class="mb-2 px-3">Announce</h4>

<app-announce [teamId]="team.id" placeholderText="Your message (sends only to this player/team)"></app-announce>

<div class="other-tools-buttons px-3">
<button class="btn btn-success btn-sm mr-2" (click)="toggleRawView(!showChallengeYaml)">
<fa-icon [icon]="fa.infoCircle"></fa-icon>
<span>{{ (showChallengeYaml ? "Hide" : "View") }} Data</span>
</button>

<app-yaml-block *ngIf="showChallengeYaml && !isLoadingChallenges"
[source]="teamChallenges"></app-yaml-block>
</div>
</div>
</app-modal-content>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,23 +90,6 @@ export class GameCenterTeamDetailComponent implements OnInit {
modalClasses: ["modal-lg"]
});
}

protected async toggleRawView(isExpanding: boolean) {
if (isExpanding) {
this.isLoadingChallenges = true;

this.teamChallenges = await firstValueFrom(
this
.playerService
.getTeamChallenges(this.team.id)
);

this.isLoadingChallenges = false;
}

this.showChallengeYaml = isExpanding;
}

protected async updateNameChangeRequest(playerId: string, overrideName: string, args: UpdatePlayerNameChangeRequest) {
if (!args.status) {
args.approvedName = overrideName || args.requestedName;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<app-modal-content *ngIf="ctx; else loading" title="Challenges" [subtitle]="ctx.team.name" [subSubtitle]="ctx.game.name"
[hideCancel]=true>
<app-error-div [errors]="errors"></app-error-div>

<ng-container *ngIf="ctx.challengeSpecStatuses.length; else noChallenges">
<alert *ngIf="('Teams_CreateEditDeleteChallenges' | can) || ('Teams_DeployGameResources' | can)" type="danger">
<div class="d-flex align-items-center">
<fa-icon class="d-block mr-2" size="2xl" [icon]="fa.triangleExclamation"></fa-icon>
<div class="flex-grow-1 pl-2">
<p>
<strong>Be <em>very</em> careful with these tools.</strong> Starting a challenge on a player or
team's behalf will affect their cumulative time, and undeploying or purging it will erase all
progress they've made on it. Use with caution!
</p>

<p class="mt-2" *ngIf="!isUnlocked">
Enter the team's admin code <strong>({{ unlockAdminCode }})</strong> below to unlock challenge
management tools.
</p>
</div>
</div>

<div class="form-group p-0 mt-4" *ngIf="!isUnlocked">
<input type="text" class="form-control" [placeholder]="unlockAdminCode"
(input)="handleUnlockAdminCodeInput(unlockAdminCodeInput.value)" #unlockAdminCodeInput>
</div>
</alert>
<table class="table gameboard-table">
<thead>
<tr>
<th>Challenge</th>
<th>Available</th>
<th>Manage</th>
</tr>
</thead>

<tbody>
<tr *ngFor="let challenge of ctx.challengeSpecStatuses">
<td>
{{ challenge.spec.name }}
<div *ngIf="challenge.challengeId" class="d-flex align-items-center text-muted">
<div>{{ challenge.score || 0 }} / {{ challenge.scoreMax }} points &middot;</div>
<button class="btn btn-link p-0" [appCopyOnClick]="challenge.challengeId">{{ {id:
challenge.challengeId} | toSupportCode }}</button>
</div>
</td>
<td>
<div *ngIf="challenge.availabilityRange; else noAvailability"
class="challenge-availability-window">
{{ challenge.availabilityRange.start | friendlyDateAndTime }}
&dash;
{{ challenge.availabilityRange.end | friendlyDateAndTime }}
</div>
</td>
<td>
<div *ngIf="!isWorking; else working">
<div class="gb-button-group btn-group">
<button
*ngIf="challenge.state == 'notStarted' && ('Teams_CreateEditDeleteChallenges' | can)"
class="btn btn-danger" tooltip="Start the challenge"
(click)="handleStartClick(challenge.spec.id)" [disabled]="!isUnlocked">
<fa-icon [icon]="fa.circlePlay"></fa-icon>
</button>
<button *ngIf="challenge.state == 'notDeployed' && ('Teams_DeployGameResources' | can)"
class="btn btn-danger" (click)="handleDeployClick(challenge.challengeId)"
tooltip="Deploy challenge resources" [disabled]="!isUnlocked">
<fa-icon [icon]="fa.play"></fa-icon>
</button>
<button *ngIf="challenge.state == 'deployed' && ('Teams_DeployGameResources' | can)"
class="btn btn-danger" (click)="handleUneployClick(challenge.challengeId)"
tooltip="Undeploy challenge resources" [disabled]="!isUnlocked">
<fa-icon [icon]="fa.stop"></fa-icon>
</button>
<button
*ngIf="challenge.state != 'notStarted' && ('Teams_CreateEditDeleteChallenges' | can)"
class="btn btn-danger" (click)="handlePurgeClick(challenge.challengeId)"
tooltip="Purge this challenge" [disabled]="!isUnlocked">
<fa-icon [icon]="fa.trash"></fa-icon>
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</ng-container>
</app-modal-content>

<ng-template #loading>
<app-spinner>Loading challenges...</app-spinner>
</ng-template>

<ng-template #working>
<div class="d-flex align-items-center justify-content-center">
<app-spinner size="small" color="#fff"></app-spinner>
</div>
</ng-template>

<ng-template #noAvailability>
<div class="text-muted text-center">--</div>
</ng-template>

<ng-template #noChallenges>
<em class="d-block my-2 text-muted text-center">No challenges have been configured for this game.</em>
</ng-template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CoreModule } from "@/core/core.module";
import { TeamService } from '@/api/team.service';
import { GetTeamChallengeSpecsStatusesResponse } from '@/api/teams.models';
import { SpinnerComponent } from '@/standalone/core/components/spinner/spinner.component';
import { ErrorDivComponent } from "../../../standalone/core/components/error-div/error-div.component";
import { ToSupportCodePipe } from '@/standalone/core/pipes/to-support-code.pipe';
import { ChallengesService } from '@/api/challenges.service';
import { firstValueFrom, Subject } from 'rxjs';
import { fa } from '@/services/font-awesome.service';

@Component({
selector: 'app-team-challenges-modal',
standalone: true,
imports: [
CommonModule,
CoreModule,
ErrorDivComponent,
SpinnerComponent,
ToSupportCodePipe
],
templateUrl: './team-challenges-modal.component.html',
styleUrls: ['./team-challenges-modal.component.scss']
})
export class TeamChallengesModalComponent implements OnInit {
private challengeService = inject(ChallengesService);
private teamService = inject(TeamService);
teamId!: string;

protected ctx?: GetTeamChallengeSpecsStatusesResponse;
protected errors: any[] = [];
protected fa = fa;
protected isWorking = false;
protected isUnlocked = false;
protected unlockAdminCode = "";
protected unlockAdminCodeInput$ = new Subject<string>();

async ngOnInit(): Promise<void> {
await this.load();
}

async handleDeployClick(challengeId?: string): Promise<void> {
if (!challengeId) {
this.errors.push("No challenge instance.");
}

await this.doChallengeStuff(() => firstValueFrom(this.challengeService.deploy({ id: challengeId! })));
}

async handleUneployClick(challengeId?: string): Promise<void> {
if (!challengeId) {
this.errors.push("No challenge instance.");
}

await this.doChallengeStuff(() => firstValueFrom(this.challengeService.undeploy({ id: challengeId! })));
}

async handlePurgeClick(challengeId?: string) {
if (!challengeId) {
this.errors.push("No challenge instance.");
}

await this.doChallengeStuff(() => this.challengeService.delete(challengeId!));
}

async handleStartClick(challengeSpecId: string): Promise<void> {
await this.doChallengeStuff(() => this.challengeService.start({ teamId: this.teamId, challengeSpecId }));
}

handleUnlockAdminCodeInput(value: string) {
this.isUnlocked = value.toLowerCase() === this.unlockAdminCode.toLowerCase();
}

private async doChallengeStuff<T>(challengeTask: () => Promise<T>) {
try {
this.isWorking = true;
await challengeTask();
}
catch (err) {
this.errors.push(err);
}
finally {
this.isWorking = false;
}

await this.load();
}

private async load(): Promise<void> {
this.errors = [];
if (!this.teamId) {
throw new Error("TeamId is required.");
}

this.ctx = await this.teamService.getChallengeSpecStatuses(this.teamId);
this.unlockAdminCode = this.teamId.substring(0, 6);
}
}
6 changes: 3 additions & 3 deletions projects/gameboard-ui/src/app/api/board.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@ export class BoardService {
}

public launch(model: NewChallenge): Observable<Challenge> {
return this.http.post<Challenge>(`${this.url}/challenge`, model);
return this.http.post<Challenge>(`${this.url}/challenge/launch`, model);
}

public start(model: ChangedChallenge): Observable<Challenge> {
public deployResources(model: ChangedChallenge): Observable<Challenge> {
return this.http.put<Challenge>(`${this.url}/challenge/start`, model);
}

public stop(model: ChangedChallenge): Observable<Challenge> {
public undeployResources(model: ChangedChallenge): Observable<Challenge> {
return this.http.put<Challenge>(`${this.url}/challenge/stop`, model);
}

Expand Down
14 changes: 13 additions & 1 deletion projects/gameboard-ui/src/app/api/challenges.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { ChallengeProgressResponse, ChallengeSolutionGuide, GetUserActiveChallen

@Injectable({ providedIn: 'root' })
export class ChallengesService {
private _challengeDeleted$ = new Subject<string>();
public readonly challengeDeleted$ = this._challengeDeleted$.asObservable();

private _challengeDeployStateChanged$ = new Subject<Challenge>();
public readonly challengeDeployStateChanged$ = this._challengeDeployStateChanged$.asObservable();

Expand All @@ -21,6 +24,11 @@ export class ChallengesService {
private apiUrl: ApiUrlService,
private http: HttpClient) { }

public async delete(challengeId: string) {
await firstValueFrom(this.http.delete(this.apiUrl.build(`challenge/${challengeId}`)));
this._challengeDeleted$.next(challengeId);
}

public getActiveChallenges(userId: string): Promise<GetUserActiveChallengesResponse> {
return firstValueFrom(this.http.get<GetUserActiveChallengesResponse>(this.apiUrl.build(`/user/${userId}/challenges/active`)));
}
Expand All @@ -29,8 +37,12 @@ export class ChallengesService {
return this.http.get<Challenge>(this.apiUrl.build(`challenge/${id}`));
}

public start(request: { teamId: string, challengeSpecId: string }): Promise<Challenge> {
return firstValueFrom(this.http.post<Challenge>(this.apiUrl.build(`challenge`), request));
}

public startPlaying(challenge: NewChallenge): Observable<Challenge> {
return this.http.post<Challenge>(this.apiUrl.build("challenge"), challenge).pipe(
return this.http.post<Challenge>(this.apiUrl.build("challenge/launch"), challenge).pipe(
tap(challenge => this._challengeStarted$.next(challenge))
);
}
Expand Down
3 changes: 0 additions & 3 deletions projects/gameboard-ui/src/app/api/player.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,6 @@ export class PlayerService {
return this.http.get<Team>(`${this.url}/team/${id}`);
}

public getTeamChallenges = (id: string): Observable<TeamChallenge[]> =>
this.http.get<TeamChallenge[]>(`${this.url}/team/${id}/challenges`);

public observeTeams(id: string): Observable<any> {
return this.http.get<Team>(`${this.url}/teams/observe/${id}`);
}
Expand Down
6 changes: 5 additions & 1 deletion projects/gameboard-ui/src/app/api/team.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Observable, Subject, firstValueFrom, map, tap } from "rxjs";
import { SessionEndRequest, SessionExtendRequest, Team, TeamSummary } from "./player-models";
import { AddToTeamResponse, AdminEnrollTeamRequest, AdminEnrollTeamResponse, AdminExtendTeamSessionResponse, AdvanceTeamsRequest, RemoveFromTeamResponse, TeamSessionResetType, TeamSessionUpdate } from "./teams.models";
import { AddToTeamResponse, AdminEnrollTeamRequest, AdminEnrollTeamResponse, AdminExtendTeamSessionResponse, AdvanceTeamsRequest, GetTeamChallengeSpecsStatusesResponse, RemoveFromTeamResponse, TeamChallengeSpecStatus, TeamSessionResetType, TeamSessionUpdate } from "./teams.models";
import { ApiUrlService } from "@/services/api-url.service";
import { unique } from "../../tools/tools";
import { GamePlayState } from "./game-models";
Expand Down Expand Up @@ -75,6 +75,10 @@ export class TeamService {
return this.http.get<Team>(this.apiUrl.build(`/team/${teamId}`));
}

public getChallengeSpecStatuses(teamId: string) {
return firstValueFrom(this.http.get<GetTeamChallengeSpecsStatusesResponse>(this.apiUrl.build(`/team/${teamId}/challenges`)));
}

public getMailMetadata(gameId: string) {
return this.http.get<TeamSummary[]>(this.apiUrl.build(`/teams/${gameId}`));
}
Expand Down
Loading

0 comments on commit 7e26017

Please sign in to comment.