From 7e26017885b30c1b16a055aea9d81c9c304848dd Mon Sep 17 00:00:00 2001 From: Ben Stein <115497763+sei-bstein@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:19:48 -0500 Subject: [PATCH] v3.29.0 (#227) * Fix broken practicea area url builder (base href) * Add team challenge management modal * Move context menu item down and mark it danger --- ...me-center-team-context-menu.component.html | 16 ++- ...game-center-team-context-menu.component.ts | 11 ++ .../game-center-team-detail.component.html | 12 +- .../game-center-team-detail.component.ts | 17 --- .../team-challenges-modal.component.html | 105 ++++++++++++++++++ .../team-challenges-modal.component.scss | 0 .../team-challenges-modal.component.ts | 99 +++++++++++++++++ .../gameboard-ui/src/app/api/board.service.ts | 6 +- .../src/app/api/challenges.service.ts | 14 ++- .../src/app/api/player.service.ts | 3 - .../gameboard-ui/src/app/api/team.service.ts | 6 +- .../gameboard-ui/src/app/api/teams.models.ts | 18 ++- .../app/api/user-role-permissions.models.ts | 1 + .../src/app/core/pipes/can.pipe.ts | 14 ++- .../session-start-controls.component.html | 2 +- .../pages/game-page/game-page.component.html | 2 +- .../gameboard-page.component.ts | 4 +- .../player-enroll.component.html | 2 +- .../src/app/services/font-awesome.service.ts | 10 +- .../src/app/services/router.service.ts | 5 +- .../components/spinner/spinner.component.ts | 3 +- .../src/app/stores/active-challenges.store.ts | 5 + .../user-page/user-page.component.html | 2 +- 23 files changed, 299 insertions(+), 58 deletions(-) create mode 100644 projects/gameboard-ui/src/app/admin/components/team-challenges-modal/team-challenges-modal.component.html create mode 100644 projects/gameboard-ui/src/app/admin/components/team-challenges-modal/team-challenges-modal.component.scss create mode 100644 projects/gameboard-ui/src/app/admin/components/team-challenges-modal/team-challenges-modal.component.ts diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.html b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.html index 303971e2d..ee1836ef5 100644 --- a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.html +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.html @@ -23,22 +23,23 @@ -
  • - Observe Team -
  • - + Observe Team
  • +
  • View Certificate
  • +
  • + +
  • @@ -72,6 +73,9 @@ +
  • + +
  • - - - diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-detail/game-center-team-detail.component.ts b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-detail/game-center-team-detail.component.ts index 51d199bc4..0a2610b40 100644 --- a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-detail/game-center-team-detail.component.ts +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-detail/game-center-team-detail.component.ts @@ -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; diff --git a/projects/gameboard-ui/src/app/admin/components/team-challenges-modal/team-challenges-modal.component.html b/projects/gameboard-ui/src/app/admin/components/team-challenges-modal/team-challenges-modal.component.html new file mode 100644 index 000000000..88cd33456 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/team-challenges-modal/team-challenges-modal.component.html @@ -0,0 +1,105 @@ + + + + + +
    + +
    +

    + Be very careful with these tools. 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! +

    + +

    + Enter the team's admin code ({{ unlockAdminCode }}) below to unlock challenge + management tools. +

    +
    +
    + +
    + +
    +
    + + + + + + + + + + + + + + + + +
    ChallengeAvailableManage
    + {{ challenge.spec.name }} +
    +
    {{ challenge.score || 0 }} / {{ challenge.scoreMax }} points ·
    + +
    +
    +
    + {{ challenge.availabilityRange.start | friendlyDateAndTime }} + ‐ + {{ challenge.availabilityRange.end | friendlyDateAndTime }} +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    + + + Loading challenges... + + + +
    + +
    +
    + + +
    --
    +
    + + + No challenges have been configured for this game. + diff --git a/projects/gameboard-ui/src/app/admin/components/team-challenges-modal/team-challenges-modal.component.scss b/projects/gameboard-ui/src/app/admin/components/team-challenges-modal/team-challenges-modal.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/projects/gameboard-ui/src/app/admin/components/team-challenges-modal/team-challenges-modal.component.ts b/projects/gameboard-ui/src/app/admin/components/team-challenges-modal/team-challenges-modal.component.ts new file mode 100644 index 000000000..5a4fea8e4 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/team-challenges-modal/team-challenges-modal.component.ts @@ -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(); + + async ngOnInit(): Promise { + await this.load(); + } + + async handleDeployClick(challengeId?: string): Promise { + if (!challengeId) { + this.errors.push("No challenge instance."); + } + + await this.doChallengeStuff(() => firstValueFrom(this.challengeService.deploy({ id: challengeId! }))); + } + + async handleUneployClick(challengeId?: string): Promise { + 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 { + await this.doChallengeStuff(() => this.challengeService.start({ teamId: this.teamId, challengeSpecId })); + } + + handleUnlockAdminCodeInput(value: string) { + this.isUnlocked = value.toLowerCase() === this.unlockAdminCode.toLowerCase(); + } + + private async doChallengeStuff(challengeTask: () => Promise) { + try { + this.isWorking = true; + await challengeTask(); + } + catch (err) { + this.errors.push(err); + } + finally { + this.isWorking = false; + } + + await this.load(); + } + + private async load(): Promise { + 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); + } +} diff --git a/projects/gameboard-ui/src/app/api/board.service.ts b/projects/gameboard-ui/src/app/api/board.service.ts index 774cf7fa3..cfbc9235c 100644 --- a/projects/gameboard-ui/src/app/api/board.service.ts +++ b/projects/gameboard-ui/src/app/api/board.service.ts @@ -42,14 +42,14 @@ export class BoardService { } public launch(model: NewChallenge): Observable { - return this.http.post(`${this.url}/challenge`, model); + return this.http.post(`${this.url}/challenge/launch`, model); } - public start(model: ChangedChallenge): Observable { + public deployResources(model: ChangedChallenge): Observable { return this.http.put(`${this.url}/challenge/start`, model); } - public stop(model: ChangedChallenge): Observable { + public undeployResources(model: ChangedChallenge): Observable { return this.http.put(`${this.url}/challenge/stop`, model); } diff --git a/projects/gameboard-ui/src/app/api/challenges.service.ts b/projects/gameboard-ui/src/app/api/challenges.service.ts index 0232ec958..a9d04cb1f 100644 --- a/projects/gameboard-ui/src/app/api/challenges.service.ts +++ b/projects/gameboard-ui/src/app/api/challenges.service.ts @@ -8,6 +8,9 @@ import { ChallengeProgressResponse, ChallengeSolutionGuide, GetUserActiveChallen @Injectable({ providedIn: 'root' }) export class ChallengesService { + private _challengeDeleted$ = new Subject(); + public readonly challengeDeleted$ = this._challengeDeleted$.asObservable(); + private _challengeDeployStateChanged$ = new Subject(); public readonly challengeDeployStateChanged$ = this._challengeDeployStateChanged$.asObservable(); @@ -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 { return firstValueFrom(this.http.get(this.apiUrl.build(`/user/${userId}/challenges/active`))); } @@ -29,8 +37,12 @@ export class ChallengesService { return this.http.get(this.apiUrl.build(`challenge/${id}`)); } + public start(request: { teamId: string, challengeSpecId: string }): Promise { + return firstValueFrom(this.http.post(this.apiUrl.build(`challenge`), request)); + } + public startPlaying(challenge: NewChallenge): Observable { - return this.http.post(this.apiUrl.build("challenge"), challenge).pipe( + return this.http.post(this.apiUrl.build("challenge/launch"), challenge).pipe( tap(challenge => this._challengeStarted$.next(challenge)) ); } diff --git a/projects/gameboard-ui/src/app/api/player.service.ts b/projects/gameboard-ui/src/app/api/player.service.ts index 10d4b3721..27a305a46 100644 --- a/projects/gameboard-ui/src/app/api/player.service.ts +++ b/projects/gameboard-ui/src/app/api/player.service.ts @@ -81,9 +81,6 @@ export class PlayerService { return this.http.get(`${this.url}/team/${id}`); } - public getTeamChallenges = (id: string): Observable => - this.http.get(`${this.url}/team/${id}/challenges`); - public observeTeams(id: string): Observable { return this.http.get(`${this.url}/teams/observe/${id}`); } diff --git a/projects/gameboard-ui/src/app/api/team.service.ts b/projects/gameboard-ui/src/app/api/team.service.ts index c57c6707e..76d05697f 100644 --- a/projects/gameboard-ui/src/app/api/team.service.ts +++ b/projects/gameboard-ui/src/app/api/team.service.ts @@ -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"; @@ -75,6 +75,10 @@ export class TeamService { return this.http.get(this.apiUrl.build(`/team/${teamId}`)); } + public getChallengeSpecStatuses(teamId: string) { + return firstValueFrom(this.http.get(this.apiUrl.build(`/team/${teamId}/challenges`))); + } + public getMailMetadata(gameId: string) { return this.http.get(this.apiUrl.build(`/teams/${gameId}`)); } diff --git a/projects/gameboard-ui/src/app/api/teams.models.ts b/projects/gameboard-ui/src/app/api/teams.models.ts index 7dc0ac346..d84c852f9 100644 --- a/projects/gameboard-ui/src/app/api/teams.models.ts +++ b/projects/gameboard-ui/src/app/api/teams.models.ts @@ -1,5 +1,5 @@ import { DateTime } from "luxon"; -import { PlayerWithSponsor, SimpleEntity } from "./models"; +import { DateTimeRange, PlayerWithSponsor, SimpleEntity } from "./models"; export interface AdminEnrollTeamRequest { gameId: string; @@ -39,6 +39,22 @@ export interface RemoveFromTeamResponse { user: SimpleEntity; } +export interface TeamChallengeSpecStatus { + availabilityRange?: DateTimeRange; + challengeId?: string; + score?: number; + scoreMax: number; + spec: SimpleEntity; + state: TeamChallengeSpecStatusState; +} + +export interface GetTeamChallengeSpecsStatusesResponse { + game: SimpleEntity; + team: SimpleEntity; + challengeSpecStatuses: TeamChallengeSpecStatus[]; +} + +export type TeamChallengeSpecStatusState = "notStarted" | "deployed" | "notDeployed" | "ended"; export type TeamSessionResetType = "unenrollAndArchiveChallenges" | "archiveChallenges" | "preserveChallenges"; export interface TeamSessionUpdate { diff --git a/projects/gameboard-ui/src/app/api/user-role-permissions.models.ts b/projects/gameboard-ui/src/app/api/user-role-permissions.models.ts index 4efe8f057..6829ab38f 100644 --- a/projects/gameboard-ui/src/app/api/user-role-permissions.models.ts +++ b/projects/gameboard-ui/src/app/api/user-role-permissions.models.ts @@ -19,6 +19,7 @@ export type UserRolePermissionKey = "Support_ViewTickets" | "SystemNotifications_CreateEdit" | "Teams_ApproveNameChanges" | + "Teams_CreateEditDeleteChallenges" | "Teams_DeployGameResources" | "Teams_EditSession" | "Teams_Enroll" | diff --git a/projects/gameboard-ui/src/app/core/pipes/can.pipe.ts b/projects/gameboard-ui/src/app/core/pipes/can.pipe.ts index a1b3668ad..e4db04c1e 100644 --- a/projects/gameboard-ui/src/app/core/pipes/can.pipe.ts +++ b/projects/gameboard-ui/src/app/core/pipes/can.pipe.ts @@ -1,12 +1,18 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import { inject, Pipe, PipeTransform } from '@angular/core'; import { UserRolePermissionKey } from '@/api/user-role-permissions.models'; import { UserRolePermissionsService } from '@/api/user-role-permissions.service'; +import { UserService } from '@/utility/user.service'; @Pipe({ name: 'can' }) export class CanPipe implements PipeTransform { - constructor(private permissionsService: UserRolePermissionsService) { } + private localUser = inject(UserService); + private permissionsService = inject(UserRolePermissionsService); - transform(user: { rolePermissions: UserRolePermissionKey[] } | null, permission: UserRolePermissionKey): boolean { - return this.permissionsService.canUser(user, permission); + transform(permission: UserRolePermissionKey): boolean { + if (!this.localUser.user$.value) { + return false; + } + + return this.permissionsService.canUser(this.localUser.user$.value, permission); } } diff --git a/projects/gameboard-ui/src/app/game/components/session-start-controls/session-start-controls.component.html b/projects/gameboard-ui/src/app/game/components/session-start-controls/session-start-controls.component.html index 6d2392741..add4ce3e4 100644 --- a/projects/gameboard-ui/src/app/game/components/session-start-controls/session-start-controls.component.html +++ b/projects/gameboard-ui/src/app/game/components/session-start-controls/session-start-controls.component.html @@ -56,7 +56,7 @@

    Game Connection Error

    Start Session - +
    diff --git a/projects/gameboard-ui/src/app/game/pages/game-page/game-page.component.html b/projects/gameboard-ui/src/app/game/pages/game-page/game-page.component.html index f76bc9500..7b22e8d21 100644 --- a/projects/gameboard-ui/src/app/game/pages/game-page/game-page.component.html +++ b/projects/gameboard-ui/src/app/game/pages/game-page/game-page.component.html @@ -114,7 +114,7 @@

    Feedback

    Practice Mode

    -

    +

    This game is now in Practice mode. If you want to try your hand at its challenges, head over to the Practice Area. diff --git a/projects/gameboard-ui/src/app/game/pages/gameboard-page/gameboard-page.component.ts b/projects/gameboard-ui/src/app/game/pages/gameboard-page/gameboard-page.component.ts index 8e8fb9399..7eb77f50c 100644 --- a/projects/gameboard-ui/src/app/game/pages/gameboard-page/gameboard-page.component.ts +++ b/projects/gameboard-ui/src/app/game/pages/gameboard-page/gameboard-page.component.ts @@ -204,7 +204,7 @@ export class GameboardPageComponent { // stop gamespace this.deploying = true; if (!model.instance) { return; } - this.api.stop({ id: model.instance.id }).subscribe( + this.api.undeployResources({ id: model.instance.id }).subscribe( c => this.syncOne(c) ); } @@ -215,7 +215,7 @@ export class GameboardPageComponent { // otherwise, start gamespace this.deploying = true; - this.api.start({ id: model.instance.id }).pipe( + this.api.deployResources({ id: model.instance.id }).pipe( catchError(e => { this.renderLaunchError(e); return of({} as Challenge); diff --git a/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.html b/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.html index 42d2ba920..1cf3e1ef2 100644 --- a/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.html +++ b/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.html @@ -160,7 +160,7 @@

    Team Up

    this.checkActiveChallengesForEnd()), + challengesService.challengeDeleted$.subscribe(challengeId => this.handleChallengeDeleted(challengeId)), challengesService.challengeStarted$.subscribe(challenge => this.handleChallengeStarted(challenge)), challengesService.challengeGraded$.subscribe(challenge => this.handleChallengeGraded(challenge)), challengesService.challengeDeployStateChanged$.subscribe(challenge => this.handleChallengeDeployStateChanged(challenge)), @@ -86,6 +87,10 @@ export class ActiveChallengesRepo implements OnDestroy { } } + private handleChallengeDeleted(challengeId: string) { + this.removeFromActive(challengeId); + } + private handleChallengeDeployStateChanged(challengeDeployStateChange: Challenge) { this.updateChallenge(challengeDeployStateChange.id, c => { c.isDeployed = challengeDeployStateChange.hasDeployedGamespace; diff --git a/projects/gameboard-ui/src/app/users/components/user-page/user-page.component.html b/projects/gameboard-ui/src/app/users/components/user-page/user-page.component.html index 53122aca6..48472a500 100644 --- a/projects/gameboard-ui/src/app/users/components/user-page/user-page.component.html +++ b/projects/gameboard-ui/src/app/users/components/user-page/user-page.component.html @@ -8,7 +8,7 @@ Certificates History Settings + *ngIf="'Admin_View' | can">Settings