diff --git a/angular-ngrx-scss/projects/prism/src/lib/types/model.ts b/angular-ngrx-scss/projects/prism/src/lib/types/model.ts index ab1342b35..0d1d6f4ee 100644 --- a/angular-ngrx-scss/projects/prism/src/lib/types/model.ts +++ b/angular-ngrx-scss/projects/prism/src/lib/types/model.ts @@ -14,7 +14,7 @@ export type TokenEnv = { tokens: Array; }; -export type StyleObj = { [klass: string]: any } | null; +export type StyleObj = { [klass: string]: unknown } | null; export type LineInputProps = { style?: StyleObj; diff --git a/angular-ngrx-scss/projects/prism/src/lib/utils/normalize-tokens.ts b/angular-ngrx-scss/projects/prism/src/lib/utils/normalize-tokens.ts index ef3954c7f..b521c1a51 100644 --- a/angular-ngrx-scss/projects/prism/src/lib/utils/normalize-tokens.ts +++ b/angular-ngrx-scss/projects/prism/src/lib/utils/normalize-tokens.ts @@ -40,7 +40,7 @@ const normalizeTokens = (tokens: Array): Token[][] => { let i = 0; let stackIndex = 0; - let currentLine: any[] = []; + let currentLine: Token[] = []; const acc = [currentLine]; diff --git a/angular-ngrx-scss/src/app/file-viewer/file-explorer/file-explorer.component.html b/angular-ngrx-scss/src/app/file-viewer/file-explorer/file-explorer.component.html index c2156eb09..199bad9d2 100644 --- a/angular-ngrx-scss/src/app/file-viewer/file-explorer/file-explorer.component.html +++ b/angular-ngrx-scss/src/app/file-viewer/file-explorer/file-explorer.component.html @@ -2,18 +2,18 @@
diff --git a/angular-ngrx-scss/src/app/file-viewer/file-explorer/file-explorer.component.ts b/angular-ngrx-scss/src/app/file-viewer/file-explorer/file-explorer.component.ts index 05da3b823..0b510ddb2 100644 --- a/angular-ngrx-scss/src/app/file-viewer/file-explorer/file-explorer.component.ts +++ b/angular-ngrx-scss/src/app/file-viewer/file-explorer/file-explorer.component.ts @@ -2,8 +2,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { Store } from '@ngrx/store'; import { ActivatedRoute } from '@angular/router'; import { - fetchRepository, RepoContents, + fetchRepository, selectedRepository, } from '../../state/repository'; import { map, takeWhile, tap } from 'rxjs'; diff --git a/angular-ngrx-scss/src/app/fixtures/repository.fixtures.ts b/angular-ngrx-scss/src/app/fixtures/repository.fixtures.ts index 5f64057a1..c5142a958 100644 --- a/angular-ngrx-scss/src/app/fixtures/repository.fixtures.ts +++ b/angular-ngrx-scss/src/app/fixtures/repository.fixtures.ts @@ -1,16 +1,16 @@ +import { HttpHeaders, HttpResponse } from '@angular/common/http'; +import { PullRequest } from '../repository/services/repository.interfaces'; import { ISSUE_STATE, PullRequestAPIResponse, - PullRequestItemAPIResponse, IssueLabel, - RepoPullRequests, } from '../state/repository'; export const generatePullRequestAPIResponseFixture = ( state: ISSUE_STATE = 'open', -): PullRequestAPIResponse => { +): HttpResponse => { const closedDate = new Date(2022, 2, 1).toISOString(); - return { + const body = { incomplete_results: false, total_count: 1, items: [ @@ -37,30 +37,18 @@ export const generatePullRequestAPIResponseFixture = ( } as IssueLabel, ], comments: 305, - } as PullRequestItemAPIResponse, + } as unknown as PullRequest, ], }; -}; - -const prObject = generatePullRequestAPIResponseFixture().items[0]; -export const pullRequestFixture: RepoPullRequests = { - totalCount: 1, - pullRequests: [ - { - id: prObject.id, - login: prObject.user.login, - title: prObject.title, - number: prObject.number, - state: prObject.state, - closedAt: prObject.closed_at ? new Date(prObject.closed_at) : null, - mergedAt: prObject.pull_request.merged_at - ? new Date(prObject.pull_request.merged_at) - : null, - createdAt: new Date(prObject.created_at), - labels: prObject.labels, - commentCount: prObject.comments, - labelCount: prObject.labels.length, - }, - ], + return { + headers: new HttpHeaders(), + status: 200, + statusText: 'OK', + ok: true, + type: 4, + url: 'https://api.github.com/search/issues?q=repo:thisdot/open-source/issues+type:pr+state:open', + clone: jasmine.createSpy('clone'), + body, + }; }; diff --git a/angular-ngrx-scss/src/app/issues/components/issues-header/issues-header.component.html b/angular-ngrx-scss/src/app/issues/components/issues-header/issues-header.component.html index 8aed36979..af309324a 100644 --- a/angular-ngrx-scss/src/app/issues/components/issues-header/issues-header.component.html +++ b/angular-ngrx-scss/src/app/issues/components/issues-header/issues-header.component.html @@ -1,3 +1,9 @@ +
+ Clear current search query, filters, and sorts +
+
@@ -22,11 +28,18 @@ name="Label" description="Select label" [isRepo]="true" + [toggle]="true" + [items]="(labels$ | async) ?? []" + (setFilter)="setLabel($event)" + [current]="filterParams.labels" >
diff --git a/angular-ngrx-scss/src/app/pull-requests/components/pull-requests-header/pull-requests-header.component.scss b/angular-ngrx-scss/src/app/pull-requests/components/pull-requests-header/pull-requests-header.component.scss index 50f04361a..1e7c1ce20 100644 --- a/angular-ngrx-scss/src/app/pull-requests/components/pull-requests-header/pull-requests-header.component.scss +++ b/angular-ngrx-scss/src/app/pull-requests/components/pull-requests-header/pull-requests-header.component.scss @@ -7,6 +7,9 @@ flex-direction: column; padding: variables.$padding--l; background-color: variables.$gray100; + margin-top: functions.rem(14); + border-top-left-radius: variables.$padding; + border-top-right-radius: variables.$padding; @media (min-width: variables.$md) { flex-direction: row; diff --git a/angular-ngrx-scss/src/app/pull-requests/components/pull-requests-header/pull-requests-header.component.ts b/angular-ngrx-scss/src/app/pull-requests/components/pull-requests-header/pull-requests-header.component.ts index b796adc4d..fdf0dc4dd 100644 --- a/angular-ngrx-scss/src/app/pull-requests/components/pull-requests-header/pull-requests-header.component.ts +++ b/angular-ngrx-scss/src/app/pull-requests/components/pull-requests-header/pull-requests-header.component.ts @@ -5,7 +5,18 @@ import { Input, Output, } from '@angular/core'; -import { ISSUE_STATE, RepoPullRequests } from '../../../state/repository'; +import { + ISSUE_STATE, + RepoPullRequests, + fetchPullRequests, + selectHasActivePullRequestFilters, + selectLabels, +} from '../../../state/repository'; +import { Store } from '@ngrx/store'; +import { map } from 'rxjs'; +import { FilterOption } from 'src/app/shared/components/filter-dropdown/filter-dropdown.component'; +import { SORTING_OPTIONS } from 'src/app/shared/constants'; +import { Sort } from 'src/app/repository/services/repository.interfaces'; @Component({ selector: 'app-pull-requests-header', @@ -14,13 +25,65 @@ import { ISSUE_STATE, RepoPullRequests } from '../../../state/repository'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class PullRequestsHeaderComponent { + @Input() owner!: string; + @Input() repoName!: string; @Input() openPullRequests!: RepoPullRequests | null; @Input() closedPullRequests!: RepoPullRequests | null; @Input() viewState: ISSUE_STATE = 'open'; @Output() viewStateChange = new EventEmitter(); + hasFilters$ = this.store.select(selectHasActivePullRequestFilters); + + filterParams: { labels?: string; sort: Sort } = { + sort: 'created', + }; + + sortOptions: FilterOption[] = SORTING_OPTIONS; + + labels$ = this.store + .select(selectLabels) + .pipe( + map((labels) => + labels.map((label) => ({ label: label.name, value: label.name })), + ), + ); + + constructor(private store: Store) {} + changeViewState(state: ISSUE_STATE) { this.viewState = state; this.viewStateChange.emit(this.viewState); } + + setLabel(label: string) { + this.filterParams.labels = label; + this.refetchPulls(); + } + + setSort(sort: string) { + this.filterParams.sort = sort as Sort; + this.refetchPulls(); + } + + clearFilters() { + this.filterParams = { sort: 'created' }; + this.refetchPulls(); + } + + private refetchPulls() { + this.store.dispatch( + fetchPullRequests({ + owner: this.owner, + repoName: this.repoName, + params: { state: 'open', ...this.filterParams }, + }), + ); + this.store.dispatch( + fetchPullRequests({ + owner: this.owner, + repoName: this.repoName, + params: { state: 'closed', ...this.filterParams }, + }), + ); + } } diff --git a/angular-ngrx-scss/src/app/pull-requests/components/pull-requests-list/pull-requests-list.component.spec.ts b/angular-ngrx-scss/src/app/pull-requests/components/pull-requests-list/pull-requests-list.component.spec.ts index bc167db55..67eb251b1 100644 --- a/angular-ngrx-scss/src/app/pull-requests/components/pull-requests-list/pull-requests-list.component.spec.ts +++ b/angular-ngrx-scss/src/app/pull-requests/components/pull-requests-list/pull-requests-list.component.spec.ts @@ -2,8 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PullRequestsListComponent } from './pull-requests-list.component'; import { ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { RepoPullRequest } from '../../../state/repository'; import { By } from '@angular/platform-browser'; +import { PullRequest } from 'src/app/repository/services/repository.interfaces'; describe('PullRequestsListComponent', () => { let component: PullRequestsListComponent; @@ -42,15 +42,20 @@ describe('PullRequestsListComponent', () => { it('should render pull request cards', () => { component.pullRequests = { - totalCount: 1, + total: 1, + paginationParams: { + page: 1, + canNext: false, + canPrev: false, + }, pullRequests: [ { id: 1, - login: 'thisdot', + user: { login: 'thisdot' }, title: 'Get PRs information', number: 45, state: 'open', - createdAt: new Date('01/01/2021'), + created_at: new Date('01/01/2021'), labels: [ { id: 2, @@ -61,7 +66,7 @@ describe('PullRequestsListComponent', () => { }, ], commentCount: 3, - } as RepoPullRequest, + } as unknown as PullRequest, ], }; fixture.detectChanges(); diff --git a/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.html b/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.html index f914b2b70..2a912c85a 100644 --- a/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.html +++ b/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.html @@ -1,9 +1,12 @@ +
-
- + diff --git a/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.scss b/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.scss index 95b7416b4..bd966c2a3 100644 --- a/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.scss +++ b/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.scss @@ -2,7 +2,8 @@ @use 'functions'; .pull-requests-container { - border-radius: variables.$padding; + border-bottom-left-radius: variables.$padding; + border-bottom-right-radius: variables.$padding; border: 1px solid variables.$gray200; - margin: functions.rem(28) auto; + margin-bottom: functions.rem(14); } diff --git a/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.spec.ts b/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.spec.ts index d9dc12c65..02c54b7e8 100644 --- a/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.spec.ts +++ b/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.spec.ts @@ -68,9 +68,11 @@ describe('PullRequestsComponent', () => { store.scannedActions$.subscribe((action) => { expect(action).toEqual( fetchPullRequests({ - prState: 'closed', owner: 'thisdot', repoName: 'starter.dev-github-showcases', + params: { + state: 'closed', + }, }), ); done(); diff --git a/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.ts b/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.ts index 8996d91a1..69ec631e4 100644 --- a/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.ts +++ b/angular-ngrx-scss/src/app/pull-requests/pull-requests.component.ts @@ -5,9 +5,10 @@ import { fetchPullRequests, ISSUE_STATE, selectClosedPullRequests, + selectClosedPullRequestsPaginationParams, selectOpenPullRequests, + selectOpenPullRequestsPaginationParams, } from '../state/repository'; - @Component({ selector: 'app-pull-requests', templateUrl: './pull-requests.component.html', @@ -20,6 +21,14 @@ export class PullRequestsComponent implements OnInit { closedPullRequests$ = this.store.select(selectClosedPullRequests); viewState: ISSUE_STATE = 'open'; + openPullRequestsPaginationParams$ = this.store.select( + selectOpenPullRequestsPaginationParams, + ); + + closedPullRequestsPaginationParams$ = this.store.select( + selectClosedPullRequestsPaginationParams, + ); + constructor(private route: ActivatedRoute, private store: Store) {} ngOnInit() { @@ -30,7 +39,9 @@ export class PullRequestsComponent implements OnInit { fetchPullRequests({ owner: this.owner, repoName: this.repoName, - prState: 'open', + params: { + state: 'open', + }, }), ); @@ -38,8 +49,24 @@ export class PullRequestsComponent implements OnInit { fetchPullRequests({ owner: this.owner, repoName: this.repoName, - prState: 'closed', + params: { + state: 'closed', + }, }), ); } + + pageChange(page: number) { + this.store.dispatch( + fetchPullRequests({ + owner: this.owner, + repoName: this.repoName, + params: { state: this.viewState, page }, + }), + ); + } + + viewStateChange(viewState: ISSUE_STATE) { + this.viewState = viewState; + } } diff --git a/angular-ngrx-scss/src/app/repository/components/repo-header/repo-navigation/repo-navigation.component.html b/angular-ngrx-scss/src/app/repository/components/repo-header/repo-navigation/repo-navigation.component.html index 503b3a566..2b9674993 100644 --- a/angular-ngrx-scss/src/app/repository/components/repo-header/repo-navigation/repo-navigation.component.html +++ b/angular-ngrx-scss/src/app/repository/components/repo-header/repo-navigation/repo-navigation.component.html @@ -24,7 +24,7 @@
  • (); + + constructor(private route: ActivatedRoute, private store: Store) {} + + ngOnInit() { + this.route.paramMap + .pipe( + takeUntil(this.destroyed$), + tap((params) => { + const owner = params.get('owner') as string; + const repoName = params.get('repo') as string; + const branch = params.get('branch') as string; + const path = params.get('path') as string; + + this.store.dispatch( + fetchRepository({ + owner: owner, + repoName: repoName, + path: path, + branch: branch, + }), + ); + }), + ) + .subscribe(); + } + + ngOnDestroy() { + this.destroyed$.next(); + this.destroyed$.complete(); + } +} diff --git a/angular-ngrx-scss/src/app/repository/repository-routing.module.ts b/angular-ngrx-scss/src/app/repository/repository-routing.module.ts index 8e3532983..e818debbd 100644 --- a/angular-ngrx-scss/src/app/repository/repository-routing.module.ts +++ b/angular-ngrx-scss/src/app/repository/repository-routing.module.ts @@ -25,7 +25,7 @@ const routes: Routes = [ import('../issues/issues.module').then((m) => m.IssuesModule), }, { - path: 'pull-requests', + path: 'pulls', loadChildren: () => import('../pull-requests/pull-requests.module').then( (m) => m.PullRequestsModule, diff --git a/angular-ngrx-scss/src/app/repository/services/repository.interfaces.ts b/angular-ngrx-scss/src/app/repository/services/repository.interfaces.ts index 6505c7a64..b1e93a13d 100644 --- a/angular-ngrx-scss/src/app/repository/services/repository.interfaces.ts +++ b/angular-ngrx-scss/src/app/repository/services/repository.interfaces.ts @@ -54,12 +54,17 @@ export interface RepositoryIssuesApiParams { page?: number; } -export interface PullRequest { - title: string; - number: number; - user: User; - closed_at?: string; - created_at: string; +export interface PullRequest extends Issue { + merged: boolean; + mergeable: boolean; + merged_by: User; + merged_at: string; + merge_commit_sha: string; + comments: number; + commits: number; + additions: number; + deletions: number; + changed_files: number; } export type PullRequests = Array; diff --git a/angular-ngrx-scss/src/app/repository/services/repository.service.spec.ts b/angular-ngrx-scss/src/app/repository/services/repository.service.spec.ts index 8d83617ad..79495bdc8 100644 --- a/angular-ngrx-scss/src/app/repository/services/repository.service.spec.ts +++ b/angular-ngrx-scss/src/app/repository/services/repository.service.spec.ts @@ -11,12 +11,9 @@ import { RepoApiResponse, RepoContentsApiResponse, RepoIssues, + RepoPullRequests, } from 'src/app/state/repository'; -import { - IssueComments, - PullRequest, - PullRequests, -} from './repository.interfaces'; +import { IssueComments, PullRequest } from './repository.interfaces'; import { generatePullRequestAPIResponseFixture } from '../../fixtures/repository.fixtures'; import { RepositoryService } from './repository.service'; @@ -192,7 +189,7 @@ const MOCK_PULL_REQUEST_COMMENTS: IssueComments = [ }, ]; -const MOCK_PULL_REQUEST: PullRequest = { +const MOCK_PULL_REQUEST = { title: 'Et quis culpa ex sapiente dolores qui quo qui.', number: MOCK_PULL_REQUEST_NUMBER, user: { @@ -204,9 +201,9 @@ const MOCK_PULL_REQUEST: PullRequest = { }, closed_at: '2022-07-01T23:46:12Z', created_at: '2022-07-01T23:46:12Z', -}; +} as PullRequest; -const MOCK_PULL_REQUESTS: PullRequests = [ +const MOCK_PULL_REQUESTS = [ { title: 'Et quis culpa ex sapiente dolores qui quo qui.', number: MOCK_PULL_REQUEST_NUMBER, @@ -233,7 +230,32 @@ const MOCK_PULL_REQUESTS: PullRequests = [ closed_at: '2022-07-02T23:46:12Z', created_at: '2022-07-02T23:46:12Z', }, -]; +] as PullRequest[]; + +const EXPECTED_PULL_REQUESTS: RepoPullRequests = { + total: 3, + paginationParams: { + page: 1, + canNext: false, + canPrev: false, + }, + pullRequests: MOCK_PULL_REQUESTS, +}; + +const MOCK_PULL_REQUESTS_RESPONSE: HttpResponse = { + headers: new HttpHeaders(), + clone: jasmine.createSpy('clone'), + type: HttpEventType.Response, + status: 200, + statusText: 'OK', + ok: true, + url: 'http://localhost', + body: { + total_count: 3, + incomplete_results: false, + items: MOCK_PULL_REQUESTS, + }, +}; describe('RepositoryService', () => { let repoService: RepositoryService; @@ -381,23 +403,25 @@ describe('RepositoryService', () => { }); it('should return multiple pull requests for a given repository', (done) => { - httpClientSpy.get.and.returnValue(of(MOCK_PULL_REQUESTS)); + httpClientSpy.get.and.returnValue(of(MOCK_PULL_REQUESTS_RESPONSE)); - repoService.getRepositoryPullRequests('FakeCo', 'fake-repo').subscribe({ - next: (pullRequests) => { - expect(pullRequests).toBe(MOCK_PULL_REQUESTS); + repoService + .getRepositoryPullRequests('FakeCo', 'fake-repo', { state: 'all' }) + .subscribe({ + next: (pullRequests) => { + expect(pullRequests).toEqual(EXPECTED_PULL_REQUESTS); - expect(httpClientSpy.get).toHaveBeenCalledWith( - `https://api.github.com/repos/FakeCo/fake-repo/pulls`, - jasmine.objectContaining({ - headers: { - Accept: 'application/vnd.github.v3+json', - }, - }), - ); - }, - complete: done, - }); + expect(httpClientSpy.get).toHaveBeenCalledWith( + `https://api.github.com/search/issues?q=repo:FakeCo/fake-repo+type:pr+state:all`, + jasmine.objectContaining({ + headers: { + Accept: 'application/vnd.github.v3+json', + }, + }), + ); + }, + complete: done, + }); }); it('should return pull request comments when given a pull request number', (done) => { @@ -468,15 +492,15 @@ describe('RepositoryService', () => { }); }); - describe('getPullRequests', () => { + describe('getRepositoryPullRequests', () => { it('should return pull request for given repository', (done) => { - const apiResponse: PullRequestAPIResponse = + const apiResponse: HttpResponse = generatePullRequestAPIResponseFixture(); httpClientSpy.get.and.returnValue(of(apiResponse).pipe(delay(0))); repoService - .getPullRequests('thisdot', 'starter.dev-github-showcases', 'open') - .subscribe((res) => { + .getRepositoryPullRequest('thisdot', 'starter.dev-github-showcases', 1) + .subscribe((res: unknown) => { expect(res).toEqual(apiResponse); done(); }); @@ -485,7 +509,7 @@ describe('RepositoryService', () => { .withContext('called once') .toBe(1); expect(httpClientSpy.get).toHaveBeenCalledOnceWith( - 'https://api.github.com/search/issues?q=repo:thisdot/starter.dev-github-showcases+type:pr+state:open', + 'https://api.github.com/repos/thisdot/starter.dev-github-showcases/pulls/1', jasmine.objectContaining({ headers: { Accept: 'application/vnd.github.v3+json', diff --git a/angular-ngrx-scss/src/app/repository/services/repository.service.ts b/angular-ngrx-scss/src/app/repository/services/repository.service.ts index 1ae21edc5..8121e021d 100644 --- a/angular-ngrx-scss/src/app/repository/services/repository.service.ts +++ b/angular-ngrx-scss/src/app/repository/services/repository.service.ts @@ -1,17 +1,18 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable, map } from 'rxjs'; import { FileContentsApiResponse, - ISSUE_STATE, IssueAPIResponse, IssueLabel, Milestone, + PaginationParams, PullRequestAPIResponse, ReadmeApiResponse, RepoApiResponse, RepoContentsApiResponse, RepoIssues, + RepoPullRequests, } from 'src/app/state/repository'; import { environment } from 'src/environments/environment'; import { @@ -49,6 +50,34 @@ export class RepositoryService { }); } + getRepositoryPullRequestsCount( + repoOwner: string, + repoName: string, + ): Observable { + const owner = encodeURIComponent(repoOwner); + const name = encodeURIComponent(repoName); + const url = `${environment.githubUrl}/repos/${owner}/${name}/pulls`; + + return this.http + .get(url, { + observe: 'response', + headers: { + Accept: 'application/vnd.github.v3+json', + }, + params: new HttpParams({ + fromObject: { + state: 'open', + per_page: 1, + }, + }), + }) + .pipe( + map((response) => + this.extractTotalFromLinkHeader(response.headers.get('Link')), + ), + ); + } + /** * Gets a list of all the pull requests for the specified repository * @param repoOwner who the repo belongs to @@ -58,16 +87,40 @@ export class RepositoryService { getRepositoryPullRequests( repoOwner: string, repoName: string, - ): Observable { + params: RepositoryIssuesApiParams, + ): Observable { const owner = encodeURIComponent(repoOwner); const name = encodeURIComponent(repoName); - const url = `${environment.githubUrl}/repos/${owner}/${name}/pulls`; + const state = encodeURIComponent(params.state); + let url = `${environment.githubUrl}/search/issues?q=repo:${owner}/${name}+type:pr+state:${state}`; - return this.http.get(url, { - headers: { - Accept: 'application/vnd.github.v3+json', - }, - }); + url = this.appendUrlParams(url, params); + + return this.http + .get(url, { + observe: 'response', + headers: { + Accept: 'application/vnd.github.v3+json', + }, + }) + .pipe( + map((response) => { + const data = response.body as PullRequestAPIResponse; + + const total = data.total_count; + + const page = params?.page ?? 1; + + const paginationParams = this.getPaginationParams( + response.headers, + page, + ); + + const pullRequests: PullRequest[] = data.items; + + return { pullRequests, paginationParams, total } as RepoPullRequests; + }), + ); } /** @@ -160,31 +213,6 @@ export class RepositoryService { }); } - /** - * NOTE: This call uses the search URL to find the information, and is a bit of a duplicate of other calls that use the repo URL. Both work fine and are provided currently. - * Gets a list of pull requests matching the provided state - * @param repoOwner who the repo belongs to - * @param repoName name of the repo - * @param prState if the pr is open or closed - * @returns the total count of state-matching pull requests and information for each of those pulls - */ - getPullRequests( - repoOwner: string, - repoName: string, - prState: ISSUE_STATE, - ): Observable { - const owner = encodeURIComponent(repoOwner); - const name = encodeURIComponent(repoName); - const state = encodeURIComponent(prState); - const url = `${environment.githubUrl}/search/issues?q=repo:${owner}/${name}+type:pr+state:${state}`; - - return this.http.get(url, { - headers: { - Accept: 'application/vnd.github.v3+json', - }, - }); - } - /** * Get a list of issues for the specified repository * @param owner who the repo belongs to @@ -211,21 +239,7 @@ export class RepositoryService { const state = encodeURIComponent(params?.state ?? defaultParams.state); let url = `${environment.githubUrl}/search/issues?q=repo:${owner}/${name}+type:issue+state:${state}`; - if (params?.labels) { - url += `+label:"${params.labels}"`; - } - - if (params?.milestone) { - url += `+milestone:"${params.milestone}"`; - } - - if (params?.sort) { - url += `+sort:${params.sort}`; - } - - if (params?.page) { - url += `&page=${params.page}`; - } + url = this.appendUrlParams(url, params); return this.http .get(url, { @@ -236,22 +250,16 @@ export class RepositoryService { }) .pipe( map((response) => { - const linkHeader = response.headers.get('Link'); - - const canNext = !!(linkHeader && linkHeader.includes('rel="next"')); - const canPrev = !!(linkHeader && linkHeader.includes('rel="prev"')); - const data = response.body as IssueAPIResponse; const total = data.total_count; const page = params?.page || 1; - const paginationParams = { - canNext, - canPrev, + const paginationParams = this.getPaginationParams( + response.headers, page, - }; + ); const issues: Issue[] = data.items; @@ -345,4 +353,73 @@ export class RepositoryService { }, }); } + + private extractTotalFromLinkHeader(linkHeader: string | null): number { + if (!linkHeader) { + return 0; + } + + // Split the linkHeader by commas to separate individual links + const links = linkHeader.split(','); + + // Find the last link in the header + const lastLink = links + .find((link) => link.includes('rel="last"')) + ?.split(';')[0] + ?.trim() + .slice(1, -1); + + if (!lastLink) { + return 0; + } + + const url = new URL(lastLink); + + const queryParams = new URLSearchParams(url.search); + const page = parseInt(queryParams.get('page') ?? '', 10); + + if (isNaN(page)) { + return 0; + } + + return page; + } + + private appendUrlParams( + url: string, + params?: RepositoryIssuesApiParams, + ): string { + if (params?.labels) { + url += `+label:"${params.labels}"`; + } + + if (params?.milestone) { + url += `+milestone:"${params.milestone}"`; + } + + if (params?.sort) { + url += `+sort:${params.sort}`; + } + + if (params?.page) { + url += `&page=${params.page}`; + } + return url; + } + + private getPaginationParams( + headers: HttpHeaders, + page: number, + ): PaginationParams { + const linkHeader = headers.get('Link'); + + const canNext = !!(linkHeader && linkHeader.includes('rel="next"')); + const canPrev = !!(linkHeader && linkHeader.includes('rel="prev"')); + + return { + canNext, + canPrev, + page, + }; + } } diff --git a/angular-ngrx-scss/src/app/shared/components/clear-filters-button/clear-filters-button.component.html b/angular-ngrx-scss/src/app/shared/components/clear-filters-button/clear-filters-button.component.html new file mode 100644 index 000000000..1219e5ca2 --- /dev/null +++ b/angular-ngrx-scss/src/app/shared/components/clear-filters-button/clear-filters-button.component.html @@ -0,0 +1,6 @@ + diff --git a/angular-ngrx-scss/src/app/shared/components/clear-filters-button/clear-filters-button.component.scss b/angular-ngrx-scss/src/app/shared/components/clear-filters-button/clear-filters-button.component.scss new file mode 100644 index 000000000..937c6e446 --- /dev/null +++ b/angular-ngrx-scss/src/app/shared/components/clear-filters-button/clear-filters-button.component.scss @@ -0,0 +1,36 @@ +@use 'variables'; +@use 'functions'; + +.button { + background: none; + color: inherit; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + display: flex; + align-items: center; + gap: variables.$padding; + color: variables.$gray500; + + &:hover { + color: variables.$blue600; + } + + &__icon { + background: variables.$gray500; + border-radius: variables.$padding--s; + width: functions.rem(20); + height: functions.rem(20); + display: grid; + place-items: center; + } + + &:hover &__icon { + background: variables.$blue600; + } + + &__text:empty::after { + content: 'Clear filter'; + } +} diff --git a/angular-ngrx-scss/src/app/shared/components/clear-filters-button/clear-filters-button.component.ts b/angular-ngrx-scss/src/app/shared/components/clear-filters-button/clear-filters-button.component.ts new file mode 100644 index 000000000..c0a5d4079 --- /dev/null +++ b/angular-ngrx-scss/src/app/shared/components/clear-filters-button/clear-filters-button.component.ts @@ -0,0 +1,10 @@ +import { Component, EventEmitter, Output } from '@angular/core'; + +@Component({ + selector: 'app-clear-filters-button', + templateUrl: './clear-filters-button.component.html', + styleUrls: ['./clear-filters-button.component.scss'], +}) +export class ClearFiltersButtonComponent { + @Output() clearFilters = new EventEmitter(); +} diff --git a/angular-ngrx-scss/src/app/shared/components/repo-controls/repo-controls.component.html b/angular-ngrx-scss/src/app/shared/components/repo-controls/repo-controls.component.html index 51169ea32..e28af6c92 100644 --- a/angular-ngrx-scss/src/app/shared/components/repo-controls/repo-controls.component.html +++ b/angular-ngrx-scss/src/app/shared/components/repo-controls/repo-controls.component.html @@ -53,12 +53,9 @@ {{ (selectSortFilter$ | async)?.split('_')?.join(' ') | lowercase }}

    - +
  • diff --git a/angular-ngrx-scss/src/app/shared/components/repo-controls/repo-controls.component.scss b/angular-ngrx-scss/src/app/shared/components/repo-controls/repo-controls.component.scss index bc82e92d0..50b04f3e0 100644 --- a/angular-ngrx-scss/src/app/shared/components/repo-controls/repo-controls.component.scss +++ b/angular-ngrx-scss/src/app/shared/components/repo-controls/repo-controls.component.scss @@ -57,31 +57,4 @@ section { &__text { flex-grow: 1; } - - &__clear { - background: none; - color: inherit; - border: none; - padding: 0; - font: inherit; - cursor: pointer; - display: flex; - align-items: center; - gap: variables.$padding; - color: variables.$gray500; - &:hover { - color: variables.$blue600; - } - &__icon { - background: variables.$gray500; - border-radius: variables.$padding--s; - width: functions.rem(20); - height: functions.rem(20); - display: grid; - place-items: center; - } - &:hover &__icon { - background: variables.$blue600; - } - } } diff --git a/angular-ngrx-scss/src/app/shared/shared.module.ts b/angular-ngrx-scss/src/app/shared/shared.module.ts index 207d738eb..d63285582 100644 --- a/angular-ngrx-scss/src/app/shared/shared.module.ts +++ b/angular-ngrx-scss/src/app/shared/shared.module.ts @@ -9,6 +9,7 @@ import { MarkdownPipe } from './pipes/markdown.pipe'; import { PaginationComponent } from './components/pagination/pagination.component'; import { RepoIssuePullCardComponent } from './components/repo-issue-pull-card/repo-issue-pull-card.component'; import { ClickAwayDirective } from './directives/click-away.directive'; +import { ClearFiltersButtonComponent } from './components/clear-filters-button/clear-filters-button.component'; @NgModule({ declarations: [ @@ -20,6 +21,7 @@ import { ClickAwayDirective } from './directives/click-away.directive'; PaginationComponent, RepoIssuePullCardComponent, ClickAwayDirective, + ClearFiltersButtonComponent, ], imports: [CommonModule, RouterModule], exports: [ @@ -31,6 +33,7 @@ import { ClickAwayDirective } from './directives/click-away.directive'; PaginationComponent, RepoIssuePullCardComponent, ClickAwayDirective, + ClearFiltersButtonComponent, ], }) export class SharedModule {} diff --git a/angular-ngrx-scss/src/app/state/auth/auth.effects.spec.ts b/angular-ngrx-scss/src/app/state/auth/auth.effects.spec.ts index 9cd66e0b7..b75da9dec 100644 --- a/angular-ngrx-scss/src/app/state/auth/auth.effects.spec.ts +++ b/angular-ngrx-scss/src/app/state/auth/auth.effects.spec.ts @@ -18,7 +18,6 @@ import { AuthEffects } from './auth.effects'; describe('AuthEffects', () => { let actions$: Actions; let effects: AuthEffects; - let store: MockStore; let mockHttpClient: jasmine.SpyObj; let authService: jasmine.SpyObj; @@ -56,8 +55,7 @@ describe('AuthEffects', () => { ], }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - store = TestBed.inject(MockStore); + TestBed.inject(MockStore); actions$ = TestBed.inject(Actions); effects = TestBed.inject(AuthEffects); }); diff --git a/angular-ngrx-scss/src/app/state/profile/profile.selectors.ts b/angular-ngrx-scss/src/app/state/profile/profile.selectors.ts index 739fbb7e1..a9642839f 100644 --- a/angular-ngrx-scss/src/app/state/profile/profile.selectors.ts +++ b/angular-ngrx-scss/src/app/state/profile/profile.selectors.ts @@ -43,7 +43,7 @@ export const isActiveFilterByType = createSelector( export const selectFilterByLanguage = createSelector( selectProfileState, - (state: ProfileState) => state.sortAndFilter?.language || TypeFilter.All, + (state: ProfileState) => state.sortAndFilter?.language ?? TypeFilter.All, ); export const isActiveFilterByLanguage = createSelector( @@ -53,7 +53,7 @@ export const isActiveFilterByLanguage = createSelector( export const selectSortFilter = createSelector( selectProfileState, - (state: ProfileState) => state.sortAndFilter?.sort || OrderField.UpdatedAt, + (state: ProfileState) => state.sortAndFilter?.sort ?? OrderField.UpdatedAt, ); export const hasActiveSortAndFilters = createSelector( diff --git a/angular-ngrx-scss/src/app/state/repository/repository.actions.ts b/angular-ngrx-scss/src/app/state/repository/repository.actions.ts index 4442b1742..45b46bda1 100644 --- a/angular-ngrx-scss/src/app/state/repository/repository.actions.ts +++ b/angular-ngrx-scss/src/app/state/repository/repository.actions.ts @@ -1,7 +1,6 @@ import { createAction, props } from '@ngrx/store'; import { FileContents, - ISSUE_STATE, RepoIssues, RepoPullRequests, RepositoryState, @@ -48,7 +47,7 @@ export const fetchPullRequests = createAction( props<{ owner: string; repoName: string; - prState: ISSUE_STATE; + params: RepositoryIssuesApiParams; }>(), ); @@ -56,7 +55,7 @@ export const fetchPullRequestsSuccess = createAction( '[Repository API] Fetch Pull Requests Success', props<{ pullRequests: RepoPullRequests; - prState: ISSUE_STATE; + params: RepositoryIssuesApiParams; }>(), ); diff --git a/angular-ngrx-scss/src/app/state/repository/repository.effects.spec.ts b/angular-ngrx-scss/src/app/state/repository/repository.effects.spec.ts index 65db7a0dd..864961f8c 100644 --- a/angular-ngrx-scss/src/app/state/repository/repository.effects.spec.ts +++ b/angular-ngrx-scss/src/app/state/repository/repository.effects.spec.ts @@ -16,6 +16,7 @@ import { ReadmeApiResponse, RepoApiResponse, RepoIssues, + RepoPullRequests, RepositoryState, } from './repository.state'; import { UserApiResponse } from '../user'; @@ -58,7 +59,17 @@ const MOCK_PULL_REQUESTS: PullRequests = [ closed_at: '2022-07-02T23:46:12Z', created_at: '2022-07-02T23:46:12Z', }, -]; +] as PullRequests; + +const EXPECTED_PULL_REQUESTS: RepoPullRequests = { + total: 3, + paginationParams: { + page: 1, + canNext: false, + canPrev: false, + }, + pullRequests: MOCK_PULL_REQUESTS, +}; const MOCK_REPO_INFO: RepoApiResponse = { id: 1, @@ -212,6 +223,7 @@ describe('RepositoryEffects', () => { 'getRepositoryIssues', 'getRepositoryMilestones', 'getRepositoryLabels', + 'getRepositoryPullRequestsCount', ]); TestBed.configureTestingModule({ providers: [ @@ -256,11 +268,14 @@ describe('RepositoryEffects', () => { closedIssues: null, labels: [], milestones: [], + path: '', + issuesFilterParams: null, + pullsFilterParams: null, }; repoServiceMock.getRepositoryInfo.and.returnValue(of(MOCK_REPO_INFO)); repoServiceMock.getRepositoryPullRequests.and.returnValue( - of(MOCK_PULL_REQUESTS), + of(EXPECTED_PULL_REQUESTS), ); repoServiceMock.getRepositoryContents.and.returnValue(of([])); repoServiceMock.getRepositoryReadme.and.returnValue(of(MOCK_README)); @@ -271,6 +286,8 @@ describe('RepositoryEffects', () => { repoServiceMock.getRepositoryLabels.and.returnValue(of([])); + repoServiceMock.getRepositoryPullRequestsCount.and.returnValue(of(2)); + effects.fetchRepository$.subscribe((action) => { expect(action).toEqual( fetchRepositorySuccess({ repoData: expectedResponseData }), diff --git a/angular-ngrx-scss/src/app/state/repository/repository.effects.ts b/angular-ngrx-scss/src/app/state/repository/repository.effects.ts index 87f5a245f..8015ce0e7 100644 --- a/angular-ngrx-scss/src/app/state/repository/repository.effects.ts +++ b/angular-ngrx-scss/src/app/state/repository/repository.effects.ts @@ -18,11 +18,7 @@ import { fetchRepositoryFailure, fetchRepositorySuccess, } from './repository.actions'; -import { - FileContents, - RepoPullRequests, - RepositoryState, -} from './repository.state'; +import { FileContents, RepositoryState } from './repository.state'; @Injectable() export class RepositoryEffects { @@ -31,10 +27,12 @@ export class RepositoryEffects { ofType(fetchRepository), switchMap(({ owner, repoName, path, branch }) => { const repoInfo$ = this.repoService.getRepositoryInfo(owner, repoName); - const repoPRList$ = this.repoService.getRepositoryPullRequests( + + const repoPRCount$ = this.repoService.getRepositoryPullRequestsCount( owner, repoName, ); + const repoContents$ = this.repoService.getRepositoryContents( owner, repoName, @@ -58,19 +56,20 @@ export class RepositoryEffects { return zip( repoInfo$, - repoPRList$, + repoPRCount$, repoContents$, repoReadme$, repoMilestones$, repoLabels$, ).pipe( - map(([info, prList, contents, readme, milestones, labels]) => { + map(([info, prCount, contents, readme, milestones, labels]) => { const allData: RepositoryState = { + path: path ?? '', description: info.description, forkCount: info.forks_count, issueCount: info.open_issues_count, ownerName: owner, - prCount: prList.length, + prCount: prCount, repoName: info.name, starCount: info.stargazers_count, tags: info.topics, @@ -87,6 +86,8 @@ export class RepositoryEffects { readme: readme.content || '', milestones: milestones || [], labels: labels || [], + pullsFilterParams: null, + issuesFilterParams: null, }; return fetchRepositorySuccess({ repoData: allData }); }), @@ -121,31 +122,15 @@ export class RepositoryEffects { fetchPullRequests$ = createEffect(() => { return this.actions$.pipe( ofType(fetchPullRequests), - mergeMap(({ owner, repoName, prState }) => { - return this.repoService.getPullRequests(owner, repoName, prState).pipe( - map((data) => { - const pullRequests: RepoPullRequests = { - totalCount: data.total_count, - pullRequests: data.items.map((item) => ({ - id: item.id, - login: item.user.login, - title: item.title, - number: item.number, - state: item.state, - closedAt: item.closed_at ? new Date(item.closed_at) : null, - mergedAt: item.pull_request.merged_at - ? new Date(item.pull_request.merged_at) - : null, - createdAt: new Date(item.created_at), - labels: item.labels, - commentCount: item.comments, - labelCount: item.labels.length, - })), - }; - return fetchPullRequestsSuccess({ pullRequests, prState }); - }), - catchError((error) => of(fetchPullRequestsFailure({ error }))), - ); + mergeMap(({ owner, repoName, params }) => { + return this.repoService + .getRepositoryPullRequests(owner, repoName, params) + .pipe( + map((pullRequests) => { + return fetchPullRequestsSuccess({ pullRequests, params }); + }), + catchError((error) => of(fetchPullRequestsFailure({ error }))), + ); }), ); }); diff --git a/angular-ngrx-scss/src/app/state/repository/repository.reducer.ts b/angular-ngrx-scss/src/app/state/repository/repository.reducer.ts index 95ca0260d..d65ffc7af 100644 --- a/angular-ngrx-scss/src/app/state/repository/repository.reducer.ts +++ b/angular-ngrx-scss/src/app/state/repository/repository.reducer.ts @@ -7,6 +7,7 @@ export const initialRepositoryState: RepositoryState = { forkCount: 0, issueCount: 0, ownerName: '', + path: '', prCount: 0, readme: '', repoName: '', @@ -24,6 +25,8 @@ export const initialRepositoryState: RepositoryState = { website: '', milestones: [], labels: [], + pullsFilterParams: null, + issuesFilterParams: null, }; const reducer = createReducer( @@ -39,13 +42,14 @@ const reducer = createReducer( // TODO: handle fetchFileError case on( RepositoryActions.fetchPullRequestsSuccess, - (state, { pullRequests, prState }) => { + (state, { pullRequests, params }) => { return { ...state, + pullsFilterParams: params, openPullRequests: - prState === 'open' ? pullRequests : state.openPullRequests, + params.state === 'open' ? pullRequests : state.openPullRequests, closedPullRequests: - prState === 'closed' ? pullRequests : state.closedPullRequests, + params.state === 'closed' ? pullRequests : state.closedPullRequests, }; }, ), @@ -53,6 +57,7 @@ const reducer = createReducer( on(RepositoryActions.fetchIssuesSuccess, (state, { issues, params }) => { return { ...state, + issuesFilterParams: params, openIssues: params.state === 'open' ? issues : state.openIssues, closedIssues: params.state === 'closed' ? issues : state.closedIssues, }; diff --git a/angular-ngrx-scss/src/app/state/repository/repository.selectors.ts b/angular-ngrx-scss/src/app/state/repository/repository.selectors.ts index 7f491f101..f6797cb55 100644 --- a/angular-ngrx-scss/src/app/state/repository/repository.selectors.ts +++ b/angular-ngrx-scss/src/app/state/repository/repository.selectors.ts @@ -46,6 +46,16 @@ export const selectClosedIssuePaginationParams = createSelector( (state) => state.closedIssues?.paginationParams, ); +export const selectClosedPullRequestsPaginationParams = createSelector( + selectRepositoryState, + (state) => state.closedPullRequests?.paginationParams, +); + +export const selectOpenPullRequestsPaginationParams = createSelector( + selectRepositoryState, + (state) => state.openPullRequests?.paginationParams, +); + export const selectMilestones = createSelector( selectRepositoryState, (state) => state.milestones, @@ -55,3 +65,26 @@ export const selectLabels = createSelector( selectRepositoryState, (state) => state.labels, ); + +export const selectHasActiveIssueFilters = createSelector( + selectRepositoryState, + (state) => + state.issuesFilterParams && + ((state.issuesFilterParams.milestone && + state.issuesFilterParams.milestone?.length > 0) || + (state.issuesFilterParams?.labels && + state.issuesFilterParams.labels.length > 0) || + (state.issuesFilterParams.sort && + state.issuesFilterParams?.sort !== 'created')), +); + +export const selectHasActivePullRequestFilters = createSelector( + selectRepositoryState, + (state) => + state.pullsFilterParams && + ((state.pullsFilterParams?.labels && + state.pullsFilterParams.labels.length > 0) || + (state.pullsFilterParams?.sort && + state.pullsFilterParams.sort && + state.pullsFilterParams.sort !== 'created')), +); diff --git a/angular-ngrx-scss/src/app/state/repository/repository.state.ts b/angular-ngrx-scss/src/app/state/repository/repository.state.ts index c760f7860..1bcd51932 100644 --- a/angular-ngrx-scss/src/app/state/repository/repository.state.ts +++ b/angular-ngrx-scss/src/app/state/repository/repository.state.ts @@ -1,7 +1,12 @@ -import { Issue } from 'src/app/repository/services/repository.interfaces'; +import { + Issue, + PullRequest, + RepositoryIssuesApiParams, +} from 'src/app/repository/services/repository.interfaces'; import { UserApiResponse } from '../user'; export interface RepositoryState { + path: string; description: string; forkCount: number; issueCount: number; @@ -23,6 +28,8 @@ export interface RepositoryState { website: string; milestones: Milestone[]; labels: IssueLabel[]; + pullsFilterParams: RepositoryIssuesApiParams | null; + issuesFilterParams: RepositoryIssuesApiParams | null; } export interface Milestone { @@ -228,58 +235,16 @@ export interface IssueLabel { default: boolean; } -export interface PullRequestItemAPIResponse { - url: string; - repository_url: string; - labels_url: string; - comments_url: string; - events_url: string; - html_url: string; - id: number; - node_id: string; - number: number; - title: string; - user: Partial; - labels: IssueLabel[]; - state: string; - locked: boolean; - assignee: string | null; - assignees: unknown[]; - milestone: null; - comments: number; - created_at: string; - updated_at: string; - closed_at: string | null; - author_association: AUTHOR_ASSOCIATION; - active_lock_reason: string | null; - draft: boolean; - pull_request: { - url: string; - html_url: string; - diff_url: string; - patch_url: string; - merged_at: string | null; - }; - body: string; - - diff_url: string; - patch_url: string; - issue_url: string; - commits_url: string; - review_comments_url: string; - review_comment_url: string; - statuses_url: string; -} - export interface PullRequestAPIResponse { total_count: number; incomplete_results: boolean; - items: PullRequestItemAPIResponse[]; + items: PullRequest[]; } export interface RepoPullRequests { - totalCount: number; - pullRequests: RepoPullRequest[]; + paginationParams: PaginationParams; + total: number; + pullRequests: PullRequest[]; } export interface IssueAPIResponse { @@ -299,27 +264,6 @@ export interface RepoIssues { total: number; issues: Issue[]; } - -export interface RepoPullRequest { - id: number; - login?: string | null; - title: string; - number: number; - closedAt?: Date | null; - mergedAt?: Date | null; - state: string; - createdAt: Date; - labels: Array<{ - id: number; - node_id: string; - url: string; - name: string; - color: string; - }>; - commentCount: number; - labelCount: number; -} - export type ISSUE_STATE = 'open' | 'closed'; export enum AUTHOR_ASSOCIATION { diff --git a/angular-ngrx-scss/src/app/state/user/user.effects.spec.ts b/angular-ngrx-scss/src/app/state/user/user.effects.spec.ts index 2fbddd734..451307803 100644 --- a/angular-ngrx-scss/src/app/state/user/user.effects.spec.ts +++ b/angular-ngrx-scss/src/app/state/user/user.effects.spec.ts @@ -126,7 +126,6 @@ const MOCK_TOP_REPOS: UserReposApiResponse = [ describe('UserEffects', () => { let actions$: Observable; let effects: UserEffects; - let store: MockStore; let userServiceMock: jasmine.SpyObj; beforeEach(() => { @@ -164,7 +163,7 @@ describe('UserEffects', () => { }); // eslint-disable-next-line @typescript-eslint/no-unused-vars - store = TestBed.inject(MockStore); + TestBed.inject(MockStore); effects = TestBed.inject(UserEffects); });