Skip to content

Commit af3d232

Browse files
committed
app: Migrate the versions page to a paginated list
This implement a paginated versions page. Instead of loading all versions at once, it loads more with a "Load More" button using a seek-based API.
1 parent 87d02e5 commit af3d232

File tree

6 files changed

+141
-11
lines changed

6 files changed

+141
-11
lines changed

app/controllers/crate/versions.js

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,88 @@
11
import Controller from '@ember/controller';
2+
import { inject as service } from '@ember/service';
23
import { tracked } from '@glimmer/tracking';
34

5+
import { dropTask } from 'ember-concurrency';
6+
import { alias } from 'macro-decorators';
7+
8+
function defaultVersionsContext() {
9+
return { data: [], next_page: undefined };
10+
}
11+
412
export default class SearchController extends Controller {
5-
queryParams = ['sort'];
13+
@service store;
614

15+
queryParams = ['per_page', 'sort'];
716
@tracked sort;
17+
@tracked per_page = 100;
818

19+
@tracked byDate;
20+
@tracked bySemver;
921
/** @type {import("../../models/crate").default} */
1022
@tracked crate;
1123

24+
@alias('versionsContext.data') data;
25+
@alias('versionsContext.next_page') next_page;
26+
27+
constructor() {
28+
super(...arguments);
29+
this.reset();
30+
}
31+
1232
get currentSortBy() {
1333
return this.sort === 'semver' ? 'SemVer' : 'Date';
1434
}
1535

36+
get versionsContext() {
37+
return this.sort === 'semver' ? this.bySemver : this.byDate;
38+
}
39+
1640
get sortedVersions() {
17-
let { versionIdsBySemver, versionIdsByDate, loadedVersionsById: versions } = this.crate;
41+
let { loadedVersionsById: versions } = this.crate;
42+
return this.data.map(id => versions.get(id));
43+
}
44+
45+
loadMoreTask = dropTask(async () => {
46+
let { crate, data, next_page, per_page, sort, versionsContext } = this;
47+
let query;
48+
49+
if (next_page) {
50+
let params = new URLSearchParams(next_page);
51+
params.set('name', crate.name);
52+
query = Object.fromEntries(params.entries());
53+
} else {
54+
if (sort !== 'semver') {
55+
sort = 'date';
56+
}
57+
query = {
58+
name: crate.name,
59+
sort,
60+
per_page,
61+
};
62+
}
63+
let versions = await this.store.query('version', query);
64+
let meta = versions.meta;
65+
66+
let ids = versions.map(it => it.id);
67+
if (sort === 'semver') {
68+
this.bySemver = {
69+
...versionsContext,
70+
data: data.concat(ids),
71+
next_page: meta.next_page,
72+
};
73+
} else {
74+
this.byDate = {
75+
...versionsContext,
76+
data: data.concat(ids),
77+
next_page: meta.next_page,
78+
};
79+
}
80+
return versions;
81+
});
1882

19-
return (this.sort === 'semver' ? versionIdsBySemver : versionIdsByDate).map(id => versions.get(id));
83+
reset() {
84+
this.crate = undefined;
85+
this.byDate = defaultVersionsContext();
86+
this.bySemver = defaultVersionsContext();
2087
}
2188
}

app/routes/crate/versions.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,28 @@ import Route from '@ember/routing/route';
22
import { waitForPromise } from '@ember/test-waiters';
33

44
export default class VersionsRoute extends Route {
5+
queryParams = {
6+
sort: { refreshModel: true },
7+
};
8+
9+
model(params) {
10+
// we need a model() implementation that changes, otherwise the setupController() hook
11+
// is not called and we won't reload the results if a new query string is used
12+
return params;
13+
}
14+
515
setupController(controller) {
616
super.setupController(...arguments);
717
let crate = this.modelFor('crate');
18+
// reset when crate changes
19+
if (crate && crate !== controller.crate) {
20+
controller.reset();
21+
}
822
controller.set('crate', crate);
9-
// TODO: Add error handling
10-
waitForPromise(crate.loadVersionsTask.perform());
23+
// Fetch initial data only if empty
24+
if (controller.data.length === 0) {
25+
// TODO: Add error handling
26+
waitForPromise(controller.loadMoreTask.perform());
27+
}
1128
}
1229
}

app/styles/crate/versions.module.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,29 @@
2626
margin-top: var(--space-2xs);
2727
}
2828
}
29+
30+
.load-more {
31+
--shadow: 0 1px 3px light-dark(hsla(51, 90%, 42%, .35), #232321);
32+
33+
/* TODO: move to shared */
34+
composes: load-more from '../../styles/dashboard.module.css';
35+
36+
border: 0;
37+
padding: 0 var(--space-m);
38+
39+
button {
40+
border-radius: var(--space-3xs);
41+
box-shadow: var(--shadow);
42+
cursor: pointer;
43+
position: relative;
44+
}
45+
46+
.loading-spinner {
47+
display: inline-flex;
48+
align-items: center;
49+
position: absolute;
50+
height: 100%;
51+
top: 0;
52+
margin-left: var(--space-2xs);
53+
}
54+
}

app/templates/crate/versions.hbs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
<div local-class="results-meta">
44
<span local-class="page-description" data-test-page-description>
5-
All <strong>{{ this.crate.num_versions }}</strong>
6-
versions of <strong>{{ this.crate.name }}</strong> since
5+
<strong>{{ this.sortedVersions.length }}</strong> of <strong>{{ this.crate.num_versions }}</strong>
6+
<strong>{{ this.crate.name }}</strong> versions since
77
{{date-format this.crate.created_at 'PPP'}}
88
</span>
99

@@ -23,3 +23,15 @@
2323
</li>
2424
{{/each}}
2525
</ul>
26+
{{#if this.next_page}}
27+
<div local-class="load-more">
28+
<button type="button" data-test-id="load-more" disabled={{this.loadMoreTask.isRunning}}
29+
{{on "click" (perform this.loadMoreTask)}}
30+
>
31+
Load More
32+
{{#if this.loadMoreTask.isRunning}}
33+
<LoadingSpinner local-class="loading-spinner" />
34+
{{/if}}
35+
</button>
36+
</div>
37+
{{/if}}

e2e/acceptance/crate.spec.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,19 @@ test.describe('Acceptance | crate page', { tag: '@acceptance' }, () => {
123123
await expect(page.locator('[data-test-heading] [data-test-crate-name]')).toHaveText('foo-bar');
124124
});
125125

126-
test('navigating to the all versions page', async ({ page, msw }) => {
126+
test('navigating to the versions page', async ({ page, msw }) => {
127127
loadFixtures(msw.db);
128128

129129
await page.goto('/crates/nanomsg');
130130
await page.click('[data-test-versions-tab] a');
131131

132132
await expect(page.locator('[data-test-page-description]')).toHaveText(
133-
/All 13\s+versions of nanomsg since\s+December \d+th, 2014/,
133+
/10 of 13\s+nanomsg versions since\s+December \d+th, 2014/,
134+
);
135+
136+
await page.getByTestId('load-more').click();
137+
await expect(page.locator('[data-test-page-description]')).toHaveText(
138+
/13 of 13\s+nanomsg versions since\s+December \d+th, 2014/,
134139
);
135140
});
136141

tests/acceptance/crate-test.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,16 @@ module('Acceptance | crate page', function (hooks) {
130130
assert.dom('[data-test-heading] [data-test-crate-name]').hasText('foo-bar');
131131
});
132132

133-
test('navigating to the all versions page', async function (assert) {
133+
test('navigating to the versions page', async function (assert) {
134134
loadFixtures(this.db);
135135

136136
await visit('/crates/nanomsg');
137137
await click('[data-test-versions-tab] a');
138138

139-
assert.dom('[data-test-page-description]').hasText(/All 13\s+versions of nanomsg since\s+December \d+th, 2014/);
139+
assert.dom('[data-test-page-description]').hasText(/10 of 13\s+nanomsg versions since\s+December \d+th, 2014/);
140+
141+
await click('[data-test-id="load-more"]');
142+
assert.dom('[data-test-page-description]').hasText(/13 of 13\s+nanomsg versions since\s+December \d+th, 2014/);
140143
});
141144

142145
test('navigating to the reverse dependencies page', async function (assert) {

0 commit comments

Comments
 (0)