diff --git a/server/application-server/openapi.yaml b/server/application-server/openapi.yaml index 568aa078..bc2f0c88 100644 --- a/server/application-server/openapi.yaml +++ b/server/application-server/openapi.yaml @@ -89,6 +89,18 @@ paths: type: array items: $ref: "#/components/schemas/PullRequest" + /meta: + get: + tags: + - meta + operationId: getMetaData + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/MetaDataDTO" /leaderboard: get: tags: @@ -107,6 +119,11 @@ paths: schema: type: string format: date + - name: repository + in: query + required: false + schema: + type: string responses: "200": description: OK @@ -469,6 +486,13 @@ components: type: array items: $ref: "#/components/schemas/PullRequestReview" + MetaDataDTO: + type: object + properties: + repositoriesToMonitor: + type: array + items: + type: string LeaderboardEntry: type: object properties: diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/SecurityConfig.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/SecurityConfig.java index 0a14f711..d90921df 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/SecurityConfig.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/SecurityConfig.java @@ -28,6 +28,7 @@ public class SecurityConfig { interface AuthoritiesConverter extends Converter, Collection> { } + @SuppressWarnings("unchecked") @Bean AuthoritiesConverter realmRolesAuthoritiesConverter() { return claims -> { diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserRepository.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserRepository.java index ba998f9d..d671a475 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserRepository.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserRepository.java @@ -46,6 +46,8 @@ SELECT new UserDTO(u.id, u.login, u.email, u.name, u.url) FROM User u JOIN FETCH u.reviews re WHERE re.createdAt BETWEEN :after AND :before + AND (:repository IS NULL OR re.pullRequest.repository.nameWithOwner = :repository) """) - List findAllInTimeframe(@Param("after") OffsetDateTime after, @Param("before") OffsetDateTime before); + List findAllInTimeframe(@Param("after") OffsetDateTime after, @Param("before") OffsetDateTime before, + @Param("repository") Optional repository); } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserService.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserService.java index 1a5f3e9b..245baa36 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserService.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserService.java @@ -33,8 +33,9 @@ public List getAllUsers() { return userRepository.findAll().stream().toList(); } - public List getAllUsersInTimeframe(OffsetDateTime after, OffsetDateTime before) { - logger.info("Getting all users in timeframe between " + after + " and " + before); - return userRepository.findAllInTimeframe(after, before); + public List getAllUsersInTimeframe(OffsetDateTime after, OffsetDateTime before, Optional repository) { + logger.info("Getting all users in timeframe between " + after + " and " + before + " for repository: " + + repository.orElse("all")); + return userRepository.findAllInTimeframe(after, before, repository); } } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardController.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardController.java index 12d7d922..5ced3e6a 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardController.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardController.java @@ -24,7 +24,8 @@ public LeaderboardController(LeaderboardService leaderboardService) { @GetMapping public ResponseEntity> getLeaderboard( @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") Optional after, - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") Optional before) { - return ResponseEntity.ok(leaderboardService.createLeaderboard(after, before)); + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") Optional before, + @RequestParam Optional repository) { + return ResponseEntity.ok(leaderboardService.createLeaderboard(after, before, repository)); } } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardService.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardService.java index a031c407..4b7668b9 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardService.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardService.java @@ -38,7 +38,8 @@ public LeaderboardService(UserService userService) { this.userService = userService; } - public List createLeaderboard(Optional after, Optional before) { + public List createLeaderboard(Optional after, Optional before, + Optional repository) { logger.info("Creating leaderboard dataset"); LocalDateTime afterCutOff = after.isPresent() ? after.get().atStartOfDay() @@ -46,7 +47,7 @@ public List createLeaderboard(Optional after, Optio Optional beforeCutOff = before.map(date -> date.plusDays(1).atStartOfDay()); List users = userService.getAllUsersInTimeframe(afterCutOff.atOffset(ZoneOffset.UTC), - beforeCutOff.map(b -> b.atOffset(ZoneOffset.UTC)).orElse(OffsetDateTime.now())); + beforeCutOff.map(b -> b.atOffset(ZoneOffset.UTC)).orElse(OffsetDateTime.now()), repository); logger.info("Found " + users.size() + " users for the leaderboard"); diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/meta/MetaController.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/meta/MetaController.java new file mode 100644 index 00000000..ff83ce16 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/meta/MetaController.java @@ -0,0 +1,22 @@ +package de.tum.in.www1.hephaestus.meta; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/meta") +public class MetaController { + + private final MetaService metaService; + + public MetaController(MetaService metaService) { + this.metaService = metaService; + } + + @GetMapping + public ResponseEntity getMetaData() { + return ResponseEntity.ok(metaService.getMetaData()); + } +} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/meta/MetaDataDTO.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/meta/MetaDataDTO.java new file mode 100644 index 00000000..cf116807 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/meta/MetaDataDTO.java @@ -0,0 +1,8 @@ +package de.tum.in.www1.hephaestus.meta; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record MetaDataDTO(String[] repositoriesToMonitor) { + +} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/meta/MetaService.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/meta/MetaService.java new file mode 100644 index 00000000..24c0b310 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/meta/MetaService.java @@ -0,0 +1,19 @@ +package de.tum.in.www1.hephaestus.meta; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class MetaService { + private static final Logger logger = LoggerFactory.getLogger(MetaService.class); + + @Value("${monitoring.repositories}") + private String[] repositoriesToMonitor; + + public MetaDataDTO getMetaData() { + logger.info("Getting meta data..."); + return new MetaDataDTO(repositoriesToMonitor); + } +} diff --git a/webapp/src/app/core/modules/openapi/.openapi-generator/FILES b/webapp/src/app/core/modules/openapi/.openapi-generator/FILES index de16ac04..e5544a76 100644 --- a/webapp/src/app/core/modules/openapi/.openapi-generator/FILES +++ b/webapp/src/app/core/modules/openapi/.openapi-generator/FILES @@ -7,6 +7,8 @@ api/admin.serviceInterface.ts api/api.ts api/leaderboard.service.ts api/leaderboard.serviceInterface.ts +api/meta.service.ts +api/meta.serviceInterface.ts api/pull-request.service.ts api/pull-request.serviceInterface.ts api/user.service.ts @@ -18,6 +20,7 @@ index.ts model/issue-comment-dto.ts model/issue-comment.ts model/leaderboard-entry.ts +model/meta-data-dto.ts model/models.ts model/pull-request-dto.ts model/pull-request-label.ts diff --git a/webapp/src/app/core/modules/openapi/api/api.ts b/webapp/src/app/core/modules/openapi/api/api.ts index 30528fda..d8152646 100644 --- a/webapp/src/app/core/modules/openapi/api/api.ts +++ b/webapp/src/app/core/modules/openapi/api/api.ts @@ -4,10 +4,13 @@ export * from './admin.serviceInterface'; export * from './leaderboard.service'; import { LeaderboardService } from './leaderboard.service'; export * from './leaderboard.serviceInterface'; +export * from './meta.service'; +import { MetaService } from './meta.service'; +export * from './meta.serviceInterface'; export * from './pull-request.service'; import { PullRequestService } from './pull-request.service'; export * from './pull-request.serviceInterface'; export * from './user.service'; import { UserService } from './user.service'; export * from './user.serviceInterface'; -export const APIS = [AdminService, LeaderboardService, PullRequestService, UserService]; +export const APIS = [AdminService, LeaderboardService, MetaService, PullRequestService, UserService]; diff --git a/webapp/src/app/core/modules/openapi/api/leaderboard.service.ts b/webapp/src/app/core/modules/openapi/api/leaderboard.service.ts index 34a2c7cd..7cc7ccc9 100644 --- a/webapp/src/app/core/modules/openapi/api/leaderboard.service.ts +++ b/webapp/src/app/core/modules/openapi/api/leaderboard.service.ts @@ -98,13 +98,14 @@ export class LeaderboardService implements LeaderboardServiceInterface { /** * @param after * @param before + * @param repository * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public getLeaderboard(after?: string, before?: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public getLeaderboard(after?: string, before?: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; - public getLeaderboard(after?: string, before?: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; - public getLeaderboard(after?: string, before?: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + public getLeaderboard(after?: string, before?: string, repository?: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getLeaderboard(after?: string, before?: string, repository?: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getLeaderboard(after?: string, before?: string, repository?: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>>; + public getLeaderboard(after?: string, before?: string, repository?: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { let localVarQueryParameters = new HttpParams({encoder: this.encoder}); if (after !== undefined && after !== null) { @@ -115,6 +116,10 @@ export class LeaderboardService implements LeaderboardServiceInterface { localVarQueryParameters = this.addToHttpParams(localVarQueryParameters, before, 'before'); } + if (repository !== undefined && repository !== null) { + localVarQueryParameters = this.addToHttpParams(localVarQueryParameters, + repository, 'repository'); + } let localVarHeaders = this.defaultHeaders; diff --git a/webapp/src/app/core/modules/openapi/api/leaderboard.serviceInterface.ts b/webapp/src/app/core/modules/openapi/api/leaderboard.serviceInterface.ts index 19a131c7..54aadd9a 100644 --- a/webapp/src/app/core/modules/openapi/api/leaderboard.serviceInterface.ts +++ b/webapp/src/app/core/modules/openapi/api/leaderboard.serviceInterface.ts @@ -29,7 +29,8 @@ export interface LeaderboardServiceInterface { * * @param after * @param before + * @param repository */ - getLeaderboard(after?: string, before?: string, extraHttpRequestParams?: any): Observable>; + getLeaderboard(after?: string, before?: string, repository?: string, extraHttpRequestParams?: any): Observable>; } diff --git a/webapp/src/app/core/modules/openapi/api/meta.service.ts b/webapp/src/app/core/modules/openapi/api/meta.service.ts new file mode 100644 index 00000000..a3da5964 --- /dev/null +++ b/webapp/src/app/core/modules/openapi/api/meta.service.ts @@ -0,0 +1,157 @@ +/** + * Hephaestus API + * API documentation for the Hephaestus application server. + * + * The version of the OpenAPI document: 0.0.1 + * Contact: felixtj.dietrich@tum.de + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +/* tslint:disable:no-unused-variable member-ordering */ + +import { Inject, Injectable, Optional } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams, + HttpResponse, HttpEvent, HttpParameterCodec, HttpContext + } from '@angular/common/http'; +import { CustomHttpParameterCodec } from '../encoder'; +import { Observable } from 'rxjs'; + +// @ts-ignore +import { MetaDataDTO } from '../model/meta-data-dto'; + +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; +import { Configuration } from '../configuration'; +import { + MetaServiceInterface +} from './meta.serviceInterface'; + + + +@Injectable({ + providedIn: 'root' +}) +export class MetaService implements MetaServiceInterface { + + protected basePath = 'http://localhost'; + public defaultHeaders = new HttpHeaders(); + public configuration = new Configuration(); + public encoder: HttpParameterCodec; + + constructor(protected httpClient: HttpClient, @Optional()@Inject(BASE_PATH) basePath: string|string[], @Optional() configuration: Configuration) { + if (configuration) { + this.configuration = configuration; + } + if (typeof this.configuration.basePath !== 'string') { + const firstBasePath = Array.isArray(basePath) ? basePath[0] : undefined; + if (firstBasePath != undefined) { + basePath = firstBasePath; + } + + if (typeof basePath !== 'string') { + basePath = this.basePath; + } + this.configuration.basePath = basePath; + } + this.encoder = this.configuration.encoder || new CustomHttpParameterCodec(); + } + + + // @ts-ignore + private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams { + if (typeof value === "object" && value instanceof Date === false) { + httpParams = this.addToHttpParamsRecursive(httpParams, value); + } else { + httpParams = this.addToHttpParamsRecursive(httpParams, value, key); + } + return httpParams; + } + + private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams { + if (value == null) { + return httpParams; + } + + if (typeof value === "object") { + if (Array.isArray(value)) { + (value as any[]).forEach( elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key)); + } else if (value instanceof Date) { + if (key != null) { + httpParams = httpParams.append(key, (value as Date).toISOString().substring(0, 10)); + } else { + throw Error("key may not be null if value is Date"); + } + } else { + Object.keys(value).forEach( k => httpParams = this.addToHttpParamsRecursive( + httpParams, value[k], key != null ? `${key}.${k}` : k)); + } + } else if (key != null) { + httpParams = httpParams.append(key, value); + } else { + throw Error("key may not be null if value is not object or array"); + } + return httpParams; + } + + /** + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public getMetaData(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getMetaData(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getMetaData(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getMetaData(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + + let localVarHeaders = this.defaultHeaders; + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + let localVarTransferCache: boolean | undefined = options && options.transferCache; + if (localVarTransferCache === undefined) { + localVarTransferCache = true; + } + + + let responseType_: 'text' | 'json' | 'blob' = 'json'; + if (localVarHttpHeaderAcceptSelected) { + if (localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { + responseType_ = 'json'; + } else { + responseType_ = 'blob'; + } + } + + let localVarPath = `/meta`; + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + transferCache: localVarTransferCache, + reportProgress: reportProgress + } + ); + } + +} diff --git a/webapp/src/app/core/modules/openapi/api/meta.serviceInterface.ts b/webapp/src/app/core/modules/openapi/api/meta.serviceInterface.ts new file mode 100644 index 00000000..f6fb5e53 --- /dev/null +++ b/webapp/src/app/core/modules/openapi/api/meta.serviceInterface.ts @@ -0,0 +1,33 @@ +/** + * Hephaestus API + * API documentation for the Hephaestus application server. + * + * The version of the OpenAPI document: 0.0.1 + * Contact: felixtj.dietrich@tum.de + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { HttpHeaders } from '@angular/common/http'; + +import { Observable } from 'rxjs'; + +import { MetaDataDTO } from '../model/models'; + + +import { Configuration } from '../configuration'; + + + +export interface MetaServiceInterface { + defaultHeaders: HttpHeaders; + configuration: Configuration; + + /** + * + * + */ + getMetaData(extraHttpRequestParams?: any): Observable; + +} diff --git a/webapp/src/app/core/modules/openapi/model/meta-data-dto.ts b/webapp/src/app/core/modules/openapi/model/meta-data-dto.ts new file mode 100644 index 00000000..0049533e --- /dev/null +++ b/webapp/src/app/core/modules/openapi/model/meta-data-dto.ts @@ -0,0 +1,17 @@ +/** + * Hephaestus API + * API documentation for the Hephaestus application server. + * + * The version of the OpenAPI document: 0.0.1 + * Contact: felixtj.dietrich@tum.de + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface MetaDataDTO { + repositoriesToMonitor?: Array; +} + diff --git a/webapp/src/app/core/modules/openapi/model/models.ts b/webapp/src/app/core/modules/openapi/model/models.ts index 3f1ef3af..349ec7a5 100644 --- a/webapp/src/app/core/modules/openapi/model/models.ts +++ b/webapp/src/app/core/modules/openapi/model/models.ts @@ -1,6 +1,7 @@ export * from './issue-comment'; export * from './issue-comment-dto'; export * from './leaderboard-entry'; +export * from './meta-data-dto'; export * from './pull-request'; export * from './pull-request-dto'; export * from './pull-request-label'; diff --git a/webapp/src/app/home/home.component.html b/webapp/src/app/home/home.component.html index 26bde6c1..19787091 100644 --- a/webapp/src/app/home/home.component.html +++ b/webapp/src/app/home/home.component.html @@ -8,7 +8,7 @@

Artemis Leaderboard

Hi {{ userValue.name }} 👋

} - +
@if (query.error()) { diff --git a/webapp/src/app/home/home.component.ts b/webapp/src/app/home/home.component.ts index c5b36629..b30b692c 100644 --- a/webapp/src/app/home/home.component.ts +++ b/webapp/src/app/home/home.component.ts @@ -11,6 +11,7 @@ import { LeaderboardComponent } from '@app/home/leaderboard/leaderboard.componen import { LeaderboardFilterComponent } from './leaderboard/filter/filter.component'; import { SecurityStore } from '@app/core/security/security-store.service'; import { HlmAlertModule } from '@spartan-ng/ui-alert-helm'; +import { MetaService } from '@app/core/modules/openapi'; dayjs.extend(isoWeek); @@ -24,20 +25,30 @@ export class HomeComponent { protected CircleX = CircleX; securityStore = inject(SecurityStore); + metaService = inject(MetaService); leaderboardService = inject(LeaderboardService); signedIn = this.securityStore.signedIn; user = this.securityStore.loadedUser; - // timeframe for leaderboard - // example: 2024-09-19 private readonly route = inject(ActivatedRoute); private queryParams = toSignal(this.route.queryParamMap, { requireSync: true }); protected after = computed(() => this.queryParams().get('after') ?? dayjs().isoWeekday(1).format('YYYY-MM-DD')); protected before = computed(() => this.queryParams().get('before') ?? dayjs().format('YYYY-MM-DD')); + protected repository = computed(() => this.queryParams().get('repository') ?? 'all'); query = injectQuery(() => ({ - queryKey: ['leaderboard', { after: this.after(), before: this.before() }], - queryFn: async () => lastValueFrom(combineLatest([this.leaderboardService.getLeaderboard(this.after(), this.before()), timer(500)]).pipe(map(([leaderboard]) => leaderboard))) + queryKey: ['leaderboard', { after: this.after(), before: this.before(), repository: this.repository() }], + queryFn: async () => + lastValueFrom( + combineLatest([this.leaderboardService.getLeaderboard(this.after(), this.before(), this.repository() !== 'all' ? this.repository() : undefined), timer(500)]).pipe( + map(([leaderboard]) => leaderboard) + ) + ) + })); + + metaQuery = injectQuery(() => ({ + queryKey: ['meta'], + queryFn: async () => lastValueFrom(this.metaService.getMetaData()) })); } diff --git a/webapp/src/app/home/leaderboard/filter/filter.component.html b/webapp/src/app/home/leaderboard/filter/filter.component.html index e0acb284..6ece45f4 100644 --- a/webapp/src/app/home/leaderboard/filter/filter.component.html +++ b/webapp/src/app/home/leaderboard/filter/filter.component.html @@ -3,17 +3,10 @@

Filter

-
- - - - - - - @for (option of options(); track option.id) { - {{ option.label }} - } - - + + + @if (repositories() && repositories()!.length > 1) { + + }
diff --git a/webapp/src/app/home/leaderboard/filter/filter.component.ts b/webapp/src/app/home/leaderboard/filter/filter.component.ts index bdad45ee..b4d4cb72 100644 --- a/webapp/src/app/home/leaderboard/filter/filter.component.ts +++ b/webapp/src/app/home/leaderboard/filter/filter.component.ts @@ -1,89 +1,17 @@ -import { Component, computed, effect, input, signal } from '@angular/core'; +import { Component, input } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { Router, RouterLink } from '@angular/router'; -import dayjs from 'dayjs'; -import isoWeek from 'dayjs/plugin/isoWeek'; -import { BrnSelectModule } from '@spartan-ng/ui-select-brain'; -import { HlmSelectModule } from '@spartan-ng/ui-select-helm'; -import { HlmLabelModule } from '@spartan-ng/ui-label-helm'; import { ListFilter, LucideAngularModule } from 'lucide-angular'; - -interface SelectOption { - id: number; - value: string; - label: string; -} - -dayjs.extend(isoWeek); - -function formatLabel(startDate: dayjs.Dayjs, endDate: dayjs.Dayjs | undefined) { - const calendarWeek = startDate.isoWeek(); - if (!endDate) { - return `CW\xa0${calendarWeek}:\xa0${startDate.format('MMM D')}\xa0-\xa0Today`; - } - - const sameMonth = startDate.month() === endDate.month(); - if (sameMonth) { - return `CW\xa0${calendarWeek}:\xa0${startDate.format('MMM D')}\xa0-\xa0${endDate.format('D')}`; - } else { - return `CW\xa0${calendarWeek}:\xa0${startDate.format('MMM D')}\xa0-\xa0${endDate.format('MMM D')}`; - } -} +import { LeaderboardFilterTimeframeComponent } from './timeframe/timeframe.component'; +import { LeaderboardFilterRepositoryComponent } from './repository/repository.component'; @Component({ selector: 'app-leaderboard-filter', standalone: true, - imports: [RouterLink, LucideAngularModule, BrnSelectModule, HlmSelectModule, HlmLabelModule, FormsModule], + imports: [LucideAngularModule, FormsModule, LeaderboardFilterTimeframeComponent, LeaderboardFilterRepositoryComponent], templateUrl: './filter.component.html' }) export class LeaderboardFilterComponent { protected ListFilter = ListFilter; - after = input(''); - before = input(''); - - value = signal(`${this.after()}.${this.before()}`); - - placeholder = computed(() => { - const beforeIsToday = this.before() === dayjs().format('YYYY-MM-DD'); - return formatLabel(this.after() ? dayjs(this.after()) : dayjs().isoWeekday(1), beforeIsToday ? undefined : dayjs(this.before())); - }); - - options = computed(() => { - const now = dayjs(); - let currentDate = dayjs().isoWeekday(1); - const options: SelectOption[] = [ - { - id: now.unix(), - value: `${currentDate.format('YYYY-MM-DD')}.${now.format('YYYY-MM-DD')}`, - label: formatLabel(currentDate, undefined) - } - ]; - - for (let i = 0; i < 4; i++) { - const startDate = currentDate.subtract(7, 'day'); - const endDate = currentDate.subtract(1, 'day'); - options.push({ - id: startDate.unix(), - value: `${startDate.format('YYYY-MM-DD')}.${endDate.format('YYYY-MM-DD')}`, - label: formatLabel(startDate, endDate) - }); - currentDate = startDate; - } - - return options; - }); - constructor(private router: Router) { - effect(() => { - if (this.value().length === 1) return; - const dates = this.value().split('.'); - // change query params - this.router.navigate([], { - queryParams: { - after: dates[0], - before: dates[1] - } - }); - }); - } + repositories = input(); } diff --git a/webapp/src/app/home/leaderboard/filter/filter.stories.ts b/webapp/src/app/home/leaderboard/filter/filter.stories.ts index 807597de..5b6df996 100644 --- a/webapp/src/app/home/leaderboard/filter/filter.stories.ts +++ b/webapp/src/app/home/leaderboard/filter/filter.stories.ts @@ -4,18 +4,25 @@ import { LeaderboardFilterComponent } from './filter.component'; const meta: Meta = { component: LeaderboardFilterComponent, tags: ['autodocs'], + args: { + repositories: [ + 'ls1intum/Artemis', + 'ls1intum/Athena', + 'ls1intum/Hephaestus', + 'ls1intum/Pyris', + 'ls1intum/Ares2', + 'ls1intum/Aeolus', + 'ls1intum/hades', + 'ls1intum/Apollon', + 'ls1intum/Apollon_standalone' + ] + }, argTypes: { - after: { + repositories: { control: { - type: 'text' + type: 'object' }, - description: 'Left limit of the timeframe' - }, - before: { - control: { - type: 'text' - }, - description: 'Right limit of the timeframe' + description: 'List of repositories' } } }; @@ -24,9 +31,15 @@ export default meta; type Story = StoryObj; export const Default: Story = { + render: (args) => ({ + props: args, + template: `` + }) +}; + +export const SingleRepository: Story = { args: { - after: '2024-09-09', - before: '2024-09-15' + repositories: ['ls1intum/Artemis'] }, render: (args) => ({ props: args, diff --git a/webapp/src/app/home/leaderboard/filter/repository/repository.component.html b/webapp/src/app/home/leaderboard/filter/repository/repository.component.html new file mode 100644 index 00000000..17acd372 --- /dev/null +++ b/webapp/src/app/home/leaderboard/filter/repository/repository.component.html @@ -0,0 +1,11 @@ + + + + + + + @for (option of options(); track option.id) { + {{ option.label }} + } + + diff --git a/webapp/src/app/home/leaderboard/filter/repository/repository.component.ts b/webapp/src/app/home/leaderboard/filter/repository/repository.component.ts new file mode 100644 index 00000000..a9a3c7ae --- /dev/null +++ b/webapp/src/app/home/leaderboard/filter/repository/repository.component.ts @@ -0,0 +1,62 @@ +import { Component, computed, effect, input, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { BrnSelectModule } from '@spartan-ng/ui-select-brain'; +import { HlmSelectModule } from '@spartan-ng/ui-select-helm'; +import { HlmLabelModule } from '@spartan-ng/ui-label-helm'; + +interface SelectOption { + id: number; + value: string; + label: string; +} + +@Component({ + selector: 'app-leaderboard-filter-repository', + standalone: true, + imports: [RouterLink, BrnSelectModule, HlmSelectModule, HlmLabelModule, FormsModule], + templateUrl: './repository.component.html' +}) +export class LeaderboardFilterRepositoryComponent { + repositories = input.required(); + value = signal(''); + + placeholder = computed(() => { + return this.repositories().find((option) => option === this.value()) ?? 'All'; + }); + + options = computed(() => { + const options: SelectOption[] = !this.repositories() + ? [] + : this.repositories()!.map((name, index) => { + return { + id: index + 1, + value: name, + label: name + }; + }); + options.unshift({ + id: 0, + value: 'all', + label: 'All' + }); + return options; + }); + + constructor(private router: Router) { + this.value.set(this.router.parseUrl(this.router.url).queryParams['repository'] ?? 'all'); + + effect(() => { + if (!this.value() || this.value() === '') return; + const queryParams = this.router.parseUrl(this.router.url).queryParams; + if (this.value() === 'all') { + delete queryParams['repository']; + } else { + queryParams['repository'] = this.value(); + } + this.router.navigate([], { + queryParams + }); + }); + } +} diff --git a/webapp/src/app/home/leaderboard/filter/repository/repository.stories.ts b/webapp/src/app/home/leaderboard/filter/repository/repository.stories.ts new file mode 100644 index 00000000..0fd723d7 --- /dev/null +++ b/webapp/src/app/home/leaderboard/filter/repository/repository.stories.ts @@ -0,0 +1,28 @@ +import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular'; +import { LeaderboardFilterRepositoryComponent } from './repository.component'; + +const meta: Meta = { + component: LeaderboardFilterRepositoryComponent, + tags: ['autodocs'], + argTypes: { + repositories: { + control: { + type: 'object' + }, + description: 'List of repositories to filter by' + } + } +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + repositories: ['ls1intum/Artemis', 'ls1intum/Athena', 'ls1intum/Hephaestus'] + }, + render: (args) => ({ + props: args, + template: `` + }) +}; diff --git a/webapp/src/app/home/leaderboard/filter/timeframe/timeframe.component.html b/webapp/src/app/home/leaderboard/filter/timeframe/timeframe.component.html new file mode 100644 index 00000000..0db6bd8e --- /dev/null +++ b/webapp/src/app/home/leaderboard/filter/timeframe/timeframe.component.html @@ -0,0 +1,11 @@ + + + + + + + @for (option of options(); track option.id) { + {{ option.label }} + } + + diff --git a/webapp/src/app/home/leaderboard/filter/timeframe/timeframe.component.ts b/webapp/src/app/home/leaderboard/filter/timeframe/timeframe.component.ts new file mode 100644 index 00000000..3f366d5b --- /dev/null +++ b/webapp/src/app/home/leaderboard/filter/timeframe/timeframe.component.ts @@ -0,0 +1,92 @@ +import { Component, computed, effect, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import dayjs from 'dayjs'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import { BrnSelectModule } from '@spartan-ng/ui-select-brain'; +import { HlmSelectModule } from '@spartan-ng/ui-select-helm'; +import { HlmLabelModule } from '@spartan-ng/ui-label-helm'; + +interface SelectOption { + id: number; + value: string; + label: string; +} + +dayjs.extend(isoWeek); + +function formatLabel(startDate: dayjs.Dayjs, endDate: dayjs.Dayjs | undefined) { + const calendarWeek = startDate.isoWeek(); + if (!endDate || endDate.isSame(dayjs(), 'day')) { + return `CW\xa0${calendarWeek}:\xa0${startDate.format('MMM D')}\xa0-\xa0Today`; + } + + const sameMonth = startDate.month() === endDate.month(); + if (sameMonth) { + return `CW\xa0${calendarWeek}:\xa0${startDate.format('MMM D')}\xa0-\xa0${endDate.format('D')}`; + } else { + return `CW\xa0${calendarWeek}:\xa0${startDate.format('MMM D')}\xa0-\xa0${endDate.format('MMM D')}`; + } +} + +@Component({ + selector: 'app-leaderboard-filter-timeframe', + standalone: true, + imports: [RouterLink, BrnSelectModule, HlmSelectModule, HlmLabelModule, FormsModule], + templateUrl: './timeframe.component.html' +}) +export class LeaderboardFilterTimeframeComponent { + after = signal(''); + before = signal(''); + value = signal(`${this.after()}.${this.before()}`); + + placeholder = computed(() => { + return formatLabel(dayjs(this.after()) ?? dayjs().day(1), !this.before() ? undefined : dayjs(this.before())); + }); + + options = computed(() => { + const now = dayjs(); + let currentDate = dayjs().isoWeekday(1); + const options: SelectOption[] = [ + { + id: now.unix(), + value: `${currentDate.format('YYYY-MM-DD')}.${now.format('YYYY-MM-DD')}`, + label: formatLabel(currentDate, undefined) + } + ]; + + for (let i = 0; i < 4; i++) { + const startDate = currentDate.subtract(7, 'day'); + const endDate = currentDate.subtract(1, 'day'); + options.push({ + id: startDate.unix(), + value: `${startDate.format('YYYY-MM-DD')}.${endDate.format('YYYY-MM-DD')}`, + label: formatLabel(startDate, endDate) + }); + currentDate = startDate; + } + + return options; + }); + + constructor(private router: Router) { + // init params + const queryParams = this.router.parseUrl(this.router.url).queryParams; + this.after.set(queryParams['after'] ?? dayjs().isoWeekday(1).format('YYYY-MM-DD')); + this.before.set(queryParams['before'] ?? dayjs().format('YYYY-MM-DD')); + + // persist changes in url + effect(() => { + if (this.value().length === 1) return; + + const queryParams = this.router.parseUrl(this.router.url).queryParams; + const dates = this.value().split('.'); + queryParams['after'] = dates[0]; + queryParams['before'] = dates[1]; + + this.router.navigate([], { + queryParams + }); + }); + } +} diff --git a/webapp/src/app/home/leaderboard/filter/timeframe/timeframe.stories.ts b/webapp/src/app/home/leaderboard/filter/timeframe/timeframe.stories.ts new file mode 100644 index 00000000..359282b9 --- /dev/null +++ b/webapp/src/app/home/leaderboard/filter/timeframe/timeframe.stories.ts @@ -0,0 +1,30 @@ +import { type Meta, type StoryObj } from '@storybook/angular'; +import { LeaderboardFilterTimeframeComponent } from './timeframe.component'; + +const meta: Meta = { + component: LeaderboardFilterTimeframeComponent, + tags: ['autodocs'], + argTypes: { + after: { + control: { + type: 'text' + }, + description: 'Left limit of the timeframe' + }, + before: { + control: { + type: 'text' + }, + description: 'Right limit of the timeframe' + } + } +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ({ + template: '' + }) +}; diff --git a/webapp/src/app/home/leaderboard/leaderboard.component.html b/webapp/src/app/home/leaderboard/leaderboard.component.html index 3318e303..7f167945 100644 --- a/webapp/src/app/home/leaderboard/leaderboard.component.html +++ b/webapp/src/app/home/leaderboard/leaderboard.component.html @@ -28,6 +28,15 @@ } + } @else if (!leaderboard() || leaderboard()?.length === 0) { + + +
+ + No entries found +
+ + } @else { @for (entry of leaderboard(); track entry.githubName) { diff --git a/webapp/src/app/home/leaderboard/leaderboard.component.ts b/webapp/src/app/home/leaderboard/leaderboard.component.ts index 50e322ac..bde91e08 100644 --- a/webapp/src/app/home/leaderboard/leaderboard.component.ts +++ b/webapp/src/app/home/leaderboard/leaderboard.component.ts @@ -1,6 +1,6 @@ import { Component, input } from '@angular/core'; import { NgIconComponent } from '@ng-icons/core'; -import { octFileDiff, octCheck, octComment, octGitPullRequest, octChevronLeft } from '@ng-icons/octicons'; +import { octFileDiff, octCheck, octComment, octGitPullRequest, octChevronLeft, octNoEntry } from '@ng-icons/octicons'; import { LeaderboardEntry } from 'app/core/modules/openapi'; import { TableBodyDirective } from 'app/ui/table/table-body.directive'; import { TableCaptionDirective } from 'app/ui/table/table-caption.directive'; @@ -37,6 +37,7 @@ export class LeaderboardComponent { protected octComment = octComment; protected octGitPullRequest = octGitPullRequest; protected octChevronLeft = octChevronLeft; + protected octNoEntry = octNoEntry; protected Math = Math; protected Array = Array; diff --git a/webapp/src/libs/ui/ui-dialog-helm/src/lib/hlm-dialog.service.ts b/webapp/src/libs/ui/ui-dialog-helm/src/lib/hlm-dialog.service.ts index f508b994..b19bbd8b 100644 --- a/webapp/src/libs/ui/ui-dialog-helm/src/lib/hlm-dialog.service.ts +++ b/webapp/src/libs/ui/ui-dialog-helm/src/lib/hlm-dialog.service.ts @@ -20,7 +20,6 @@ export class HlmDialogService { options = { ...DEFAULT_BRN_DIALOG_OPTIONS, closeDelay: 100, - ...(options ?? {}), backdropClass: cssClassesToArray(`${hlmDialogOverlayClass} ${options?.backdropClass ?? ''}`), context: { ...options?.context, $component: component, $dynamicComponentClass: options?.contentClass }