Skip to content

Commit df5aaa9

Browse files
committed
fix: add questions list lazy load on the Questions page
1 parent 3d80fc2 commit df5aaa9

3 files changed

Lines changed: 69 additions & 17 deletions

File tree

src/app/questions/questions.page.html

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
<ion-searchbar animated="true" placeholder="Search by topic, keyword or ID" (ionInput)="handleInput($event)"></ion-searchbar>
1818

1919
<div class="question-list">
20-
@for (question of filteredQuestions; track trackById($index, question)) {
20+
@for (question of visibleQuestions; track trackById($index, question)) {
2121
<ion-item
2222
class="question"
2323
button
@@ -28,7 +28,7 @@
2828
<ion-icon name="arrow-forward" slot="end" class="chevron-icon"></ion-icon>
2929
<ion-label>
3030
<h2 class="question__name">{{ question.id }}. {{ question.name }}</h2>
31-
@let pct = (getPercentById(question.id) | async) ?? 0;
31+
@let pct = getPercent(question.id);
3232
<div class="question__details">
3333
@if (question.level) {
3434
<span class="level-pill level-pill--{{ levelColor(question.level) }}">{{ question.level }}</span>
@@ -42,5 +42,13 @@ <h2 class="question__name">{{ question.id }}. {{ question.name }}</h2>
4242
</ion-item>
4343
}
4444
</div>
45+
46+
<ion-infinite-scroll
47+
threshold="100px"
48+
[disabled]="visibleQuestions.length >= filteredQuestions.length"
49+
(ionInfinite)="loadMore($event)"
50+
>
51+
<ion-infinite-scroll-content loadingSpinner="crescent"></ion-infinite-scroll-content>
52+
</ion-infinite-scroll>
4553
</div>
4654
</ion-content>

src/app/questions/questions.page.spec.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
22
import { IonicModule } from '@ionic/angular';
33
import { Router } from '@angular/router';
4-
import { BehaviorSubject, of } from 'rxjs';
4+
import { BehaviorSubject } from 'rxjs';
55

66
import { QuestionsPage } from './questions.page';
77
import { QuestionsService } from 'src/app/services/questions.service';
@@ -26,7 +26,7 @@ describe('QuestionsPage', () => {
2626
let fixture: ComponentFixture<QuestionsPage>;
2727

2828
let questionsServiceMock: { questions: BehaviorSubject<Question[]> };
29-
let resultsServiceMock: { results: BehaviorSubject<Results[]>; getPercentById: jasmine.Spy };
29+
let resultsServiceMock: { results: BehaviorSubject<Results[]> };
3030
let routerMock: { navigate: jasmine.Spy };
3131

3232
const questions: Question[] = [
@@ -39,7 +39,6 @@ describe('QuestionsPage', () => {
3939
questionsServiceMock = { questions: new BehaviorSubject<Question[]>(questions) };
4040
resultsServiceMock = {
4141
results: new BehaviorSubject<Results[]>([{ id: 1, correctness: 80 }]),
42-
getPercentById: jasmine.createSpy('getPercentById').and.returnValue(of(80)),
4342
};
4443
routerMock = { navigate: jasmine.createSpy('navigate') };
4544

@@ -68,7 +67,36 @@ describe('QuestionsPage', () => {
6867

6968
expect(component.questions).toEqual(questions);
7069
expect(component.filteredQuestions).toEqual(questions);
70+
expect(component.visibleQuestions).toEqual(questions);
7171
expect(component.results).toEqual([{ id: 1, correctness: 80 }]);
72+
expect(component.percentById.get(1)).toBe(80);
73+
});
74+
75+
it('should render only the first batch when there are many questions', () => {
76+
const manyQuestions = Array.from({ length: 80 }, (_, i) => makeQuestion({ id: i + 1 }));
77+
questionsServiceMock.questions.next(manyQuestions);
78+
79+
component.ngOnInit();
80+
81+
expect(component.filteredQuestions.length).toBe(80);
82+
expect(component.visibleQuestions.length).toBe(30);
83+
});
84+
});
85+
86+
describe('loadMore', () => {
87+
it('should append the next batch and complete the infinite scroll event', () => {
88+
const manyQuestions = Array.from({ length: 80 }, (_, i) => makeQuestion({ id: i + 1 }));
89+
questionsServiceMock.questions.next(manyQuestions);
90+
component.ngOnInit();
91+
92+
const event = { target: { complete: jasmine.createSpy('complete') } };
93+
component.loadMore(event as never);
94+
95+
expect(component.visibleQuestions.length).toBe(60);
96+
expect(event.target.complete).toHaveBeenCalled();
97+
98+
component.loadMore(event as never);
99+
expect(component.visibleQuestions.length).toBe(80);
72100
});
73101
});
74102

@@ -84,6 +112,7 @@ describe('QuestionsPage', () => {
84112

85113
expect(component.filteredQuestions.length).toBe(1);
86114
expect(component.filteredQuestions[0].id).toBe(2);
115+
expect(component.visibleQuestions).toEqual(component.filteredQuestions);
87116
});
88117

89118
it('should filter by tag', () => {
@@ -109,6 +138,7 @@ describe('QuestionsPage', () => {
109138
component.handleInput(inputEvent(' '));
110139

111140
expect(component.filteredQuestions).toEqual(questions);
141+
expect(component.visibleQuestions).toEqual(questions);
112142
});
113143

114144
it('should return an empty list when nothing matches', () => {
@@ -123,12 +153,11 @@ describe('QuestionsPage', () => {
123153
expect(component.trackById(0, questions[0])).toBe(1);
124154
});
125155

126-
it('getPercentById should delegate to ResultsService', (done) => {
127-
component.getPercentById(1).subscribe(percent => {
128-
expect(percent).toBe(80);
129-
expect(resultsServiceMock.getPercentById).toHaveBeenCalledWith(1);
130-
done();
131-
});
156+
it('getPercent should return the stored percent or 0', () => {
157+
component.ngOnInit();
158+
159+
expect(component.getPercent(1)).toBe(80);
160+
expect(component.getPercent(999)).toBe(0);
132161
});
133162

134163
it('clickOnQuestion should navigate to question-info with the question id', () => {

src/app/questions/questions.page.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, inject } from '@angular/core';
2-
import { Observable, Subject } from 'rxjs';
2+
import { Subject } from 'rxjs';
33
import { takeUntil } from 'rxjs/operators';
44
import { NavigationExtras, Router } from '@angular/router';
55

@@ -10,31 +10,39 @@ import { ResultsService } from 'src/app/services/results.service';
1010
import { QuestionLevels } from 'src/app/enums/questions.enum';
1111
import { Colors } from 'src/app/enums/app.enum';
1212
import { IonicModule } from '@ionic/angular';
13-
import { AsyncPipe } from '@angular/common';
13+
import type { InfiniteScrollCustomEvent } from '@ionic/angular';
1414
@Component({
1515
selector: 'app-questions',
1616
templateUrl: 'questions.page.html',
1717
styleUrls: ['questions.page.scss'],
1818
changeDetection: ChangeDetectionStrategy.Eager,
19-
imports: [IonicModule, AsyncPipe]
19+
imports: [IonicModule]
2020
})
2121
export class QuestionsPage implements OnInit, OnDestroy {
22+
private static readonly BATCH_SIZE = 30;
23+
2224
private questionsService = inject(QuestionsService);
2325
private router = inject(Router);
2426
private resultsService = inject(ResultsService);
2527

2628
questions: Question[] = [];
2729
filteredQuestions: Question[] = [];
30+
visibleQuestions: Question[] = [];
2831
results: Results[];
32+
percentById = new Map<number, number>();
2933

3034
private destroy$ = new Subject<void>();
3135

3236
ngOnInit() {
3337
this.questionsService.questions.pipe(takeUntil(this.destroy$)).subscribe(questions => {
3438
this.questions = questions;
3539
this.filteredQuestions = questions;
40+
this.visibleQuestions = questions.slice(0, QuestionsPage.BATCH_SIZE);
41+
});
42+
this.resultsService.results.pipe(takeUntil(this.destroy$)).subscribe(results => {
43+
this.results = results;
44+
this.percentById = new Map(results.map(r => [Number(r.id), r.correctness]));
3645
});
37-
this.resultsService.results.pipe(takeUntil(this.destroy$)).subscribe(results => this.results = results);
3846
}
3947

4048
ngOnDestroy() {
@@ -48,6 +56,7 @@ export class QuestionsPage implements OnInit, OnDestroy {
4856

4957
if (!query.trim()) {
5058
this.filteredQuestions = [...this.questions];
59+
this.visibleQuestions = this.filteredQuestions.slice(0, QuestionsPage.BATCH_SIZE);
5160
return;
5261
}
5362

@@ -59,14 +68,20 @@ export class QuestionsPage implements OnInit, OnDestroy {
5968

6069
return matchTags || matchCategory || matchName || matchId;
6170
});
71+
this.visibleQuestions = this.filteredQuestions.slice(0, QuestionsPage.BATCH_SIZE);
72+
}
73+
74+
public loadMore(event: InfiniteScrollCustomEvent) {
75+
this.visibleQuestions = this.filteredQuestions.slice(0, this.visibleQuestions.length + QuestionsPage.BATCH_SIZE);
76+
event.target.complete();
6277
}
6378

6479
public trackById(index: number, item: Question) {
6580
return item.id;
6681
}
6782

68-
public getPercentById(id: number): Observable<number> {
69-
return this.resultsService.getPercentById(id);
83+
public getPercent(id: number): number {
84+
return this.percentById.get(Number(id)) ?? 0;
7085
}
7186

7287
public levelColor(level: QuestionLevels): Colors {

0 commit comments

Comments
 (0)