Skip to content
Merged
9 changes: 9 additions & 0 deletions app/adapters/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ export default class VersionAdapter extends ApplicationAdapter {
return `/${this.namespace}/crates/${crateName}/${num}`;
}

urlForQuery(query) {
let { name } = query ?? {};
let baseUrl = this.buildURL('crate', name);
let url = `${baseUrl}/versions`;
// The following used to remove them from URL's query string.
delete query.name;
return url;
}

urlForQueryRecord(query) {
let { name, num } = query ?? {};
let baseUrl = this.buildURL('crate', name);
Expand Down
2 changes: 0 additions & 2 deletions app/components/version-list/row.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
{{svg-jar "trash"}}
{{else if @version.invalidSemver}}
?
{{else if @version.isFirst}}
{{svg-jar "star"}}
{{else}}
{{@version.releaseTrack}}
{{/if}}
Expand Down
3 changes: 0 additions & 3 deletions app/components/version-list/row.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ export default class VersionRow extends Component {
if (version.invalidSemver) {
return `Failed to parse version ${version.num}`;
}
if (version.isFirst) {
return 'This is the first version that was released';
}

let { releaseTrack } = version;

Expand Down
103 changes: 100 additions & 3 deletions app/controllers/crate/versions.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,115 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

import { didCancel, dropTask } from 'ember-concurrency';
import { alias } from 'macro-decorators';

import { AjaxError } from '../../utils/ajax';

function defaultVersionsContext() {
return { data: [], next_page: undefined };
}

export default class SearchController extends Controller {
queryParams = ['sort'];
@service sentry;
@service store;

queryParams = ['per_page', 'sort'];
@tracked sort;
@tracked per_page = 100;

@tracked byDate;
@tracked bySemver;
/** @type {import("../../models/crate").default} */
@tracked crate;

@alias('versionsContext.data') data;
@alias('versionsContext.next_page') next_page;

constructor() {
super(...arguments);
this.reset();
}

get currentSortBy() {
return this.sort === 'semver' ? 'SemVer' : 'Date';
}

get versionsContext() {
return this.sort === 'semver' ? this.bySemver : this.byDate;
}

get sortedVersions() {
let { versionIdsBySemver, versionIdsByDate, versionsObj: versions } = this.model;
let { loadedVersionsById: versions } = this.crate;
return this.data.map(id => versions.get(id));
}

loadMoreTask = dropTask(async () => {
let { crate, data, next_page, per_page, sort, versionsContext } = this;
let query;

if (next_page) {
let params = new URLSearchParams(next_page);
params.set('name', crate.name);
params.delete('include');
query = Object.fromEntries(params.entries());
} else {
if (sort !== 'semver') {
sort = 'date';
}
query = {
name: crate.name,
sort,
per_page,
};
}
if (crate.release_tracks == null) {
query.include = 'release_tracks';
}

try {
let versions = await this.store.query('version', query);
let meta = versions.meta;

let ids = versions.map(it => it.id);
if (sort === 'semver') {
this.bySemver = {
...versionsContext,
data: data.concat(ids),
next_page: meta.next_page,
};
} else {
this.byDate = {
...versionsContext,
data: data.concat(ids),
next_page: meta.next_page,
};
}

// set release_tracks to crate
if (meta.release_tracks) {
let payload = {
crate: {
id: crate.id,
release_tracks: meta.release_tracks,
},
};
this.store.pushPayload(payload);
}

return versions;
} catch (error) {
// report unexpected errors to Sentry and ignore `ajax()` errors
if (!didCancel(error) && !(error instanceof AjaxError)) {
this.sentry.captureException(error);
}
}
});

return (this.sort === 'semver' ? versionIdsBySemver : versionIdsByDate).map(id => versions[id]);
reset() {
this.crate = undefined;
this.byDate = defaultVersionsContext();
this.bySemver = defaultVersionsContext();
}
}
77 changes: 17 additions & 60 deletions app/models/crate.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export default class Crate extends Model {
@attr documentation;
@attr repository;

/**
* This isn't an attribute in the crate response.
* It's actually the `meta` attribute that belongs to `versions`
* and needs to be assigned to `crate` manually.
* @type {Object.<string, {highest: string}>?}
**/
@attr release_tracks;

@hasMany('version', { async: true, inverse: 'crate' }) versions;
@hasMany('team', { async: true, inverse: null }) owner_team;
@hasMany('user', { async: true, inverse: null }) owner_user;
Expand All @@ -37,35 +45,12 @@ export default class Crate extends Model {
@hasMany('category', { async: true, inverse: null }) categories;
@hasMany('dependency', { async: true, inverse: null }) reverse_dependencies;

@cached get versionIdsBySemver() {
let { last } = this.loadVersionsTask;
assert('`loadVersionsTask.perform()` must be called before calling `versionIdsBySemver`', last != null);
let versions = last?.value ?? [];
return versions
.slice()
.sort(compareVersionBySemver)
.map(v => v.id);
}

@cached get versionIdsByDate() {
let { last } = this.loadVersionsTask;
assert('`loadVersionsTask.perform()` must be called before calling `versionIdsByDate`', last != null);
let versions = last?.value ?? [];
return versions
.slice()
.sort(compareVersionByDate)
.map(v => v.id);
}

@cached get firstVersionId() {
return this.versionIdsByDate.at(-1);
}

@cached get versionsObj() {
let { last } = this.loadVersionsTask;
assert('`loadVersionsTask.perform()` must be called before calling `versionsObj`', last != null);
let versions = last?.value ?? [];
return Object.fromEntries(versions.slice().map(v => [v.id, v]));
/** @return {Map<number, import("../models/version").default>} */
@cached
get loadedVersionsById() {
let versionsRef = this.hasMany('versions');
let values = versionsRef.value();
return new Map(values?.map(ref => [ref.id, ref]));
}

/** @return {Map<string, import("../models/version").default>} */
Expand All @@ -77,15 +62,9 @@ export default class Crate extends Model {
}

@cached get releaseTrackSet() {
let map = new Map();
let { versionsObj: versions, versionIdsBySemver } = this;
for (let id of versionIdsBySemver) {
let { releaseTrack, isPrerelease, yanked } = versions[id];
if (releaseTrack && !isPrerelease && !yanked && !map.has(releaseTrack)) {
map.set(releaseTrack, id);
}
}
return new Set(map.values());
let { release_tracks } = this;
let nums = release_tracks ? Object.values(this.release_tracks).map(it => it.highest) : [];
return new Set(nums);
}

hasOwnerUser(userId) {
Expand Down Expand Up @@ -145,25 +124,3 @@ export default class Crate extends Model {
return (await fut) ?? [];
});
}

function compareVersionBySemver(a, b) {
let aSemver = a.semver;
let bSemver = b.semver;

if (aSemver === bSemver) {
return b.created_at - a.created_at;
} else if (aSemver === null) {
return 1;
} else if (bSemver === null) {
return -1;
} else {
return bSemver.compare(aSemver);
}
}

function compareVersionByDate(a, b) {
let bDate = b.created_at.getTime();
let aDate = a.created_at.getTime();

return bDate === aDate ? parseInt(b.id) - parseInt(a.id) : bDate - aDate;
}
6 changes: 1 addition & 5 deletions app/models/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,6 @@ export default class Version extends Model {
return Date.now() - this.created_at.getTime() < EIGHT_DAYS;
}

@cached get isFirst() {
return this.id === this.crate?.firstVersionId;
}

get semver() {
return semverParse(this.num, { loose: true });
}
Expand Down Expand Up @@ -106,7 +102,7 @@ export default class Version extends Model {
return false;
}

return this.crate?.releaseTrackSet.has(this.id);
return this.crate?.releaseTrackSet.has(this.num);
}

get featureList() {
Expand Down
20 changes: 18 additions & 2 deletions app/routes/crate/versions.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,27 @@ import Route from '@ember/routing/route';
import { waitForPromise } from '@ember/test-waiters';

export default class VersionsRoute extends Route {
queryParams = {
sort: { refreshModel: true },
};

model(params) {
// we need a model() implementation that changes, otherwise the setupController() hook
// is not called and we won't reload the results if a new query string is used
return params;
}

setupController(controller) {
super.setupController(...arguments);
let crate = this.modelFor('crate');
// reset when crate changes
if (crate && crate !== controller.crate) {
controller.reset();
}
controller.set('crate', crate);
// TODO: Add error handling
waitForPromise(crate.loadVersionsTask.perform());
// Fetch initial data only if empty
if (controller.data.length === 0) {
waitForPromise(controller.loadMoreTask.perform());
}
}
}
26 changes: 26 additions & 0 deletions app/styles/crate/versions.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,29 @@
margin-top: var(--space-2xs);
}
}

.load-more {
--shadow: 0 1px 3px light-dark(hsla(51, 90%, 42%, .35), #232321);

/* TODO: move to shared */
composes: load-more from '../../styles/dashboard.module.css';

border: 0;
padding: 0 var(--space-m);

button {
border-radius: var(--space-3xs);
box-shadow: var(--shadow);
cursor: pointer;
position: relative;
}

.loading-spinner {
display: inline-flex;
align-items: center;
position: absolute;
height: 100%;
top: 0;
margin-left: var(--space-2xs);
}
}
20 changes: 16 additions & 4 deletions app/templates/crate/versions.hbs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<CrateHeader @crate={{this.model}} />
<CrateHeader @crate={{this.crate}} />

<div local-class="results-meta">
<span local-class="page-description" data-test-page-description>
All <strong>{{ this.model.num_versions }}</strong>
versions of <strong>{{ this.model.name }}</strong> since
{{date-format this.model.created_at 'PPP'}}
<strong>{{ this.sortedVersions.length }}</strong> of <strong>{{ this.crate.num_versions }}</strong>
<strong>{{ this.crate.name }}</strong> versions since
Comment on lines +5 to +6
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This currently seems a bit odd, since the bolded number and name are positioned directly beside each other. There might be better wording or display methods.

{{date-format this.crate.created_at 'PPP'}}
</span>

<div data-test-search-sort>
Expand All @@ -23,3 +23,15 @@
</li>
{{/each}}
</ul>
{{#if this.next_page}}
<div local-class="load-more">
<button type="button" data-test-id="load-more" disabled={{this.loadMoreTask.isRunning}}
{{on "click" (perform this.loadMoreTask)}}
>
Load More
{{#if this.loadMoreTask.isRunning}}
<LoadingSpinner local-class="loading-spinner" />
{{/if}}
</button>
</div>
{{/if}}
19 changes: 17 additions & 2 deletions e2e/acceptance/crate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,29 @@ test.describe('Acceptance | crate page', { tag: '@acceptance' }, () => {
await expect(page.locator('[data-test-heading] [data-test-crate-name]')).toHaveText('foo-bar');
});

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

// default with a page size more than 13
await page.goto('/crates/nanomsg');
await page.click('[data-test-versions-tab] a');

await expect(page.locator('[data-test-page-description]')).toHaveText(
/All 13\s+versions of nanomsg since\s+December \d+th, 2014/,
/13 of 13\s+nanomsg versions since\s+December \d+th, 2014/,
);
});

test('navigating to the versions page with custom per_page', async ({ page, msw }) => {
loadFixtures(msw.db);

await page.goto('/crates/nanomsg/versions?per_page=10');
await expect(page.locator('[data-test-page-description]')).toHaveText(
/10 of 13\s+nanomsg versions since\s+December \d+th, 2014/,
);

await page.getByTestId('load-more').click();
await expect(page.locator('[data-test-page-description]')).toHaveText(
/13 of 13\s+nanomsg versions since\s+December \d+th, 2014/,
);
});

Expand Down
Loading
Loading