diff --git a/app/assets/styles.css b/app/assets/styles.css index 48bd2ae4..9af0470d 100644 --- a/app/assets/styles.css +++ b/app/assets/styles.css @@ -177,6 +177,7 @@ section.event { .whoops { display: flex; + flex-direction: column; justify-content: center; align-items: center; padding: var(--spacing-6); diff --git a/app/controllers/project-version.js b/app/controllers/project-version.js index 01cbedcc..f4543865 100644 --- a/app/controllers/project-version.js +++ b/app/controllers/project-version.js @@ -2,7 +2,7 @@ import { action, computed, set } from '@ember/object'; import { inject as service } from '@ember/service'; import { readOnly, alias } from '@ember/object/computed'; -import Controller, { inject as controller } from '@ember/controller'; +import Controller from '@ember/controller'; import { A } from '@ember/array'; import values from 'lodash.values'; import groupBy from 'lodash.groupby'; @@ -25,10 +25,6 @@ export default class ProjectVersionController extends Controller { @service router; @service('project') projectService; - @controller('project-version.classes.class') classController; - @controller('project-version.modules.module') moduleController; - @controller('project-version.namespaces.namespace') namespaceController; - @alias('filterData.sideNav.showPrivate') showPrivateClasses; @@ -141,61 +137,61 @@ export default class ProjectVersionController extends Controller { @action updateProject(project, ver /*, component */) { - let projectVersionID = ver.compactVersion; - let endingRoute; - switch (this.router.currentRouteName) { - case 'project-version.classes.class': { - let className = this._getEncodedNameForCurrentClass(); - endingRoute = `classes/${className}`; - break; - } - case 'project-version.modules.module': { - let moduleName = encodeURIComponent(this.moduleController.model.name); - endingRoute = `modules/${moduleName}`; - break; - } - case 'project-version.namespaces.namespace': { - let namespaceName = this.namespaceController.model.name; - endingRoute = `namespaces/${namespaceName}`; - break; - } - default: - endingRoute = ''; - break; - } - // if the user is navigating to/from api versions >= 2.16, take them - // to the home page instead of trying to translate the url - let shouldConvertPackages = this._shouldConvertPackages( - ver, - this.projectService.version, - ); - let isEmberProject = project === 'ember'; - - if (!isEmberProject || !shouldConvertPackages) { - this.router.transitionTo( - `/${project}/${projectVersionID}/${endingRoute}`, - ); - } else { - this.router.transitionTo(`/${project}/${projectVersionID}`); - } - } - - _getEncodedNameForCurrentClass() { - // escape any reserved characters for url, like slashes - return encodeURIComponent(this.classController.model.get('name')); - } - - // Input some version info, returns a boolean based on - // whether the user is switching versions for a 2.16 docs release or later. - // The urls for pre-2.16 classes and later packages are quite different - _shouldConvertPackages(targetVer, previousVer) { - let targetVersion = getCompactVersion(targetVer.id); - let previousVersion = getCompactVersion(previousVer); - let previousComparison = semverCompare(previousVersion, '2.16'); - let targetComparison = semverCompare(targetVersion, '2.16'); - return ( - (previousComparison < 0 && targetComparison >= 0) || - (previousComparison >= 0 && targetComparison < 0) + const currentURL = this.router.currentURL; + this.router.transitionTo( + findEndingRoute({ + project, + targetVersion: ver.id, + currentVersion: this.projectService.version, + currentUrlVersion: this.projectService.getUrlVersion(), + currentURL, + currentAnchor: window.location.hash, + }), ); } } + +export function findEndingRoute({ + project, + targetVersion, + currentVersion, + currentUrlVersion, + currentURL, + currentAnchor, +}) { + let projectVersionID = getCompactVersion(targetVersion); + // if the user is navigating to/from api versions Ember >= 2.16 or Ember Data >= 4.0, take them + // to the home page instead of trying to translate the url + if (shouldGoToVersionIndex(project, targetVersion, currentVersion)) { + return `/${project}/${projectVersionID}`; + } else { + return `${currentURL.replace(currentUrlVersion, projectVersionID)}${currentAnchor}`; + } +} + +function shouldGoToVersionIndex(project, targetVersion, currentVersion) { + let boundaryVersion; + if (project === 'ember') { + boundaryVersion = '2.16'; + } else if (project === 'ember-data') { + boundaryVersion = '4.0'; + } + return isCrossingVersionBoundary( + targetVersion, + currentVersion, + boundaryVersion, + ); +} + +// Input some version info, returns a boolean based on +// whether the user is switching versions for a release or later. +function isCrossingVersionBoundary(targetVer, previousVer, boundaryVersion) { + let targetVersion = getCompactVersion(targetVer); + let previousVersion = getCompactVersion(previousVer); + let previousComparison = semverCompare(previousVersion, boundaryVersion); + let targetComparison = semverCompare(targetVersion, boundaryVersion); + return ( + (previousComparison < 0 && targetComparison >= 0) || + (previousComparison >= 0 && targetComparison < 0) + ); +} diff --git a/app/routes/project-version/classes/class.js b/app/routes/project-version/classes/class.js index 58ce7fad..93841db9 100644 --- a/app/routes/project-version/classes/class.js +++ b/app/routes/project-version/classes/class.js @@ -59,8 +59,26 @@ export default class ClassRoute extends Route { }); } - redirect(model) { + redirect(model, transition) { if (model.isError) { + // Transitioning to the same route, probably only changing version + // Could explicitly check by comparing transition.to and transition.from + if (transition.to.name === transition?.from?.name) { + const projectVersionRouteInfo = transition.to.find(function (item) { + return item.params?.project_version; + }); + const attemptedVersion = + projectVersionRouteInfo.params?.project_version; + const attemptedProject = projectVersionRouteInfo.params?.project; + let error = new Error( + `We could not find ${transition.to.localName} ${transition.to.params[transition.to.paramNames[0]]} in v${attemptedVersion} of ${attemptedProject}.`, + ); + error.status = 404; + error.attemptedProject = attemptedProject; + error.attemptedVersion = attemptedVersion; + throw error; + } + let error = new Error( 'Error retrieving model in routes/project-version/classes/class', ); diff --git a/app/routes/project-version/functions/function.js b/app/routes/project-version/functions/function.js index 3da43198..412f697d 100644 --- a/app/routes/project-version/functions/function.js +++ b/app/routes/project-version/functions/function.js @@ -17,7 +17,7 @@ export default class FunctionRoute extends Route { return model?.fn?.name; } - async model(params) { + async model(params, transition) { const pVParams = this.paramsFor('project-version'); const { project, project_version: compactVersion } = pVParams; @@ -38,10 +38,24 @@ export default class FunctionRoute extends Route { `${project}-${projectVersion}-${className}`.toLowerCase(), ); } catch (e) { - fnModule = await this.store.find( - 'namespace', - `${project}-${projectVersion}-${className}`.toLowerCase(), - ); + try { + fnModule = await this.store.find( + 'namespace', + `${project}-${projectVersion}-${className}`.toLowerCase(), + ); + } catch (e2) { + if (transition.to.name === transition?.from?.name) { + let error = new Error( + `We could not find function ${className}/${functionName} in v${compactVersion} of ${project}.`, + ); + error.status = 404; + error.attemptedProject = project; + error.attemptedVersion = compactVersion; + throw error; + } else { + throw e2; + } + } } return { diff --git a/app/templates/error.hbs b/app/templates/error.hbs index 0a9f9d3f..40bfb234 100644 --- a/app/templates/error.hbs +++ b/app/templates/error.hbs @@ -1,12 +1,20 @@
{{#if (eq this.model.status 404)}}

Ack! 404 friend, you're in the wrong place

-
+ {{#if this.model.attemptedVersion}} +

+ {{this.model.message}} +

+

+ Modules, classes, and functions sometimes move around or are renamed across versions. + Try the v{{this.model.attemptedVersion}} API docs index. +

+ {{else}}

This page wasn't found. Please try the API docs page. If you expected something else to be here, please file a ticket.

-
+ {{/if}} {{else}}

Whoops! Something went wrong. diff --git a/tests/acceptance/switch-versions-test.js b/tests/acceptance/switch-versions-test.js index 74134f1b..74537075 100644 --- a/tests/acceptance/switch-versions-test.js +++ b/tests/acceptance/switch-versions-test.js @@ -11,6 +11,8 @@ async function waitForSettled() { await settled(); } +const versionIndexLinkSelector = '[data-test-version-index-link]'; + module('Acceptance | version navigation', function (hooks) { setupApplicationTest(hooks); @@ -38,6 +40,23 @@ module('Acceptance | version navigation', function (hooks) { ); }); + test('switching versions from release', async function (assert) { + await visit('/ember/release/modules/@glimmer%2Ftracking'); + + assert.equal( + currentURL(), + '/ember/release/modules/@glimmer%2Ftracking', + 'navigated to release', + ); + await selectChoose('.ember-power-select-trigger', '6.4'); + + assert.equal( + currentURL(), + '/ember/6.4/modules/@glimmer%2Ftracking', + 'navigated to v6.4 class', + ); + }); + test('switching namespace versions less than 2.16 should retain namespace page', async function (assert) { await visit('/ember/2.7/namespaces/Ember'); await waitForSettled(); @@ -165,6 +184,21 @@ module('Acceptance | version navigation', function (hooks) { ); }); + test('switching between versions on a function works', async function (assert) { + await visit('/ember/6.5/functions/@ember%2Fdebug/debug'); + assert.strictEqual( + currentURL(), + '/ember/6.5/functions/@ember%2Fdebug/debug', + ); + + await selectChoose('.ember-power-select-trigger', '6.4'); + + assert.strictEqual( + currentURL(), + '/ember/6.4/functions/@ember%2Fdebug/debug', + ); + }); + test('switching versions works if class name includes slashes', async function (assert) { await visit('/ember/3.4/classes/@ember%2Fobject%2Fcomputed'); assert.equal( @@ -279,4 +313,62 @@ module('Acceptance | version navigation', function (hooks) { 'navigated to v1.13 class', ); }); + + test('switching to a version that is missing a module offers a link to the API index for that version', async function (assert) { + await visit('/ember/6.4/modules/@glimmer%2Ftracking%2Fprimitives%2Fcache'); + assert.strictEqual( + currentURL(), + '/ember/6.4/modules/@glimmer%2Ftracking%2Fprimitives%2Fcache', + ); + + await selectChoose('.ember-power-select-trigger', '3.10'); + + assert + .dom() + .includesText( + 'We could not find module @glimmer/tracking/primitives/cache in v3.10 of ember.', + ); + + assert + .dom(versionIndexLinkSelector) + .includesText('v3.10') + .hasAttribute('href', '/ember/3.10'); + }); + + test('switching to a version that is missing a class offers a link to the API index for that version', async function (assert) { + await visit('/ember/3.0/classes/Ember.Debug'); + assert.strictEqual(currentURL(), '/ember/3.0/classes/Ember.Debug'); + + await selectChoose('.ember-power-select-trigger', '4.0'); + + assert + .dom() + .includesText('We could not find class Ember.Debug in v4.0 of ember.'); + + assert + .dom(versionIndexLinkSelector) + .includesText('v4.0') + .hasAttribute('href', '/ember/4.0'); + }); + + test('switching to a version that is missing a function offers a link to the API index for that version', async function (assert) { + await visit('/ember/3.28/functions/@glimmer%2Ftracking/tracked'); + assert.strictEqual( + currentURL(), + '/ember/3.28/functions/@glimmer%2Ftracking/tracked', + ); + + await selectChoose('.ember-power-select-trigger', '3.12'); + + assert + .dom() + .includesText( + 'We could not find function @glimmer/tracking/tracked in v3.12 of ember.', + ); + + assert + .dom(versionIndexLinkSelector) + .includesText('v3.12') + .hasAttribute('href', '/ember/3.12'); + }); }); diff --git a/tests/unit/controllers/project-version-test.js b/tests/unit/controllers/project-version-test.js index 0795b761..3ed03ef0 100644 --- a/tests/unit/controllers/project-version-test.js +++ b/tests/unit/controllers/project-version-test.js @@ -1,6 +1,7 @@ import { module, test } from 'qunit'; /* eslint-disable ember/no-restricted-resolver-tests */ import { setupTest } from 'ember-qunit'; +import { findEndingRoute } from 'ember-api-docs/controllers/project-version'; const moduleIds = [ 'ember-2.10.0-ember', @@ -45,4 +46,84 @@ module('Unit | Controller | project version', function (hooks) { let moduleNames = controller.getModuleRelationships('ember-2.10.1'); assert.deepEqual(moduleNames, expectedModuleNames); }); + + module('findEndingRoute', function () { + test('Maintains anchors', function (assert) { + let endingRoute = findEndingRoute({ + project: 'ember', + targetVersion: '6.4.0', + currentVersion: '6.5.0', + currentUrlVersion: '6.5', + currentURL: '/ember/6.5/classes/Component', + currentAnchor: '#didInsertElement', + }); + + assert.strictEqual( + endingRoute, + '/ember/6.4/classes/Component#didInsertElement', + ); + + endingRoute = findEndingRoute({ + project: 'ember', + targetVersion: '6.4.0', + currentVersion: '6.5.0', + currentUrlVersion: '6.5', + currentURL: '/ember/6.5/modules/%40ember%2Fapplication', + currentAnchor: '#classes', + }); + + assert.strictEqual( + endingRoute, + '/ember/6.4/modules/%40ember%2Fapplication#classes', + ); + }); + + test('For ember project, it goes to version root when crossing 2.16 boundary', function (assert) { + let endingRoute = findEndingRoute({ + project: 'ember', + targetVersion: '2.15.0', + currentVersion: '2.16.0', + currentUrlVersion: '2.16', + currentURL: '/ember/2.16/classes/Component', + currentAnchor: '#didInsertElement', + }); + + assert.strictEqual(endingRoute, '/ember/2.15'); + + endingRoute = findEndingRoute({ + project: 'ember', + targetVersion: '2.16.0', + currentVersion: '2.15.0', + currentUrlVersion: '2.15', + currentURL: '/ember/2.15/classes/Component', + currentAnchor: '#didInsertElement', + }); + + assert.strictEqual(endingRoute, '/ember/2.16'); + }); + + test('For ember-data project, it goes to version root when crossing 4.0 boundary', function (assert) { + let endingRoute = findEndingRoute({ + project: 'ember-data', + targetVersion: '3.28.0', + currentVersion: '4.0.0', + currentUrlVersion: '4.0', + currentURL: '/ember-data/4.0/classes/Adapter', + currentAnchor: '', + }); + + assert.strictEqual(endingRoute, '/ember-data/3.28'); + + endingRoute = findEndingRoute({ + project: 'ember-data', + targetVersion: '4.0.0', + currentVersion: '3.28.0', + currentUrlVersion: '3.28', + currentURL: '/ember-data/3.28/classes/DS.Adapter', + currentAnchor: '', + }); + + assert.strictEqual(endingRoute, '/ember-data/4.0'); + }); + }); });