From 511c515b0c11c0b9b453dc687de5f3c188eec370 Mon Sep 17 00:00:00 2001 From: Armin Stanitzok <21990230+GODrums@users.noreply.github.com> Date: Fri, 18 Oct 2024 23:53:03 +0200 Subject: [PATCH] Profile Pages with User Activity and PRs (#115) Co-authored-by: github-actions[bot] Co-authored-by: Felix T.J. Dietrich Co-authored-by: Felix T.J. Dietrich --- server/application-server/openapi.yaml | 153 ++++++++++++++---- .../codereview/pullrequest/PullRequest.java | 8 + .../pullrequest/PullRequestConverter.java | 4 +- .../pullrequest/PullRequestDTO.java | 28 +++- .../pullrequest/PullRequestLabel.java | 4 +- .../pullrequest/review/PullRequestReview.java | 2 + .../review/PullRequestReviewConverter.java | 1 + .../review/PullRequestReviewDTO.java | 20 ++- .../codereview/repository/RepositoryDTO.java | 10 +- .../codereview/user/UserController.java | 6 + .../codereview/user/UserConverter.java | 2 +- .../codereview/user/UserProfileDTO.java | 23 +++ .../codereview/user/UserService.java | 75 +++++++++ webapp/src/app/app.routes.ts | 4 +- .../core/issue-card/issue-card.component.html | 37 ----- .../core/issue-card/issue-card.component.ts | 41 ----- .../app/core/issue-card/issue-card.stories.ts | 39 ----- .../modules/openapi/.openapi-generator/FILES | 1 + .../core/modules/openapi/api/user.service.ts | 65 ++++++++ .../openapi/api/user.serviceInterface.ts | 8 + .../app/core/modules/openapi/model/models.ts | 1 + .../modules/openapi/model/pull-request-dto.ts | 17 +- .../openapi/model/pull-request-review-dto.ts | 13 +- .../openapi/model/pull-request-review.ts | 1 + .../modules/openapi/model/repository-dto.ts | 6 +- .../modules/openapi/model/user-profile-dto.ts | 25 +++ .../leaderboard/leaderboard.component.html | 6 +- .../home/leaderboard/leaderboard.component.ts | 5 +- .../src/app/user/header/header.component.html | 54 +++++++ .../src/app/user/header/header.component.ts | 59 +++++++ webapp/src/app/user/header/header.stories.ts | 83 ++++++++++ .../user/issue-card/issue-card.component.html | 55 +++++++ .../user/issue-card/issue-card.component.scss | 25 +++ .../user/issue-card/issue-card.component.ts | 91 +++++++++++ .../app/user/issue-card/issue-card.stories.ts | 46 ++++++ .../review-activity-card.component.html | 25 +++ .../review-activity-card.component.ts | 79 +++++++++ .../review-activity-card.stories.ts | 101 ++++++++++++ .../src/app/user/user-profile.component.html | 69 ++++++++ webapp/src/app/user/user-profile.component.ts | 69 ++++++++ webapp/src/app/user/user-profile.stories.ts | 35 ++++ .../src/lib/hlm-avatar.component.ts | 3 +- .../src/lib/hlm-card-content.directive.ts | 19 ++- .../src/lib/hlm-card.directive.ts | 17 +- webapp/src/styles.css | 8 + 45 files changed, 1256 insertions(+), 187 deletions(-) create mode 100644 server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserProfileDTO.java delete mode 100644 webapp/src/app/core/issue-card/issue-card.component.html delete mode 100644 webapp/src/app/core/issue-card/issue-card.component.ts delete mode 100644 webapp/src/app/core/issue-card/issue-card.stories.ts create mode 100644 webapp/src/app/core/modules/openapi/model/user-profile-dto.ts create mode 100644 webapp/src/app/user/header/header.component.html create mode 100644 webapp/src/app/user/header/header.component.ts create mode 100644 webapp/src/app/user/header/header.stories.ts create mode 100644 webapp/src/app/user/issue-card/issue-card.component.html create mode 100644 webapp/src/app/user/issue-card/issue-card.component.scss create mode 100644 webapp/src/app/user/issue-card/issue-card.component.ts create mode 100644 webapp/src/app/user/issue-card/issue-card.stories.ts create mode 100644 webapp/src/app/user/review-activity-card/review-activity-card.component.html create mode 100644 webapp/src/app/user/review-activity-card/review-activity-card.component.ts create mode 100644 webapp/src/app/user/review-activity-card/review-activity-card.stories.ts create mode 100644 webapp/src/app/user/user-profile.component.html create mode 100644 webapp/src/app/user/user-profile.component.ts create mode 100644 webapp/src/app/user/user-profile.stories.ts diff --git a/server/application-server/openapi.yaml b/server/application-server/openapi.yaml index bc2f0c88..146cfc4a 100644 --- a/server/application-server/openapi.yaml +++ b/server/application-server/openapi.yaml @@ -31,6 +31,24 @@ paths: application/json: schema: $ref: "#/components/schemas/UserDTO" + /user/{login}/profile: + get: + tags: + - user + operationId: getUserProfile + parameters: + - name: login + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UserProfileDTO" /user/{login}/full: get: tags: @@ -176,6 +194,16 @@ components: pullRequest: $ref: "#/components/schemas/PullRequestDTO" PullRequestDTO: + required: + - additions + - createdAt + - deletions + - id + - number + - state + - title + - updatedAt + - url type: object properties: id: @@ -183,6 +211,9 @@ components: format: int64 title: type: string + number: + type: integer + format: int32 url: type: string state: @@ -190,12 +221,21 @@ components: enum: - CLOSED - OPEN + additions: + type: integer + format: int32 + deletions: + type: integer + format: int32 createdAt: type: string + format: date-time updatedAt: type: string + format: date-time mergedAt: type: string + format: date-time author: $ref: "#/components/schemas/UserDTO" comments: @@ -203,9 +243,25 @@ components: type: array items: $ref: "#/components/schemas/IssueCommentDTO" + labels: + uniqueItems: true + type: array + items: + $ref: "#/components/schemas/PullRequestLabel" repository: $ref: "#/components/schemas/RepositoryDTO" + PullRequestLabel: + type: object + properties: + name: + type: string + color: + type: string RepositoryDTO: + required: + - name + - nameWithOwner + - url type: object properties: name: @@ -240,6 +296,72 @@ components: type: array items: $ref: "#/components/schemas/IssueCommentDTO" + PullRequestReviewDTO: + required: + - createdAt + - id + - state + - submittedAt + - updatedAt + type: object + properties: + id: + type: integer + format: int64 + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + state: + type: string + enum: + - COMMENTED + - APPROVED + - CHANGES_REQUESTED + - DISMISSED + url: + type: string + pullRequest: + $ref: "#/components/schemas/PullRequestDTO" + UserProfileDTO: + required: + - avatarUrl + - firstContribution + - id + - login + - repositories + type: object + properties: + id: + type: integer + format: int64 + login: + type: string + avatarUrl: + type: string + firstContribution: + type: string + format: date-time + repositories: + uniqueItems: true + type: array + items: + type: string + activity: + uniqueItems: true + type: array + items: + $ref: "#/components/schemas/PullRequestReviewDTO" + pullRequests: + uniqueItems: true + type: array + items: + $ref: "#/components/schemas/PullRequestDTO" IssueComment: type: object properties: @@ -323,13 +445,6 @@ components: type: array items: $ref: "#/components/schemas/PullRequestLabel" - PullRequestLabel: - type: object - properties: - name: - type: string - color: - type: string PullRequestReview: required: - state @@ -356,6 +471,8 @@ components: submittedAt: type: string format: date-time + url: + type: string comments: uniqueItems: true type: array @@ -528,28 +645,6 @@ components: type: array items: $ref: "#/components/schemas/PullRequestReviewDTO" - PullRequestReviewDTO: - type: object - properties: - id: - type: integer - format: int64 - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - submittedAt: - type: string - format: date-time - state: - type: string - enum: - - COMMENTED - - APPROVED - - CHANGES_REQUESTED - - DISMISSED UserInfoDto: type: object properties: diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequest.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequest.java index 9ad5c321..376f8ce3 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequest.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequest.java @@ -78,4 +78,12 @@ public void addComment(IssueComment comment) { public void addReview(PullRequestReview review) { reviews.add(review); } + + public Integer getAdditions() { + return additions; + } + + public Integer getDeletions() { + return deletions; + } } \ No newline at end of file diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequestConverter.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequestConverter.java index f065f597..22dbcdcf 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequestConverter.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequestConverter.java @@ -78,9 +78,7 @@ private IssueState convertState(org.kohsuke.github.GHIssueState state) { private Set convertLabels(Collection labels) { Set pullRequestLabels = new HashSet<>(); for (GHLabel label : labels) { - PullRequestLabel pullRequestLabel = new PullRequestLabel(); - pullRequestLabel.setName(label.getName()); - pullRequestLabel.setColor(label.getColor()); + PullRequestLabel pullRequestLabel = new PullRequestLabel(label.getName(), label.getColor()); pullRequestLabels.add(pullRequestLabel); } return pullRequestLabels; diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequestDTO.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequestDTO.java index 9e85f02c..81ff3023 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequestDTO.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequestDTO.java @@ -1,7 +1,10 @@ package de.tum.in.www1.hephaestus.codereview.pullrequest; +import java.time.OffsetDateTime; import java.util.Set; +import org.springframework.lang.NonNull; + import com.fasterxml.jackson.annotation.JsonInclude; import de.tum.in.www1.hephaestus.codereview.comment.IssueCommentDTO; @@ -9,10 +12,25 @@ import de.tum.in.www1.hephaestus.codereview.user.UserDTO; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record PullRequestDTO(Long id, String title, String url, IssueState state, String createdAt, String updatedAt, - String mergedAt, UserDTO author, Set comments, RepositoryDTO repository) { - public PullRequestDTO(Long id, String title, String url, IssueState state, String createdAt, String updatedAt, - String mergedAt) { - this(id, title, url, state, createdAt, updatedAt, mergedAt, null, null, null); +public record PullRequestDTO(@NonNull Long id, @NonNull String title, @NonNull Integer number, @NonNull String url, + @NonNull IssueState state, @NonNull Integer additions, @NonNull Integer deletions, + @NonNull OffsetDateTime createdAt, @NonNull OffsetDateTime updatedAt, OffsetDateTime mergedAt, + UserDTO author, Set comments, Set labels, RepositoryDTO repository) { + public PullRequestDTO(@NonNull Long id, @NonNull String title, @NonNull Integer number, @NonNull String url, + @NonNull IssueState state, @NonNull Integer additions, @NonNull Integer deletions, + @NonNull OffsetDateTime createdAt, @NonNull OffsetDateTime updatedAt, OffsetDateTime mergedAt) { + this(id, title, number, url, state, additions, deletions, createdAt, updatedAt, mergedAt, null, null, + null, + null); + } + + public PullRequestDTO(@NonNull Long id, @NonNull String title, @NonNull Integer number, @NonNull String url, + @NonNull IssueState state, @NonNull Integer additions, + @NonNull Integer deletions, @NonNull OffsetDateTime createdAt, + @NonNull OffsetDateTime updatedAt, OffsetDateTime mergedAt, + @NonNull Set labels, + @NonNull RepositoryDTO repository) { + this(id, title, number, url, state, additions, deletions, createdAt, updatedAt, mergedAt, null, null, + labels, repository); } } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequestLabel.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequestLabel.java index 28c666e2..1dd201f8 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequestLabel.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/PullRequestLabel.java @@ -1,8 +1,9 @@ package de.tum.in.www1.hephaestus.codereview.pullrequest; import jakarta.persistence.Embeddable; -import lombok.Getter; +import lombok.AllArgsConstructor; import lombok.NoArgsConstructor; +import lombok.Getter; import lombok.NonNull; import lombok.Setter; @@ -10,6 +11,7 @@ @Getter @Setter @NoArgsConstructor +@AllArgsConstructor public class PullRequestLabel { @NonNull private String name; diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/review/PullRequestReview.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/review/PullRequestReview.java index 74bc73da..8361fff4 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/review/PullRequestReview.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/review/PullRequestReview.java @@ -40,6 +40,8 @@ public class PullRequestReview extends BaseGitServiceEntity { private OffsetDateTime submittedAt; + private String url; + @OneToMany(cascade = CascadeType.REFRESH, mappedBy = "review") @ToString.Exclude private Set comments = new HashSet<>(); diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/review/PullRequestReviewConverter.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/review/PullRequestReviewConverter.java index 4f382f45..36a4d483 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/review/PullRequestReviewConverter.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/review/PullRequestReviewConverter.java @@ -24,6 +24,7 @@ public PullRequestReview convert(@NonNull GHPullRequestReview source) { public PullRequestReview update(@NonNull GHPullRequestReview source, @NonNull PullRequestReview review) { convertBaseFields(source, review); review.setState(convertState(source.getState())); + review.setUrl(source.getHtmlUrl().toString()); try { review.setSubmittedAt(convertToOffsetDateTime(source.getSubmittedAt())); } catch (IOException e) { diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/review/PullRequestReviewDTO.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/review/PullRequestReviewDTO.java index 92e2564b..b7dc0451 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/review/PullRequestReviewDTO.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/pullrequest/review/PullRequestReviewDTO.java @@ -2,9 +2,25 @@ import java.time.OffsetDateTime; +import org.springframework.lang.NonNull; + import com.fasterxml.jackson.annotation.JsonInclude; +import de.tum.in.www1.hephaestus.codereview.pullrequest.PullRequestDTO; + @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record PullRequestReviewDTO(Long id, OffsetDateTime createdAt, OffsetDateTime updatedAt, - OffsetDateTime submittedAt, PullRequestReviewState state) { +public record PullRequestReviewDTO(@NonNull Long id, @NonNull OffsetDateTime createdAt, + @NonNull OffsetDateTime updatedAt, @NonNull OffsetDateTime submittedAt, + @NonNull PullRequestReviewState state, String url, PullRequestDTO pullRequest) { + public PullRequestReviewDTO(@NonNull Long id, @NonNull OffsetDateTime createdAt, + @NonNull OffsetDateTime updatedAt, @NonNull OffsetDateTime submittedAt, + @NonNull PullRequestReviewState state) { + this(id, createdAt, updatedAt, submittedAt, state, null, null); + } + + public PullRequestReviewDTO(@NonNull Long id, @NonNull OffsetDateTime createdAt, + @NonNull OffsetDateTime updatedAt, @NonNull OffsetDateTime submittedAt, + @NonNull PullRequestReviewState state, String url) { + this(id, createdAt, updatedAt, submittedAt, state, url, null); + } } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/repository/RepositoryDTO.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/repository/RepositoryDTO.java index 5defadbe..712eca0a 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/repository/RepositoryDTO.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/repository/RepositoryDTO.java @@ -2,11 +2,17 @@ import java.util.Set; +import org.springframework.lang.NonNull; + +import com.fasterxml.jackson.annotation.JsonInclude; + import de.tum.in.www1.hephaestus.codereview.pullrequest.PullRequestDTO; -public record RepositoryDTO(String name, String nameWithOwner, String description, String url, +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record RepositoryDTO(@NonNull String name, @NonNull String nameWithOwner, String description, + @NonNull String url, Set pullRequests) { - public RepositoryDTO(String name, String nameWithOwner, String description, String url) { + public RepositoryDTO(@NonNull String name, @NonNull String nameWithOwner, String description, @NonNull String url) { this(name, nameWithOwner, description, url, null); } } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserController.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserController.java index 3e264422..dee5a879 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserController.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserController.java @@ -29,4 +29,10 @@ public ResponseEntity getFullUser(@PathVariable String login) { System.out.println(user); return user.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); } + + @GetMapping("/{login}/profile") + public ResponseEntity getUserProfile(@PathVariable String login) { + Optional userProfile = userService.getUserProfileDTO(login); + return userProfile.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); + } } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserConverter.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserConverter.java index 3f2dadbb..d618ab88 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserConverter.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserConverter.java @@ -32,7 +32,7 @@ public User update(@NonNull GHUser source, @NonNull User user) { logger.error("Failed to convert user email field for source {}: {}", source.getId(), e.getMessage()); } try { - user.setName(source.getName()); + user.setName(source.getName() != null ? source.getName() : source.getLogin()); } catch (IOException e) { logger.error("Failed to convert user name field for source {}: {}", source.getId(), e.getMessage()); } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserProfileDTO.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserProfileDTO.java new file mode 100644 index 00000000..07f92fd3 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/codereview/user/UserProfileDTO.java @@ -0,0 +1,23 @@ +package de.tum.in.www1.hephaestus.codereview.user; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.lang.NonNull; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.hephaestus.codereview.pullrequest.PullRequestDTO; +import de.tum.in.www1.hephaestus.codereview.pullrequest.review.PullRequestReviewDTO; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record UserProfileDTO(@NonNull Long id, @NonNull String login, @NonNull String avatarUrl, + @NonNull OffsetDateTime firstContribution, @NonNull Set repositories, + Set activity, Set pullRequests) { + public UserProfileDTO(@NonNull Long id, @NonNull String login, @NonNull String avatarUrl, + @NonNull OffsetDateTime firstContribution, @NonNull List repositories) { + this(id, login, avatarUrl, firstContribution, repositories.stream().collect(Collectors.toSet()), null, null); + } +} 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 245baa36..6d6acd17 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 @@ -1,13 +1,28 @@ package de.tum.in.www1.hephaestus.codereview.user; import java.time.OffsetDateTime; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; +import de.tum.in.www1.hephaestus.codereview.base.BaseGitServiceEntity; +import de.tum.in.www1.hephaestus.codereview.pullrequest.IssueState; +import de.tum.in.www1.hephaestus.codereview.pullrequest.PullRequest; +import de.tum.in.www1.hephaestus.codereview.pullrequest.PullRequestDTO; +import de.tum.in.www1.hephaestus.codereview.pullrequest.review.PullRequestReview; +import de.tum.in.www1.hephaestus.codereview.pullrequest.review.PullRequestReviewDTO; +import de.tum.in.www1.hephaestus.codereview.repository.RepositoryDTO; + @Service public class UserService { private static final Logger logger = LoggerFactory.getLogger(UserService.class); @@ -38,4 +53,64 @@ public List getAllUsersInTimeframe(OffsetDateTime after, OffsetDateTime be + repository.orElse("all")); return userRepository.findAllInTimeframe(after, before, repository); } + + public Optional getUserProfileDTO(String login) { + logger.info("Getting userProfileDTO with login: " + login); + Optional optionalUser = userRepository.findUser(login); + if (optionalUser.isEmpty()) { + return Optional.empty(); + } + User user = optionalUser.get(); + + OffsetDateTime firstContribution = user.getPullRequests().stream().map(pr -> pr.getCreatedAt()) + .min(OffsetDateTime::compareTo).orElse(null); + Set repositories = mapToDTO(user.getPullRequests(), pr -> true, + pr -> pr.getRepository().getNameWithOwner(), + (r1, r2) -> r1.compareTo(r2)); + Set pullRequests = getPullRequestDTOs(user.getPullRequests()); + Set activity = getPullRequestReviewDTOs(user.getReviews()); + + return Optional.of(new UserProfileDTO(user.getId(), user.getLogin(), user.getAvatarUrl(), firstContribution, + repositories, activity, pullRequests)); + } + + private Set getPullRequestDTOs(Set pullRequests) { + return mapToDTO(pullRequests, + isRecentlyPredicate().and(pr -> ((PullRequest) pr).getState().equals(IssueState.OPEN)), + pr -> new PullRequestDTO( + pr.getId(), pr.getTitle(), pr.getNumber(), pr.getUrl(), pr.getState(), pr.getAdditions(), + pr.getDeletions(), + pr.getCreatedAt(), pr.getUpdatedAt(), null, + pr.getPullRequestLabels(), + new RepositoryDTO(pr.getRepository().getName(), + pr.getRepository().getNameWithOwner(), null, + pr.getRepository().getUrl())), + (pr1, pr2) -> pr1.createdAt().compareTo(pr2.createdAt())); + } + + private Set getPullRequestReviewDTOs(Set reviews) { + return mapToDTO(reviews, isRecentlyPredicate(), re -> { + PullRequest pr = re.getPullRequest(); + return new PullRequestReviewDTO(re.getId(), + re.getCreatedAt(), re.getUpdatedAt(), re.getSubmittedAt(), re.getState(), re.getUrl(), + new PullRequestDTO(pr.getId(), pr.getTitle(), pr.getNumber(), pr.getUrl(), pr.getState(), + pr.getAdditions(), pr.getDeletions(), pr.getCreatedAt(), pr.getUpdatedAt(), null, + new HashSet<>(), + new RepositoryDTO(pr.getRepository().getName(), + pr.getRepository().getNameWithOwner(), null, + pr.getRepository().getUrl()))); + }, (prr1, prr2) -> prr2.submittedAt().compareTo(prr1.submittedAt())); + } + + private Set mapToDTO(Set entities, Predicate predicate, + Function mapper, + Comparator comparator) { + return entities.stream().filter(predicate).map(mapper).sorted(comparator) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + // Predicate filtering createdAt for within past 7 days + private Predicate isRecentlyPredicate() { + return entity -> entity.getCreatedAt().isAfter(OffsetDateTime.now().minusDays(7)); + } } diff --git a/webapp/src/app/app.routes.ts b/webapp/src/app/app.routes.ts index 4f4e19eb..905e9a4f 100644 --- a/webapp/src/app/app.routes.ts +++ b/webapp/src/app/app.routes.ts @@ -3,6 +3,7 @@ import { AboutComponent } from '@app/about/about.component'; import { HomeComponent } from '@app/home/home.component'; import { AdminComponent } from '@app/admin/admin.component'; import { AdminGuard } from '@app/core/security/admin.guard'; +import { UserProfileComponent } from '@app/user/user-profile.component'; export const routes: Routes = [ { path: '', component: HomeComponent }, @@ -11,5 +12,6 @@ export const routes: Routes = [ path: 'admin', component: AdminComponent, canActivate: [AdminGuard] - } + }, + { path: 'user/:id', component: UserProfileComponent } ]; diff --git a/webapp/src/app/core/issue-card/issue-card.component.html b/webapp/src/app/core/issue-card/issue-card.component.html deleted file mode 100644 index 04e370da..00000000 --- a/webapp/src/app/core/issue-card/issue-card.component.html +++ /dev/null @@ -1,37 +0,0 @@ -
-
- - @if (state() === 'OPEN') { - - } @else { - - } - - {{ repositoryName() }} #{{ number() }} on {{ createdAt().format('MMM D') }} - - - +{{ additions() }} - -{{ deletions() }} - -
- -
- {{ title() }} - @if (getMostRecentReview(); as review) { - @if (review.state === 'APPROVED') { - - } @else if (review.state === 'DISMISSED') { - - } @else if (review.state === 'COMMENTED') { - - } @else { - - } - } -
-
- @for (label of pullRequestLabels(); track label.name) { - {{ label.name }} - } -
-
diff --git a/webapp/src/app/core/issue-card/issue-card.component.ts b/webapp/src/app/core/issue-card/issue-card.component.ts deleted file mode 100644 index 75bcb571..00000000 --- a/webapp/src/app/core/issue-card/issue-card.component.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Component, input } from '@angular/core'; -import { PullRequest, PullRequestLabel, PullRequestReview } from '@app/core/modules/openapi'; -import { NgIcon } from '@ng-icons/core'; -import { octCheck, octComment, octFileDiff, octGitPullRequest, octGitPullRequestClosed, octX } from '@ng-icons/octicons'; -import { Dayjs } from 'dayjs'; -import { NgStyle } from '@angular/common'; - -@Component({ - selector: 'app-issue-card', - templateUrl: './issue-card.component.html', - imports: [NgIcon, NgStyle], - standalone: true -}) -export class IssueCardComponent { - title = input.required(); - number = input.required(); - additions = input.required(); - deletions = input.required(); - url = input.required(); - repositoryName = input.required(); - reviews = input.required>(); - createdAt = input.required(); - state = input.required(); - pullRequestLabels = input.required>(); - protected readonly octCheck = octCheck; - protected readonly octX = octX; - protected readonly octComment = octComment; - protected readonly octGitPullRequest = octGitPullRequest; - protected readonly octFileDiff = octFileDiff; - protected readonly octGitPullRequestClosed = octGitPullRequestClosed; - - getMostRecentReview() { - return this.reviews().reduce((latest, review) => { - return new Date(review.updatedAt || 0) > new Date(latest.updatedAt || 0) ? review : latest; - }); - } - - openIssue() { - window.open(this.url()); - } -} diff --git a/webapp/src/app/core/issue-card/issue-card.stories.ts b/webapp/src/app/core/issue-card/issue-card.stories.ts deleted file mode 100644 index f9c50540..00000000 --- a/webapp/src/app/core/issue-card/issue-card.stories.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Meta, StoryObj } from '@storybook/angular'; -import { IssueCardComponent } from './issue-card.component'; -import dayjs from 'dayjs'; - -const meta: Meta = { - component: IssueCardComponent, - tags: ['autodocs'] // Auto-generate docs if enabled -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - title: 'Add feature X', - number: 12, - additions: 10, - deletions: 5, - url: 'http://example.com', - state: 'OPEN', - repositoryName: 'Artemis', - createdAt: dayjs('Jan 1'), - pullRequestLabels: [ - { name: 'bug', color: 'red' }, - { name: 'enhancement', color: 'green' } - ], - reviews: [ - { - state: 'APPROVED', - updatedAt: 'Jan 2' - }, - { - state: 'CHANGES_REQUESTED', - updatedAt: 'Jan 4' - } - ] - } -}; diff --git a/webapp/src/app/core/modules/openapi/.openapi-generator/FILES b/webapp/src/app/core/modules/openapi/.openapi-generator/FILES index e5544a76..ef18ebb1 100644 --- a/webapp/src/app/core/modules/openapi/.openapi-generator/FILES +++ b/webapp/src/app/core/modules/openapi/.openapi-generator/FILES @@ -32,6 +32,7 @@ model/repository-dto.ts model/repository.ts model/user-dto.ts model/user-info-dto.ts +model/user-profile-dto.ts model/user.ts param.ts variables.ts diff --git a/webapp/src/app/core/modules/openapi/api/user.service.ts b/webapp/src/app/core/modules/openapi/api/user.service.ts index 2e8ba927..96945444 100644 --- a/webapp/src/app/core/modules/openapi/api/user.service.ts +++ b/webapp/src/app/core/modules/openapi/api/user.service.ts @@ -22,6 +22,8 @@ import { Observable } from 'rxjs'; import { User } from '../model/user'; // @ts-ignore import { UserDTO } from '../model/user-dto'; +// @ts-ignore +import { UserProfileDTO } from '../model/user-profile-dto'; // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; @@ -223,4 +225,67 @@ export class UserService implements UserServiceInterface { ); } + /** + * @param login + * @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 getUserProfile(login: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public getUserProfile(login: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getUserProfile(login: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public getUserProfile(login: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + if (login === null || login === undefined) { + throw new Error('Required parameter login was null or undefined when calling getUserProfile.'); + } + + 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 = `/user/${this.configuration.encodeParam({name: "login", value: login, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/profile`; + 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/user.serviceInterface.ts b/webapp/src/app/core/modules/openapi/api/user.serviceInterface.ts index 65217329..23e5f0d3 100644 --- a/webapp/src/app/core/modules/openapi/api/user.serviceInterface.ts +++ b/webapp/src/app/core/modules/openapi/api/user.serviceInterface.ts @@ -15,6 +15,7 @@ import { Observable } from 'rxjs'; import { User } from '../model/models'; import { UserDTO } from '../model/models'; +import { UserProfileDTO } from '../model/models'; import { Configuration } from '../configuration'; @@ -39,4 +40,11 @@ export interface UserServiceInterface { */ getUser(login: string, extraHttpRequestParams?: any): Observable; + /** + * + * + * @param login + */ + getUserProfile(login: string, extraHttpRequestParams?: any): Observable; + } diff --git a/webapp/src/app/core/modules/openapi/model/models.ts b/webapp/src/app/core/modules/openapi/model/models.ts index 349ec7a5..492d9c34 100644 --- a/webapp/src/app/core/modules/openapi/model/models.ts +++ b/webapp/src/app/core/modules/openapi/model/models.ts @@ -13,3 +13,4 @@ export * from './repository-dto'; export * from './user'; export * from './user-dto'; export * from './user-info-dto'; +export * from './user-profile-dto'; diff --git a/webapp/src/app/core/modules/openapi/model/pull-request-dto.ts b/webapp/src/app/core/modules/openapi/model/pull-request-dto.ts index 4a1064f1..2237a970 100644 --- a/webapp/src/app/core/modules/openapi/model/pull-request-dto.ts +++ b/webapp/src/app/core/modules/openapi/model/pull-request-dto.ts @@ -9,21 +9,26 @@ * https://openapi-generator.tech * Do not edit the class manually. */ +import { PullRequestLabel } from './pull-request-label'; import { UserDTO } from './user-dto'; import { IssueCommentDTO } from './issue-comment-dto'; import { RepositoryDTO } from './repository-dto'; export interface PullRequestDTO { - id?: number; - title?: string; - url?: string; - state?: PullRequestDTO.StateEnum; - createdAt?: string; - updatedAt?: string; + id: number; + title: string; + number: number; + url: string; + state: PullRequestDTO.StateEnum; + additions: number; + deletions: number; + createdAt: string; + updatedAt: string; mergedAt?: string; author?: UserDTO; comments?: Set; + labels?: Set; repository?: RepositoryDTO; } export namespace PullRequestDTO { diff --git a/webapp/src/app/core/modules/openapi/model/pull-request-review-dto.ts b/webapp/src/app/core/modules/openapi/model/pull-request-review-dto.ts index b4947f88..86479807 100644 --- a/webapp/src/app/core/modules/openapi/model/pull-request-review-dto.ts +++ b/webapp/src/app/core/modules/openapi/model/pull-request-review-dto.ts @@ -9,14 +9,17 @@ * https://openapi-generator.tech * Do not edit the class manually. */ +import { PullRequestDTO } from './pull-request-dto'; export interface PullRequestReviewDTO { - id?: number; - createdAt?: string; - updatedAt?: string; - submittedAt?: string; - state?: PullRequestReviewDTO.StateEnum; + id: number; + createdAt: string; + updatedAt: string; + submittedAt: string; + state: PullRequestReviewDTO.StateEnum; + url?: string; + pullRequest?: PullRequestDTO; } export namespace PullRequestReviewDTO { export type StateEnum = 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED' | 'DISMISSED'; diff --git a/webapp/src/app/core/modules/openapi/model/pull-request-review.ts b/webapp/src/app/core/modules/openapi/model/pull-request-review.ts index 52c37e3b..68ca75ad 100644 --- a/webapp/src/app/core/modules/openapi/model/pull-request-review.ts +++ b/webapp/src/app/core/modules/openapi/model/pull-request-review.ts @@ -21,6 +21,7 @@ export interface PullRequestReview { author?: User; state: PullRequestReview.StateEnum; submittedAt?: string; + url?: string; comments?: Set; pullRequest?: PullRequest; } diff --git a/webapp/src/app/core/modules/openapi/model/repository-dto.ts b/webapp/src/app/core/modules/openapi/model/repository-dto.ts index bb31547f..9aa946fc 100644 --- a/webapp/src/app/core/modules/openapi/model/repository-dto.ts +++ b/webapp/src/app/core/modules/openapi/model/repository-dto.ts @@ -12,9 +12,9 @@ export interface RepositoryDTO { - name?: string; - nameWithOwner?: string; + name: string; + nameWithOwner: string; description?: string; - url?: string; + url: string; } diff --git a/webapp/src/app/core/modules/openapi/model/user-profile-dto.ts b/webapp/src/app/core/modules/openapi/model/user-profile-dto.ts new file mode 100644 index 00000000..dbaadabe --- /dev/null +++ b/webapp/src/app/core/modules/openapi/model/user-profile-dto.ts @@ -0,0 +1,25 @@ +/** + * 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 { PullRequestDTO } from './pull-request-dto'; +import { PullRequestReviewDTO } from './pull-request-review-dto'; + + +export interface UserProfileDTO { + id: number; + login: string; + avatarUrl: string; + firstContribution: string; + repositories: Set; + activity?: Set; + pullRequests?: Set; +} + diff --git a/webapp/src/app/home/leaderboard/leaderboard.component.html b/webapp/src/app/home/leaderboard/leaderboard.component.html index 7f167945..c77c106d 100644 --- a/webapp/src/app/home/leaderboard/leaderboard.component.html +++ b/webapp/src/app/home/leaderboard/leaderboard.component.html @@ -39,10 +39,10 @@ } @else { @for (entry of leaderboard(); track entry.githubName) { - + {{ entry.rank }} - + @@ -50,7 +50,7 @@ {{ entry.name }} - + {{ entry.score }} diff --git a/webapp/src/app/home/leaderboard/leaderboard.component.ts b/webapp/src/app/home/leaderboard/leaderboard.component.ts index bde91e08..51e79977 100644 --- a/webapp/src/app/home/leaderboard/leaderboard.component.ts +++ b/webapp/src/app/home/leaderboard/leaderboard.component.ts @@ -12,6 +12,7 @@ import { TableRowDirective } from 'app/ui/table/table-row.directive'; import { TableComponent } from 'app/ui/table/table.component'; import { HlmAvatarModule } from '@spartan-ng/ui-avatar-helm'; import { HlmSkeletonModule } from '@spartan-ng/ui-skeleton-helm'; +import { RouterLink, RouterOutlet } from '@angular/router'; @Component({ selector: 'app-leaderboard', @@ -27,7 +28,9 @@ import { HlmSkeletonModule } from '@spartan-ng/ui-skeleton-helm'; TableHeaderDirective, TableHeadDirective, TableRowDirective, - NgIconComponent + NgIconComponent, + RouterLink, + RouterOutlet ], templateUrl: './leaderboard.component.html' }) diff --git a/webapp/src/app/user/header/header.component.html b/webapp/src/app/user/header/header.component.html new file mode 100644 index 00000000..80cbaa39 --- /dev/null +++ b/webapp/src/app/user/header/header.component.html @@ -0,0 +1,54 @@ +
+ @if (isLoading()) { + + + + + } @else { + + + + {{ userData()?.login?.slice(0, 2)?.toUpperCase() }} + + + } + @if (isLoading()) { +
+ + + +
+ + +
+
+ } @else { +
+

{{ userData()?.login }}

+ + github.com/{{ userData()?.login }} + + @if (displayFirstContribution()) { +
+ + Contributing since {{ displayFirstContribution() }} +
+ } +
+ @for (repository of userData()?.repositories; track repository) { + + + + + {{ repository }} + + } +
+
+ } +
diff --git a/webapp/src/app/user/header/header.component.ts b/webapp/src/app/user/header/header.component.ts new file mode 100644 index 00000000..50f6562a --- /dev/null +++ b/webapp/src/app/user/header/header.component.ts @@ -0,0 +1,59 @@ +import { Component, computed, input } from '@angular/core'; +import { NgIconComponent } from '@ng-icons/core'; +import { octClockFill } from '@ng-icons/octicons'; +import { HlmAvatarModule } from '@spartan-ng/ui-avatar-helm'; +import { HlmSkeletonModule } from '@spartan-ng/ui-skeleton-helm'; +import { HlmIconModule } from 'libs/ui/ui-icon-helm/src/index'; +import { BrnTooltipContentDirective } from '@spartan-ng/ui-tooltip-brain'; +import { HlmTooltipComponent, HlmTooltipTriggerDirective } from '@spartan-ng/ui-tooltip-helm'; +import { HlmButtonModule } from '@spartan-ng/ui-button-helm'; +import { LucideAngularModule } from 'lucide-angular'; +import dayjs from 'dayjs'; +import advancedFormat from 'dayjs/plugin/advancedFormat'; + +dayjs.extend(advancedFormat); + +type UserHeaderProps = { + avatarUrl: string; + login: string; + firstContribution: string; + repositories: Set; +}; + +const repoImages: { [key: string]: string } = { + Hephaestus: 'https://github.com/ls1intum/Hephaestus/raw/refs/heads/develop/docs/images/hammer.svg', + Artemis: 'https://artemis.in.tum.de/public/images/logo.png', + Athena: 'https://raw.githubusercontent.com/ls1intum/Athena/develop/playground/public/logo.png' +}; + +@Component({ + selector: 'app-user-header', + standalone: true, + imports: [ + LucideAngularModule, + NgIconComponent, + HlmAvatarModule, + HlmSkeletonModule, + HlmIconModule, + HlmTooltipComponent, + HlmTooltipTriggerDirective, + BrnTooltipContentDirective, + HlmButtonModule + ], + templateUrl: './header.component.html' +}) +export class UserHeaderComponent { + protected octClockFill = octClockFill; + + isLoading = input(false); + userData = input(); + + displayFirstContribution = computed(() => { + if (this.userData()?.firstContribution) { + return dayjs(this.userData()?.firstContribution).format('Do [of] MMMM YYYY'); + } + return null; + }); + + getRepositoryImage = (name: string) => (name ? repoImages[name.split('/')[1]] : null) || 'https://avatars.githubusercontent.com/u/11064260?v=4'; +} diff --git a/webapp/src/app/user/header/header.stories.ts b/webapp/src/app/user/header/header.stories.ts new file mode 100644 index 00000000..d0014897 --- /dev/null +++ b/webapp/src/app/user/header/header.stories.ts @@ -0,0 +1,83 @@ +import { argsToTemplate, Meta, StoryObj } from '@storybook/angular'; +import dayjs from 'dayjs'; +import { UserHeaderComponent } from './header.component'; + +type FlatArgs = { + isLoading: boolean; + avatarUrl: string; + login: string; + firstContribution: string; + repositories: string; +}; + +function flatArgsToProps(args: FlatArgs) { + return { + isLoading: args.isLoading, + userData: { + avatarUrl: args.avatarUrl, + login: args.login, + firstContribution: dayjs(args.firstContribution), + repositories: new Set(args.repositories.split(',').map((repo) => repo.trim())) + } + }; +} + +const meta: Meta = { + component: UserHeaderComponent, + tags: ['autodocs'], + args: { + isLoading: false, + avatarUrl: 'https://avatars.githubusercontent.com/u/11064260?v=4', + login: 'octocat', + firstContribution: dayjs().subtract(4, 'days').toISOString(), + repositories: 'ls1intum/Hephaestus, ls1intum/Artemis, ls1intum/Athena' + }, + argTypes: { + isLoading: { + control: { + type: 'boolean' + } + }, + firstContribution: { + control: { + type: 'date' + } + }, + avatarUrl: { + control: { + type: 'text' + } + }, + login: { + control: { + type: 'text' + } + }, + repositories: { + control: { + type: 'text' + } + } + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: flatArgsToProps(args as unknown as FlatArgs), + template: `` + }) +}; + +export const IsLoading: Story = { + args: { + isLoading: true + }, + render: (args) => ({ + props: flatArgsToProps(args as unknown as FlatArgs), + template: `` + }) +}; diff --git a/webapp/src/app/user/issue-card/issue-card.component.html b/webapp/src/app/user/issue-card/issue-card.component.html new file mode 100644 index 00000000..d5e22c68 --- /dev/null +++ b/webapp/src/app/user/issue-card/issue-card.component.html @@ -0,0 +1,55 @@ + +
+
+ + @if (isLoading()) { + + + } @else { + @if (state() === 'OPEN') { + + } @else { + + } + + {{ repositoryName() }} #{{ number() }} on {{ displayCreated().format('MMM D') }} + } + + + @if (isLoading()) { + + + } @else { + +{{ additions() }} + -{{ deletions() }} + } + +
+ + + @if (isLoading()) { + + } @else { +
+ } +
+
+ @if (!isLoading()) { +
+ @for (label of pullRequestLabels(); track label.name) { + @let labelColors = hexToRgb(label.color ?? 'FFFFFF'); + + {{ label.name }} + + } +
+ } +
diff --git a/webapp/src/app/user/issue-card/issue-card.component.scss b/webapp/src/app/user/issue-card/issue-card.component.scss new file mode 100644 index 00000000..31688d27 --- /dev/null +++ b/webapp/src/app/user/issue-card/issue-card.component.scss @@ -0,0 +1,25 @@ +.gh-label { + --lightness-threshold: 0.453; + --border-threshold: 0.96; + --lightness-threshold: 0.453; + --border-threshold: 0.96; + --border-alpha: max(0, min(calc((var(--perceived-lightness) - var(--border-threshold)) * 100), 1)); + --background-alpha: max(0, min(calc((var(--perceived-lightness) - var(--border-threshold)) * 100), 1)); + color: hsl(0deg, 0%, calc(var(--lightness-switch) * 100%)); + background: rgb(var(--label-r), var(--label-g), var(--label-b)); + border-color: hsla(var(--label-h), calc(var(--label-s) * 1%), calc((var(--label-l) - 25) * 1%), var(--border-alpha)); +} +:host-context(.dark) .gh-label { + --lightness-threshold: 0.6; + --background-alpha: 0.18; + --border-alpha: 0.3; + --lightness-threshold: 0.6; + --background-alpha: 0.18; + --border-alpha: 0.3; + --perceived-lightness: calc(((var(--label-r) * 0.2126) + (var(--label-g) * 0.7152) + (var(--label-b) * 0.0722)) / 255); + --lightness-switch: max(0, min(calc((1 / (var(--lightness-threshold) - var(--perceived-lightness)))), 1)); + --lighten-by: calc(((var(--lightness-threshold) - var(--perceived-lightness)) * 100) * var(--lightness-switch)); + color: hsl(var(--label-h), calc(var(--label-s) * 1%), calc((var(--label-l) + var(--lighten-by)) * 1%)); + background: rgba(var(--label-r), var(--label-g), var(--label-b), var(--background-alpha)); + border-color: hsla(var(--label-h), calc(var(--label-s) * 1%), calc((var(--label-l) + var(--lighten-by)) * 1%), var(--border-alpha)); +} diff --git a/webapp/src/app/user/issue-card/issue-card.component.ts b/webapp/src/app/user/issue-card/issue-card.component.ts new file mode 100644 index 00000000..80bf1930 --- /dev/null +++ b/webapp/src/app/user/issue-card/issue-card.component.ts @@ -0,0 +1,91 @@ +import { Component, computed, input } from '@angular/core'; +import { PullRequest, PullRequestLabel } from '@app/core/modules/openapi'; +import { NgIcon } from '@ng-icons/core'; +import { octCheck, octComment, octFileDiff, octGitPullRequest, octGitPullRequestClosed, octX } from '@ng-icons/octicons'; +import { HlmCardModule } from '@spartan-ng/ui-card-helm'; +import { HlmSkeletonComponent } from '@spartan-ng/ui-skeleton-helm'; +import dayjs from 'dayjs'; +import { cn } from '@app/utils'; + +@Component({ + selector: 'app-issue-card', + templateUrl: './issue-card.component.html', + imports: [NgIcon, HlmCardModule, HlmSkeletonComponent], + styleUrls: ['./issue-card.component.scss'], + standalone: true +}) +export class IssueCardComponent { + protected readonly octCheck = octCheck; + protected readonly octX = octX; + protected readonly octComment = octComment; + protected readonly octGitPullRequest = octGitPullRequest; + protected readonly octFileDiff = octFileDiff; + protected readonly octGitPullRequestClosed = octGitPullRequestClosed; + + isLoading = input(false); + class = input(''); + title = input(); + number = input(); + additions = input(); + deletions = input(); + url = input(); + repositoryName = input(); + createdAt = input(); + state = input(); + pullRequestLabels = input>(); + + displayCreated = computed(() => dayjs(this.createdAt())); + displayTitle = computed(() => (this.title() ?? '').replace(/`([^`]+)`/g, '$1')); + computedClass = computed(() => cn('w-72', !this.isLoading() ? 'hover:bg-accent/50 cursor-pointer' : '', this.class())); + + hexToRgb(hex: string) { + const bigint = parseInt(hex, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + + const hsl = this.rgbToHsl(r, g, b); + + return { + r: r, + g: g, + b: b, + ...hsl + }; + } + + rgbToHsl(r: number, g: number, b: number) { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0, + s = 0, + l = (max + min) / 2; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + h /= 6; + } + + h = Math.round(h * 360); + s = Math.round(s * 100); + l = Math.round(l * 100); + + return { h, s, l }; + } +} diff --git a/webapp/src/app/user/issue-card/issue-card.stories.ts b/webapp/src/app/user/issue-card/issue-card.stories.ts new file mode 100644 index 00000000..274a59f1 --- /dev/null +++ b/webapp/src/app/user/issue-card/issue-card.stories.ts @@ -0,0 +1,46 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { IssueCardComponent } from './issue-card.component'; + +const meta: Meta = { + component: IssueCardComponent, + tags: ['autodocs'] // Auto-generate docs if enabled +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Add feature X', + number: 12, + additions: 10, + deletions: 5, + url: 'http://example.com', + state: 'OPEN', + repositoryName: 'Artemis', + createdAt: '2024-01-01', + pullRequestLabels: new Set([ + { name: 'bug', color: 'f00000' }, + { name: 'enhancement', color: '008000' } + ]) + } +}; + +export const isLoading: Story = { + args: { + title: 'Add feature X', + number: 12, + additions: 10, + deletions: 5, + url: 'http://example.com', + state: 'OPEN', + repositoryName: 'Artemis', + createdAt: '2024-01-01', + pullRequestLabels: new Set([ + { name: 'bug', color: 'f00000' }, + { name: 'enhancement', color: '008000' } + ]), + isLoading: true + } +}; diff --git a/webapp/src/app/user/review-activity-card/review-activity-card.component.html b/webapp/src/app/user/review-activity-card/review-activity-card.component.html new file mode 100644 index 00000000..da2d2184 --- /dev/null +++ b/webapp/src/app/user/review-activity-card/review-activity-card.component.html @@ -0,0 +1,25 @@ +@if (isLoading()) { +
+
+ +
+ + +
+
+
+} @else { + +
+
+ {{ relativeActivityTime() }} + in + {{ this.repositoryName() }} #{{ this.pullRequest()?.number }} +
+
+ + +
+
+
+} diff --git a/webapp/src/app/user/review-activity-card/review-activity-card.component.ts b/webapp/src/app/user/review-activity-card/review-activity-card.component.ts new file mode 100644 index 00000000..117440d7 --- /dev/null +++ b/webapp/src/app/user/review-activity-card/review-activity-card.component.ts @@ -0,0 +1,79 @@ +import { Component, computed, input } from '@angular/core'; +import { PullRequestReviewDTO } from '@app/core/modules/openapi'; +import { NgIcon } from '@ng-icons/core'; +import { octCheck, octComment, octFileDiff, octGitPullRequest, octGitPullRequestClosed } from '@ng-icons/octicons'; +import { HlmCardModule } from '@spartan-ng/ui-card-helm'; +import { HlmSkeletonComponent } from '@spartan-ng/ui-skeleton-helm'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +dayjs.extend(relativeTime); + +type PullRequestProps = { + number: number; + title: string; + url: string; +}; + +type ReviewStateCases = { + [key: string]: { + icon: string; + color: string; + skeletonColor: string; + }; +}; + +@Component({ + selector: 'app-review-activity-card', + templateUrl: './review-activity-card.component.html', + imports: [NgIcon, HlmCardModule, HlmSkeletonComponent], + standalone: true +}) +export class ReviewActivityCardComponent { + protected readonly octCheck = octCheck; + protected readonly octFileDiff = octFileDiff; + protected readonly octComment = octComment; + protected readonly octGitPullRequest = octGitPullRequest; + protected readonly octGitPullRequestClosed = octGitPullRequestClosed; + + isLoading = input(false); + class = input(''); + state = input(); + createdAt = input(); + pullRequest = input(); + repositoryName = input(); + + relativeActivityTime = computed(() => dayjs(this.createdAt()).fromNow()); + displayPullRequestTitle = computed(() => (this.pullRequest()?.title ?? '').replace(/`([^`]+)`/g, '$1')); + + reviewStateCases: ReviewStateCases = { + [PullRequestReviewDTO.StateEnum.Approved]: { + icon: this.octCheck, + color: 'text-github-success-foreground', + skeletonColor: 'bg-green-500/30' + }, + [PullRequestReviewDTO.StateEnum.ChangesRequested]: { + icon: this.octFileDiff, + color: 'text-github-danger-foreground', + skeletonColor: 'bg-destructive/20' + }, + [PullRequestReviewDTO.StateEnum.Commented]: { + icon: this.octComment, + color: 'text-github-neutral-foreground', + skeletonColor: 'bg-neutral-500/20' + } + }; + + skeletonColorForReviewState = computed(() => { + if (this.isLoading()) { + const colors = Object.values(this.reviewStateCases).map((value) => value.skeletonColor); + return colors[Math.floor(Math.random() * colors.length)]; + } + return ''; + }); + + reviewStateProps = computed(() => { + const props = this.state() ? this.reviewStateCases[this.state()!] : undefined; + return props ?? this.reviewStateCases[PullRequestReviewDTO.StateEnum.Commented]; + }); +} diff --git a/webapp/src/app/user/review-activity-card/review-activity-card.stories.ts b/webapp/src/app/user/review-activity-card/review-activity-card.stories.ts new file mode 100644 index 00000000..d6839212 --- /dev/null +++ b/webapp/src/app/user/review-activity-card/review-activity-card.stories.ts @@ -0,0 +1,101 @@ +import { argsToTemplate, Meta, StoryObj } from '@storybook/angular'; +import { ReviewActivityCardComponent } from './review-activity-card.component'; +import dayjs from 'dayjs'; + +type FlatArgs = { + isLoading: boolean; + reviewActivityCreatedAt: string; + reviewActivityState: string; + pullRequestNumber: number; + pullRequestState: string; + pullRequestUrl: string; + pullRequestTitle: string; + repositoryName: string; +}; + +function flatArgsToProps(args: FlatArgs) { + return { + isLoading: args.isLoading, + createdAt: dayjs(args.reviewActivityCreatedAt), + state: args.reviewActivityState, + pullRequest: { + number: args.pullRequestNumber, + title: args.pullRequestTitle, + url: args.pullRequestUrl + }, + repositoryName: args.repositoryName + }; +} + +const meta: Meta = { + component: ReviewActivityCardComponent, + tags: ['autodocs'], + args: { + isLoading: false, + reviewActivityCreatedAt: dayjs().subtract(4, 'days').toISOString(), + reviewActivityState: 'CHANGES_REQUESTED', + pullRequestNumber: 100, + pullRequestTitle: '`Leaderboard`: Custom Sliding Time Window', + pullRequestUrl: 'https://github.com/ls1intum/Hephaestus/pull/100', + repositoryName: 'Hephaestus' + }, + argTypes: { + isLoading: { + control: { + type: 'boolean' + } + }, + reviewActivityCreatedAt: { + control: { + type: 'date' + } + }, + reviewActivityState: { + options: ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED'], + control: { + type: 'select' + } + }, + pullRequestNumber: { + control: { + type: 'number' + } + }, + pullRequestTitle: { + control: { + type: 'text' + } + }, + pullRequestUrl: { + control: { + type: 'text' + } + }, + repositoryName: { + control: { + type: 'text' + } + } + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: flatArgsToProps(args as unknown as FlatArgs), + template: `` + }) +}; + +export const IsLoading: Story = { + args: { + isLoading: true + }, + render: (args) => ({ + props: flatArgsToProps(args as unknown as FlatArgs), + template: `` + }) +}; diff --git a/webapp/src/app/user/user-profile.component.html b/webapp/src/app/user/user-profile.component.html new file mode 100644 index 00000000..3e5e6e47 --- /dev/null +++ b/webapp/src/app/user/user-profile.component.html @@ -0,0 +1,69 @@ +
+ @if (query.error()) { +
+
+ +

Something went wrong...

+

User couldn't be loaded. Please try again later.

+
+
+ } @else { + @let showSkeleton = query.isPending(); + +
+
+

Latest Review Activity

+ @let userActivity = showSkeleton ? skeletonReviews : query.data()?.activity; + +
+ @for (activity of userActivity; track activity.id) { + + } + @if (!showSkeleton && (!query.data()?.activity || query.data()?.activity?.size === 0)) { +
+ + No activity found +
+ } +
+
+
+
+

Open Pull Requests

+ @let userPullRequests = showSkeleton ? skeletonPullRequests : query.data()?.pullRequests; + +
+ @for (pullRequest of userPullRequests; track pullRequest.id) { + + } + + @if (!showSkeleton && (!query.data()?.pullRequests || query.data()?.pullRequests?.size === 0)) { +
+ + No open pull requests found +
+ } +
+
+
+
+ } +
diff --git a/webapp/src/app/user/user-profile.component.ts b/webapp/src/app/user/user-profile.component.ts new file mode 100644 index 00000000..0e888799 --- /dev/null +++ b/webapp/src/app/user/user-profile.component.ts @@ -0,0 +1,69 @@ +import { Component, inject } from '@angular/core'; +import { NgIconComponent } from '@ng-icons/core'; +import { PullRequestDTO, PullRequestReviewDTO, UserService } from 'app/core/modules/openapi'; +import { HlmAvatarModule } from '@spartan-ng/ui-avatar-helm'; +import { HlmSkeletonModule } from '@spartan-ng/ui-skeleton-helm'; +import { ActivatedRoute } from '@angular/router'; +import { injectQuery } from '@tanstack/angular-query-experimental'; +import { HlmIconModule } from 'libs/ui/ui-icon-helm/src/index'; +import { BrnTooltipContentDirective } from '@spartan-ng/ui-tooltip-brain'; +import { HlmTooltipComponent, HlmTooltipTriggerDirective } from '@spartan-ng/ui-tooltip-helm'; +import { HlmButtonModule } from '@spartan-ng/ui-button-helm'; +import { HlmScrollAreaComponent } from '@spartan-ng/ui-scrollarea-helm'; +import { ReviewActivityCardComponent } from '@app/user/review-activity-card/review-activity-card.component'; +import { IssueCardComponent } from '@app/user/issue-card/issue-card.component'; +import { combineLatest, lastValueFrom, map, timer } from 'rxjs'; +import { CircleX, LucideAngularModule, Info } from 'lucide-angular'; +import { UserHeaderComponent } from './header/header.component'; + +@Component({ + selector: 'app-user-profile', + standalone: true, + imports: [ + LucideAngularModule, + NgIconComponent, + ReviewActivityCardComponent, + IssueCardComponent, + HlmAvatarModule, + HlmSkeletonModule, + HlmIconModule, + HlmTooltipComponent, + HlmTooltipTriggerDirective, + BrnTooltipContentDirective, + HlmButtonModule, + HlmScrollAreaComponent, + UserHeaderComponent + ], + templateUrl: './user-profile.component.html' +}) +export class UserProfileComponent { + userService = inject(UserService); + + protected CircleX = CircleX; + protected Info = Info; + // get user id from the url + protected userLogin: string | null = null; + + constructor(private route: ActivatedRoute) { + this.userLogin = this.route.snapshot.paramMap.get('id'); + } + + skeletonReviews = this.genSkeletonArray(3); + skeletonPullRequests = this.genSkeletonArray(2); + + genSkeletonArray(length: number): T[] { + return Array.from({ length }, (_, i) => ({ id: i })) as T[]; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + calcScrollHeight = (arr: any[] | Set | undefined, elHeight = 100) => { + if (Array.isArray(arr)) return `min(400px, calc(${arr.length * elHeight}px + ${8 * arr.length}px))`; + return '400px'; + }; + + query = injectQuery(() => ({ + queryKey: ['user', { id: this.userLogin }], + enabled: !!this.userLogin, + queryFn: async () => lastValueFrom(combineLatest([this.userService.getUserProfile(this.userLogin!), timer(400)]).pipe(map(([user]) => user))) + })); +} diff --git a/webapp/src/app/user/user-profile.stories.ts b/webapp/src/app/user/user-profile.stories.ts new file mode 100644 index 00000000..d23cc0e4 --- /dev/null +++ b/webapp/src/app/user/user-profile.stories.ts @@ -0,0 +1,35 @@ +import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular'; +import { UserProfileComponent } from './user-profile.component'; +import { ActivatedRoute } from '@angular/router'; + +const meta: Meta = { + title: 'pages/user', + component: UserProfileComponent, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: { + get: () => 'krusche' + } + } + } + } + ] + }) + ] +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: `` + }) +}; diff --git a/webapp/src/libs/ui/ui-avatar-helm/src/lib/hlm-avatar.component.ts b/webapp/src/libs/ui/ui-avatar-helm/src/lib/hlm-avatar.component.ts index 05bcb8e6..afefdb0d 100644 --- a/webapp/src/libs/ui/ui-avatar-helm/src/lib/hlm-avatar.component.ts +++ b/webapp/src/libs/ui/ui-avatar-helm/src/lib/hlm-avatar.component.ts @@ -9,7 +9,8 @@ export const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounde variant: { small: 'h-6 w-6 text-xs', medium: 'h-10 w-10', - large: 'h-14 w-14 text-lg' + large: 'h-14 w-14 text-lg', + extralarge: 'h-32 w-32 lg:h-40 lg:w-40 text-xl md:text-3xl' } }, defaultVariants: { diff --git a/webapp/src/libs/ui/ui-card-helm/src/lib/hlm-card-content.directive.ts b/webapp/src/libs/ui/ui-card-helm/src/lib/hlm-card-content.directive.ts index 1350ab49..8d8a2342 100644 --- a/webapp/src/libs/ui/ui-card-helm/src/lib/hlm-card-content.directive.ts +++ b/webapp/src/libs/ui/ui-card-helm/src/lib/hlm-card-content.directive.ts @@ -1,10 +1,15 @@ -import { Directive, computed, input } from '@angular/core'; +import { Directive, Input, computed, input, signal } from '@angular/core'; import { hlm } from '@spartan-ng/ui-core'; import { type VariantProps, cva } from 'class-variance-authority'; import type { ClassValue } from 'clsx'; -export const cardContentVariants = cva('p-6 pt-0', { - variants: {}, +export const cardContentVariants = cva('pt-0', { + variants: { + variant: { + default: 'p-6', + profile: 'flex flex-col gap-2' + } + }, defaultVariants: {} }); export type CardContentVariants = VariantProps; @@ -18,5 +23,11 @@ export type CardContentVariants = VariantProps; }) export class HlmCardContentDirective { public readonly userClass = input('', { alias: 'class' }); - protected _computedClass = computed(() => hlm(cardContentVariants(), this.userClass())); + protected _computedClass = computed(() => hlm(cardContentVariants({ variant: this._variant() }), this.userClass())); + + private readonly _variant = signal('default'); + @Input() + set variant(variant: CardContentVariants['variant']) { + this._variant.set(variant); + } } diff --git a/webapp/src/libs/ui/ui-card-helm/src/lib/hlm-card.directive.ts b/webapp/src/libs/ui/ui-card-helm/src/lib/hlm-card.directive.ts index fd4dd7c4..57e5ad3e 100644 --- a/webapp/src/libs/ui/ui-card-helm/src/lib/hlm-card.directive.ts +++ b/webapp/src/libs/ui/ui-card-helm/src/lib/hlm-card.directive.ts @@ -1,10 +1,15 @@ -import { Directive, computed, input } from '@angular/core'; +import { Directive, Input, computed, input, signal } from '@angular/core'; import { hlm } from '@spartan-ng/ui-core'; import { type VariantProps, cva } from 'class-variance-authority'; import type { ClassValue } from 'clsx'; export const cardVariants = cva('rounded-lg border border-border bg-card focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 text-card-foreground shadow-sm', { - variants: {}, + variants: { + variant: { + default: '', + profile: 'shadow-md block p-4' + } + }, defaultVariants: {} }); export type CardVariants = VariantProps; @@ -18,5 +23,11 @@ export type CardVariants = VariantProps; }) export class HlmCardDirective { public readonly userClass = input('', { alias: 'class' }); - protected _computedClass = computed(() => hlm(cardVariants(), this.userClass())); + protected _computedClass = computed(() => hlm(cardVariants({ variant: this._variant() }), this.userClass())); + + private readonly _variant = signal('default'); + @Input() + set variant(variant: CardVariants['variant']) { + this._variant.set(variant); + } } diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 8f83662d..ff8c2fde 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -63,4 +63,12 @@ body { @apply bg-background text-foreground; } + + code.textCode { + @apply bg-github-muted tracking-tight rounded px-1 py-0.5; + } + + .containerSize { + container-type: inline-size; + } }