Skip to content

Commit e790534

Browse files
committed
feat: use search api, connect filters
1 parent 2c5f6bb commit e790534

10 files changed

Lines changed: 144 additions & 115 deletions

File tree

angular-ngrx-scss/src/app/issues/components/issues-header/issues-header.component.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,25 @@
1818
description="Select label"
1919
[isRepo]="true"
2020
[items]="(labels$ | async) || []"
21+
[toggle]="true"
22+
(setFilter)="setLabel($event)"
23+
[current]="filterParams.labels"
2124
></app-filter-dropdown>
2225
<app-filter-dropdown
2326
name="Milestones"
2427
description="Select milestone"
2528
[isRepo]="true"
2629
[items]="(milestones$ | async) || []"
30+
(setFilter)="setMilestone($event)"
31+
[current]="filterParams.milestone"
2732
></app-filter-dropdown>
2833
<app-filter-dropdown
2934
name="Sort"
3035
description="Select sort"
3136
[isRepo]="true"
37+
[items]="sortOptions"
38+
(setFilter)="setSort($event)"
39+
[current]="filterParams.sort"
3240
></app-filter-dropdown>
3341
</div>
3442
</div>

angular-ngrx-scss/src/app/issues/components/issues-header/issues-header.component.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { Component, EventEmitter, Input, Output } from '@angular/core';
22
import { Store } from '@ngrx/store';
33
import { Observable, map } from 'rxjs';
4+
import { Sort } from 'src/app/repository/services/repository.interfaces';
45
import { FilterOption } from 'src/app/shared/components/filter-dropdown/filter-dropdown.component';
6+
import { SORTING_OPTIONS } from 'src/app/shared/constants';
57
import {
68
ISSUE_STATE,
79
RepoIssues,
10+
fetchIssues,
811
selectLabels,
912
selectMilestones,
1013
} from 'src/app/state/repository';
@@ -15,6 +18,16 @@ import {
1518
styleUrls: ['./issues-header.component.scss'],
1619
})
1720
export class IssuesHeaderComponent {
21+
@Input() owner!: string;
22+
23+
@Input() repoName!: string;
24+
25+
filterParams: { labels?: string; milestone?: string; sort: Sort } = {
26+
sort: 'created',
27+
};
28+
29+
sortOptions: FilterOption[] = SORTING_OPTIONS;
30+
1831
milestones$: Observable<FilterOption[]> = this.store
1932
.select(selectMilestones)
2033
.pipe(
@@ -53,4 +66,36 @@ export class IssuesHeaderComponent {
5366
selectClosed() {
5467
this.viewStateChange.emit('closed');
5568
}
69+
70+
setLabel(label: string) {
71+
this.filterParams.labels = label;
72+
this.refetchIssues();
73+
}
74+
75+
setMilestone(milestone: string) {
76+
this.filterParams.milestone = milestone;
77+
this.refetchIssues();
78+
}
79+
80+
setSort(sort: string) {
81+
this.filterParams.sort = sort as Sort;
82+
this.refetchIssues();
83+
}
84+
85+
private refetchIssues() {
86+
this.store.dispatch(
87+
fetchIssues({
88+
owner: this.owner,
89+
repoName: this.repoName,
90+
params: { state: 'open', ...this.filterParams },
91+
}),
92+
);
93+
this.store.dispatch(
94+
fetchIssues({
95+
owner: this.owner,
96+
repoName: this.repoName,
97+
params: { state: 'closed', ...this.filterParams },
98+
}),
99+
);
100+
}
56101
}

angular-ngrx-scss/src/app/issues/components/issues.component.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
[viewState]="viewState"
44
[openIssues]="openIssues$ | async"
55
[closedIssues]="closedIssues$ | async"
6+
[owner]="owner"
7+
[repoName]="repoName"
68
(viewStateChange)="viewStateChange($event)"
79
></app-issues-header>
810
<app-issues-list

angular-ngrx-scss/src/app/issues/components/issues.component.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
.issues-container {
44
border-radius: 0.5rem;
55
border: 1px solid variables.$gray200;
6+
margin: 1rem auto;
67
}

angular-ngrx-scss/src/app/repository/services/repository.interfaces.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,22 @@ export interface Issue {
3333

3434
export type Issues = Array<Issue>;
3535

36+
export type Sort =
37+
| 'created'
38+
| 'created-asc'
39+
| 'updated'
40+
| 'updated-asc'
41+
| 'comments'
42+
| 'comments-asc';
43+
3644
export interface RepositoryIssuesApiParams {
3745
milestone?: string;
38-
state?: 'open' | 'closed' | 'all';
46+
state: 'open' | 'closed' | 'all';
3947
assignee?: string;
4048
creator?: string;
4149
mentioned?: string;
4250
labels?: string;
43-
sort?: 'created' | 'updated' | 'comments';
44-
direction?: 'asc' | 'desc';
51+
sort?: Sort;
4552
since?: string;
4653
per_page?: number;
4754
page?: number;

angular-ngrx-scss/src/app/repository/services/repository.service.spec.ts

Lines changed: 18 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,25 @@ import {
22
HttpClient,
33
HttpEventType,
44
HttpHeaders,
5-
HttpParams,
65
HttpResponse,
76
} from '@angular/common/http';
87
import { delay, of } from 'rxjs';
98
import {
109
PullRequestAPIResponse,
1110
RepoApiResponse,
1211
RepoContentsApiResponse,
12+
RepoIssues,
1313
} from 'src/app/state/repository';
1414
import {
1515
IssueComments,
16-
Issues,
1716
PullRequest,
1817
PullRequests,
1918
} from './repository.interfaces';
2019

2120
import { generatePullRequestAPIResponseFixture } from '../../fixtures/repository.fixtures';
2221
import { RepositoryService } from './repository.service';
2322

24-
const MOCK_ISSUES: Issues = [
23+
const MOCK_ISSUES = [
2524
{
2625
number: 30734,
2726
state: 'open',
@@ -136,27 +135,29 @@ const MOCK_ISSUES: Issues = [
136135
},
137136
];
138137

139-
const MOCK_REPO_ISSUES = {
138+
const EXPECTED_ISSUES: RepoIssues = {
140139
total: 3,
141140
paginationParams: {
142-
canNext: true,
143-
canPrev: false,
144141
page: 1,
142+
canNext: false,
143+
canPrev: false,
145144
},
146145
issues: MOCK_ISSUES,
147146
};
148147

149-
const MOCK_ISSUES_RESPONSE: HttpResponse<Issues> = {
150-
headers: new HttpHeaders({
151-
Link: '<https://api.github.com/repositories/421063754/issues?state=open&sort=created&direction=desc&per_page=30&page=1&pulls=false>; rel="next", <https://api.github.com/repositories/421063754/issues?state=open&sort=created&direction=desc&per_page=30&page=3&pulls=false>; rel="last"',
152-
}),
148+
const MOCK_ISSUES_RESPONSE: HttpResponse<unknown> = {
149+
headers: new HttpHeaders(),
153150
clone: jasmine.createSpy('clone'),
154151
type: HttpEventType.Response,
155152
status: 200,
156153
statusText: 'OK',
157154
ok: true,
158155
url: 'http://localhost',
159-
body: MOCK_ISSUES,
156+
body: {
157+
total_count: 3,
158+
incomplete_results: false,
159+
items: MOCK_ISSUES,
160+
},
160161
};
161162

162163
const MOCK_PULL_REQUEST_NUMBER = 11814;
@@ -428,25 +429,15 @@ describe('RepositoryService', () => {
428429
httpClientSpy.get.and.returnValue(of(MOCK_ISSUES_RESPONSE));
429430

430431
repoService.getRepositoryIssues('FakeCo', 'fake-repo').subscribe({
431-
next: (response) => {
432-
expect(response).toEqual(MOCK_REPO_ISSUES);
432+
next: (repos) => {
433+
expect(repos).toEqual(EXPECTED_ISSUES);
433434

434435
expect(httpClientSpy.get).toHaveBeenCalledWith(
435-
'https://api.github.com/repos/FakeCo/fake-repo/issues',
436+
'https://api.github.com/search/issues?q=repo:FakeCo/fake-repo+type:issue+state:all',
436437
jasmine.objectContaining({
437438
headers: {
438439
Accept: 'application/vnd.github.v3+json',
439440
},
440-
params: new HttpParams({
441-
fromObject: {
442-
state: 'all',
443-
sort: 'created',
444-
direction: 'desc',
445-
per_page: 30,
446-
page: 1,
447-
pulls: false,
448-
},
449-
}),
450441
}),
451442
);
452443
},
@@ -460,25 +451,15 @@ describe('RepositoryService', () => {
460451
repoService
461452
.getRepositoryIssues('FakeCo', 'fake-repo', { state: 'closed' })
462453
.subscribe({
463-
next: (response) => {
464-
expect(response).toEqual(MOCK_REPO_ISSUES);
454+
next: (repos) => {
455+
expect(repos).toEqual(EXPECTED_ISSUES);
465456

466457
expect(httpClientSpy.get).toHaveBeenCalledWith(
467-
'https://api.github.com/repos/FakeCo/fake-repo/issues',
458+
'https://api.github.com/search/issues?q=repo:FakeCo/fake-repo+type:issue+state:closed',
468459
jasmine.objectContaining({
469460
headers: {
470461
Accept: 'application/vnd.github.v3+json',
471462
},
472-
params: new HttpParams({
473-
fromObject: {
474-
state: 'closed',
475-
sort: 'created',
476-
direction: 'desc',
477-
per_page: 30,
478-
page: 1,
479-
pulls: false,
480-
},
481-
}),
482463
}),
483464
);
484465
},

angular-ngrx-scss/src/app/repository/services/repository.service.ts

Lines changed: 39 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { HttpClient, HttpParams } from '@angular/common/http';
1+
import { HttpClient } from '@angular/common/http';
22
import { Injectable } from '@angular/core';
3-
import { Observable, forkJoin, map } from 'rxjs';
3+
import { Observable, map } from 'rxjs';
44
import {
55
FileContentsApiResponse,
66
ISSUE_STATE,
7+
IssueAPIResponse,
78
IssueLabel,
89
Milestone,
910
PullRequestAPIResponse,
@@ -207,56 +208,52 @@ export class RepositoryService {
207208

208209
const owner = encodeURIComponent(repoOwner);
209210
const name = encodeURIComponent(repoName);
210-
const url = `${environment.githubUrl}/repos/${owner}/${name}/issues`;
211-
212-
// We need to make two calls to get the total count of issues and the issues themselves.
213-
// This workaround is needed because the GitHub REST API doesn't return the total count of issues in the response.
214-
// We can get the total count from the "Link" HTTP header,
215-
// but we need to make a call with per_page=1 to be able to calculate the total count.
216-
// See https://developer.github.com/v3/guides/traversing-with-pagination/#calculating-the-pagination-offset
217-
return forkJoin([
218-
this.http.get(url, {
219-
observe: 'response',
220-
headers: {
221-
Accept: 'application/vnd.github.v3+json',
222-
},
223-
params: new HttpParams({
224-
fromObject: { ...Object.assign(defaultParams, params) },
225-
}),
226-
}),
227-
this.http.get(url, {
211+
const state = encodeURIComponent(params?.state ?? defaultParams.state);
212+
let url = `${environment.githubUrl}/search/issues?q=repo:${owner}/${name}+type:issue+state:${state}`;
213+
214+
if (params?.labels) {
215+
url += `+label:"${params.labels}"`;
216+
}
217+
218+
if (params?.milestone) {
219+
url += `+milestone:"${params.milestone}"`;
220+
}
221+
222+
if (params?.sort) {
223+
url += `+sort:${params.sort}`;
224+
}
225+
226+
return this.http
227+
.get(url, {
228228
observe: 'response',
229229
headers: {
230230
Accept: 'application/vnd.github.v3+json',
231231
},
232-
params: new HttpParams({
233-
fromObject: { ...Object.assign(defaultParams, { per_page: 1 }) },
234-
}),
235-
}),
236-
]).pipe(
237-
map(([paginatedIssues, helperIssues]) => {
238-
const linkHeader = paginatedIssues.headers.get('Link');
232+
})
233+
.pipe(
234+
map((response) => {
235+
const linkHeader = response.headers.get('Link');
236+
237+
const canNext = !!(linkHeader && linkHeader.includes('rel="next"'));
238+
const canPrev = !!(linkHeader && linkHeader.includes('rel="prev"'));
239239

240-
const canNext = !!(linkHeader && linkHeader.includes('rel="next"'));
241-
const canPrev = !!(linkHeader && linkHeader.includes('rel="prev"'));
240+
const data = response.body as IssueAPIResponse;
242241

243-
const total = this.extractTotalFromLinkHeader(
244-
helperIssues.headers.get('Link'),
245-
);
242+
const total = data.total_count;
246243

247-
const page = params?.page || 1;
244+
const page = params?.page || 1;
248245

249-
const paginationParams = {
250-
canNext,
251-
canPrev,
252-
page,
253-
};
246+
const paginationParams = {
247+
canNext,
248+
canPrev,
249+
page,
250+
};
254251

255-
const issues = paginatedIssues.body as Issue[];
252+
const issues: Issue[] = data.items;
256253

257-
return { issues, paginationParams, total } as RepoIssues;
258-
}),
259-
);
254+
return { issues, paginationParams, total } as RepoIssues;
255+
}),
256+
);
260257
}
261258

262259
/**
@@ -344,33 +341,4 @@ export class RepositoryService {
344341
},
345342
});
346343
}
347-
348-
private extractTotalFromLinkHeader(linkHeader: string | null): number {
349-
if (!linkHeader) {
350-
return 0;
351-
}
352-
353-
// Find the link with rel="last"
354-
const lastLinkPattern = /<([^>]+)>; rel="last"/;
355-
const lastLinkMatch = linkHeader.match(lastLinkPattern);
356-
357-
if (!lastLinkMatch) {
358-
return 0;
359-
}
360-
361-
const lastLink = lastLinkMatch[1];
362-
363-
// Parse as a URL
364-
const url = new URL(lastLink);
365-
366-
// Extract query parameters
367-
const queryParams = new URLSearchParams(url.search);
368-
const page = parseInt(queryParams.get('page') || '', 10);
369-
370-
if (isNaN(page)) {
371-
return 0;
372-
}
373-
374-
return page;
375-
}
376344
}

0 commit comments

Comments
 (0)