From 9c72fcc5b5927c3f5c821ca3472f4d2d801be84a Mon Sep 17 00:00:00 2001 From: "Felix T.J. Dietrich" Date: Fri, 18 Oct 2024 22:23:24 +0200 Subject: [PATCH] Improve user profile page (#119) Co-authored-by: GODrums --- webapp/src/app/app.routes.ts | 4 +- .../core/issue-card/issue-card.component.html | 48 ------- .../profile-activity-card.component.html | 24 ---- .../profile-activity-card.component.ts | 63 -------- .../profile-activity-card.stories.ts | 22 --- .../app/home/profile/profile.component.html | 134 ------------------ .../src/app/home/profile/profile.component.ts | 90 ------------ .../src/app/user/header/header.component.html | 49 +++++++ .../src/app/user/header/header.component.ts | 59 ++++++++ webapp/src/app/user/header/header.stories.ts | 83 +++++++++++ .../user/issue-card/issue-card.component.html | 54 +++++++ .../issue-card/issue-card.component.scss | 0 .../issue-card/issue-card.component.ts | 38 +++-- .../issue-card/issue-card.stories.ts | 17 --- .../review-activity-card.component.html | 25 ++++ .../review-activity-card.component.ts | 78 ++++++++++ .../review-activity-card.stories.ts | 101 +++++++++++++ .../src/app/user/user-profile.component.html | 68 +++++++++ webapp/src/app/user/user-profile.component.ts | 69 +++++++++ webapp/src/app/user/user-profile.stories.ts | 35 +++++ .../src/lib/hlm-avatar.component.ts | 2 +- .../src/lib/hlm-card-content.directive.ts | 19 ++- .../src/lib/hlm-card.directive.ts | 17 ++- webapp/src/styles.css | 8 ++ 24 files changed, 677 insertions(+), 430 deletions(-) delete mode 100644 webapp/src/app/core/issue-card/issue-card.component.html delete mode 100644 webapp/src/app/core/profile-activity-card/profile-activity-card.component.html delete mode 100644 webapp/src/app/core/profile-activity-card/profile-activity-card.component.ts delete mode 100644 webapp/src/app/core/profile-activity-card/profile-activity-card.stories.ts delete mode 100644 webapp/src/app/home/profile/profile.component.html delete mode 100644 webapp/src/app/home/profile/profile.component.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 rename webapp/src/app/{core => user}/issue-card/issue-card.component.scss (100%) rename webapp/src/app/{core => user}/issue-card/issue-card.component.ts (66%) rename webapp/src/app/{core => user}/issue-card/issue-card.stories.ts (62%) 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/webapp/src/app/app.routes.ts b/webapp/src/app/app.routes.ts index 2c317be0..905e9a4f 100644 --- a/webapp/src/app/app.routes.ts +++ b/webapp/src/app/app.routes.ts @@ -3,7 +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 { ProfileComponent } from './home/profile/profile.component'; +import { UserProfileComponent } from '@app/user/user-profile.component'; export const routes: Routes = [ { path: '', component: HomeComponent }, @@ -13,5 +13,5 @@ export const routes: Routes = [ component: AdminComponent, canActivate: [AdminGuard] }, - { path: 'user/:id', component: ProfileComponent } + { 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 61e750c0..00000000 --- a/webapp/src/app/core/issue-card/issue-card.component.html +++ /dev/null @@ -1,48 +0,0 @@ -
-
- - @if (state() === 'OPEN') { - - } @else { - - } - - {{ repositoryName() }} #{{ number() }} on {{ displayCreated().format('MMM D') }} - - - +{{ additions() }} - -{{ deletions() }} - -
- - - {{ title() }} - @if (reviews() && reviews()!.size > 0 && 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) { - @let labelColors = hexToRgb(label.color ?? 'FFFFFF'); - - {{ label.name }} - - } -
-
diff --git a/webapp/src/app/core/profile-activity-card/profile-activity-card.component.html b/webapp/src/app/core/profile-activity-card/profile-activity-card.component.html deleted file mode 100644 index 1457f563..00000000 --- a/webapp/src/app/core/profile-activity-card/profile-activity-card.component.html +++ /dev/null @@ -1,24 +0,0 @@ - - - {{ displayAge() }} - -
-
- - {{ this.reviewStateProps().text }} -
-
-
- {{ this.repositoryName() }} #{{ this.pullRequestNumber() }} - @if (this.pullRequestState() === 'OPEN') { - - } @else { - - } -
- diff --git a/webapp/src/app/core/profile-activity-card/profile-activity-card.component.ts b/webapp/src/app/core/profile-activity-card/profile-activity-card.component.ts deleted file mode 100644 index f8a6e61a..00000000 --- a/webapp/src/app/core/profile-activity-card/profile-activity-card.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Component, computed, input } from '@angular/core'; -import { PullRequestDTO, PullRequestReviewDTO } from '@app/core/modules/openapi'; -import { NgIcon } from '@ng-icons/core'; -import { octCheck, octComment, octFileDiff, octGitPullRequest, octGitPullRequestClosed } from '@ng-icons/octicons'; -import dayjs from 'dayjs'; -import relativeTime from 'dayjs/plugin/relativeTime'; - -dayjs.extend(relativeTime); - -@Component({ - selector: 'app-profile-activity-card', - templateUrl: './profile-activity-card.component.html', - imports: [NgIcon], - standalone: true -}) -export class ProfileActivityCardComponent { - url = input(undefined); - createdAt = input.required(); - state = input.required(); - repositoryName = input.required(); - pullRequestNumber = input.required(); - pullRequestState = input.required(); - pullRequestUrl = input.required(); - - protected readonly octCheck = octCheck; - protected readonly octFileDiff = octFileDiff; - protected readonly octComment = octComment; - protected readonly octGitPullRequest = octGitPullRequest; - protected readonly octGitPullRequestClosed = octGitPullRequestClosed; - - displayAge = computed(() => dayjs(this.createdAt()).fromNow()); - - reviewStateProps = computed(() => { - switch (this.state()) { - case 'APPROVED': - return { - icon: this.octCheck, - color: 'text-github-success-foreground', - text: 'Approved' - }; - case 'CHANGES_REQUESTED': - return { - icon: this.octFileDiff, - color: 'text-github-danger-foreground', - text: 'Changes requested' - }; - default: - return { - icon: this.octComment, - color: 'text-github-neutral-foreground', - text: 'Commented' - }; - } - }); - - toTitleCase(str: string) { - return str - .replaceAll('_', ' ') - .split(' ') - .map((s) => s.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase())) - .join(' '); - } -} diff --git a/webapp/src/app/core/profile-activity-card/profile-activity-card.stories.ts b/webapp/src/app/core/profile-activity-card/profile-activity-card.stories.ts deleted file mode 100644 index c97bae33..00000000 --- a/webapp/src/app/core/profile-activity-card/profile-activity-card.stories.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Meta, StoryObj } from '@storybook/angular'; -import { ProfileActivityCardComponent } from './profile-activity-card.component'; - -const meta: Meta = { - component: ProfileActivityCardComponent, - tags: ['autodocs'] // Auto-generate docs if enabled -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - createdAt: '2024-10-06', - state: 'CHANGES_REQUESTED', - repositoryName: 'Hephaestus', - pullRequestNumber: 100, - pullRequestState: 'OPEN', - pullRequestUrl: 'https://github.com/ls1intum/Hephaestus/pull/100' - } -}; diff --git a/webapp/src/app/home/profile/profile.component.html b/webapp/src/app/home/profile/profile.component.html deleted file mode 100644 index 394fd1ce..00000000 --- a/webapp/src/app/home/profile/profile.component.html +++ /dev/null @@ -1,134 +0,0 @@ -
- @if (query.error()) { -
-
- -

Something went wrong...

-

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

-
-
- } @else if (query.isPending()) { -
- - - - -
- - -
-
-
-
-

Latest Activity

- - - - -
-
-

Open Pullrequests

- - - - -
-
- } @else { - @if (query.data(); as userData) { -
- - - - {{ userData.login.slice(0, 2).toUpperCase() }} - - -
-

{{ userData.login }}

- -
- - Contributing since {{ displayFirstContribution() }} -
-
- @for (repository of userData.repositories; track repository) { - -
- -
- {{ repository }} -
- } -
-
-
-
-
-

Latest Activity

- - @if (userData.activity) { - @for (activity of userData.activity; track activity.id) { -
- -
- } - } @else { -
- - No activity found -
- } -
-
-
-

Open Pullrequests

- - @if (userData.pullRequests) { - @for (pullRequest of userData.pullRequests; track pullRequest.id) { - @if (pullRequest.number && pullRequest.title && pullRequest.state && userData.activity && pullRequest.createdAt) { -
- -
- } - } - } @else { -
- - No open pullrequests found -
- } -
-
-
- } - } -
diff --git a/webapp/src/app/home/profile/profile.component.ts b/webapp/src/app/home/profile/profile.component.ts deleted file mode 100644 index b93fcc5c..00000000 --- a/webapp/src/app/home/profile/profile.component.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Component, computed, inject } from '@angular/core'; -import { NgIconComponent } from '@ng-icons/core'; -import { octClockFill } from '@ng-icons/octicons'; -import { UserService } from 'app/core/modules/openapi'; -import { TableBodyDirective } from 'app/ui/table/table-body.directive'; -import { TableCaptionDirective } from 'app/ui/table/table-caption.directive'; -import { TableCellDirective } from 'app/ui/table/table-cell.directive'; -import { TableFooterDirective } from 'app/ui/table/table-footer.directive'; -import { TableHeadDirective } from 'app/ui/table/table-head.directive'; -import { TableHeaderDirective } from 'app/ui/table/table-header.directive'; -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 { 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 { ProfileActivityCardComponent } from '../../core/profile-activity-card/profile-activity-card.component'; -import { IssueCardComponent } from '../../core/issue-card/issue-card.component'; -import { combineLatest, lastValueFrom, map, timer } from 'rxjs'; -import { CircleX, LucideAngularModule, Info } from 'lucide-angular'; -import dayjs from 'dayjs'; -import advancedFormat from 'dayjs/plugin/advancedFormat'; - -dayjs.extend(advancedFormat); - -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-profile', - standalone: true, - imports: [ - LucideAngularModule, - NgIconComponent, - ProfileActivityCardComponent, - IssueCardComponent, - HlmAvatarModule, - HlmSkeletonModule, - TableComponent, - TableBodyDirective, - TableCaptionDirective, - TableCellDirective, - TableFooterDirective, - TableHeaderDirective, - TableHeadDirective, - TableRowDirective, - HlmIconModule, - HlmTooltipComponent, - HlmTooltipTriggerDirective, - BrnTooltipContentDirective, - HlmButtonModule, - HlmScrollAreaComponent - ], - templateUrl: './profile.component.html' -}) -export class ProfileComponent { - userService = inject(UserService); - - protected octClockFill = octClockFill; - protected CircleX = CircleX; - protected Info = Info; - // get user id from the url - protected userLogin: string | null = null; - - displayFirstContribution = computed(() => { - if (this.query.data()) { - return dayjs(this.query.data()?.firstContribution).format('Do [of] MMMM YYYY'); - } - return ''; - }); - - constructor(private route: ActivatedRoute) { - this.userLogin = this.route.snapshot.paramMap.get('id'); - } - - getRepositoryImage = (name: string) => repoImages[name.split('/')[1]] || 'https://avatars.githubusercontent.com/u/11064260?v=4'; - - query = injectQuery(() => ({ - queryKey: ['user', { id: this.userLogin }], - queryFn: async () => lastValueFrom(combineLatest([this.userService.getUserProfile(this.userLogin ?? 'testuser'), timer(400)]).pipe(map(([user]) => user))) - })); -} 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..9609ac9a --- /dev/null +++ b/webapp/src/app/user/header/header.component.html @@ -0,0 +1,49 @@ +
+ @if (isLoading()) { + + + + + } @else { + + + + {{ userData()?.login?.slice(0, 2)?.toUpperCase() }} + + + } +
+ @if (isLoading()) { + + + } @else { +

{{ 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..b7d62691 --- /dev/null +++ b/webapp/src/app/user/issue-card/issue-card.component.html @@ -0,0 +1,54 @@ +
+
+
+ + @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/core/issue-card/issue-card.component.scss b/webapp/src/app/user/issue-card/issue-card.component.scss similarity index 100% rename from webapp/src/app/core/issue-card/issue-card.component.scss rename to webapp/src/app/user/issue-card/issue-card.component.scss diff --git a/webapp/src/app/core/issue-card/issue-card.component.ts b/webapp/src/app/user/issue-card/issue-card.component.ts similarity index 66% rename from webapp/src/app/core/issue-card/issue-card.component.ts rename to webapp/src/app/user/issue-card/issue-card.component.ts index 34c64d70..abb79719 100644 --- a/webapp/src/app/core/issue-card/issue-card.component.ts +++ b/webapp/src/app/user/issue-card/issue-card.component.ts @@ -1,29 +1,31 @@ import { Component, computed, input } from '@angular/core'; -import { PullRequest, PullRequestLabel, PullRequestReviewDTO } from '@app/core/modules/openapi'; +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], + imports: [NgIcon, HlmCardModule, HlmSkeletonComponent], styleUrls: ['./issue-card.component.scss'], standalone: true }) export class IssueCardComponent { + isLoading = input(false); class = input(''); - title = input.required(); - number = input.required(); - additions = input.required(); - deletions = input.required(); - url = input.required(); - repositoryName = input.required(); - reviews = input>(); - createdAt = input.required(); - state = input.required(); - pullRequestLabels = input.required | undefined>(); + title = input(); + number = input(); + additions = input(); + deletions = input(); + url = input(); + repositoryName = input(); + createdAt = input(); + state = input(); + pullRequestLabels = input>(); protected readonly octCheck = octCheck; protected readonly octX = octX; protected readonly octComment = octComment; @@ -32,16 +34,8 @@ export class IssueCardComponent { protected readonly octGitPullRequestClosed = octGitPullRequestClosed; displayCreated = computed(() => dayjs(this.createdAt())); - computedClass = computed(() => cn('border border-border bg-card rounded-lg p-4 w-72', this.class())); - - getMostRecentReview() { - if (!this.reviews()) { - return null; - } - return Array.from(this.reviews()!).reduce((latest, review) => { - return new Date(review.updatedAt || 0) > new Date(latest.updatedAt || 0) ? review : latest; - }); - } + displayTitle = computed(() => (this.title() ?? '').replace(/`([^`]+)`/g, '$1')); + computedClass = computed(() => cn('w-72', this.class())); hexToRgb(hex: string) { const bigint = parseInt(hex, 16); diff --git a/webapp/src/app/core/issue-card/issue-card.stories.ts b/webapp/src/app/user/issue-card/issue-card.stories.ts similarity index 62% rename from webapp/src/app/core/issue-card/issue-card.stories.ts rename to webapp/src/app/user/issue-card/issue-card.stories.ts index c801e2d2..f6e223e0 100644 --- a/webapp/src/app/core/issue-card/issue-card.stories.ts +++ b/webapp/src/app/user/issue-card/issue-card.stories.ts @@ -1,6 +1,5 @@ import { Meta, StoryObj } from '@storybook/angular'; import { IssueCardComponent } from './issue-card.component'; -import { PullRequestReviewDTO } from '../modules/openapi'; const meta: Meta = { component: IssueCardComponent, @@ -24,22 +23,6 @@ export const Default: Story = { pullRequestLabels: new Set([ { name: 'bug', color: 'f00000' }, { name: 'enhancement', color: '008000' } - ]), - reviews: new Set([ - { - id: 0, - createdAt: 'Jan 2', - updatedAt: 'Jan 2', - submittedAt: 'Jan 2', - state: 'APPROVED' - }, - { - id: 1, - createdAt: 'Jan 4', - updatedAt: 'Jan 4', - submittedAt: 'Jan 4', - state: 'CHANGES_REQUESTED' - } ]) } }; 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..edf61e11 --- /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..60ec6b73 --- /dev/null +++ b/webapp/src/app/user/review-activity-card/review-activity-card.component.ts @@ -0,0 +1,78 @@ +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); + 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..67da5a2d --- /dev/null +++ b/webapp/src/app/user/user-profile.component.html @@ -0,0 +1,68 @@ +
+ @if (query.error()) { +
+
+ +

Something went wrong...

+

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

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

Latest 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 3b8f6445..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 @@ -10,7 +10,7 @@ export const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounde small: 'h-6 w-6 text-xs', medium: 'h-10 w-10', large: 'h-14 w-14 text-lg', - extralarge: 'h-40 w-40 text-3xl' + 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; + } }