diff --git a/app/components/version-list/row.hbs b/app/components/version-list/row.hbs index 11ce1dcc151..97a3bc2c2bb 100644 --- a/app/components/version-list/row.hbs +++ b/app/components/version-list/row.hbs @@ -129,16 +129,15 @@ - + diff --git a/app/components/version-list/row.js b/app/components/version-list/row.js index 15deed1253a..82ee537993c 100644 --- a/app/components/version-list/row.js +++ b/app/components/version-list/row.js @@ -4,12 +4,9 @@ import { htmlSafe } from '@ember/template'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { keepLatestTask } from 'ember-concurrency'; - import styles from './row.module.css'; export default class VersionRow extends Component { - @service notifications; @service session; @tracked focused = false; @@ -62,16 +59,4 @@ export default class VersionRow extends Component { @action setFocused(value) { this.focused = value; } - - rebuildDocsTask = keepLatestTask(async () => { - let { version } = this.args; - try { - await version.rebuildDocs(); - this.notifications.success('Docs rebuild task was enqueued successfully!'); - } catch (error) { - let reason = error?.errors?.[0]?.detail ?? 'Failed to equeue docs rebuild task.'; - let msg = `Error: ${reason}`; - this.notifications.error(msg); - } - }); } diff --git a/app/controllers/crate/rebuild-docs.js b/app/controllers/crate/rebuild-docs.js new file mode 100644 index 00000000000..accf689c248 --- /dev/null +++ b/app/controllers/crate/rebuild-docs.js @@ -0,0 +1,22 @@ +import Controller from '@ember/controller'; +import { service } from '@ember/service'; + +import { keepLatestTask } from 'ember-concurrency'; + +export default class RebuildDocsController extends Controller { + @service notifications; + @service router; + + rebuildTask = keepLatestTask(async () => { + let { version } = this.model; + try { + await version.rebuildDocs(); + this.notifications.success('Docs rebuild task was enqueued successfully!'); + this.router.transitionTo('crate.versions', version.crate.name); + } catch (error) { + let reason = error?.errors?.[0]?.detail ?? 'Failed to enqueue docs rebuild task.'; + let msg = `Error: ${reason}`; + this.notifications.error(msg); + } + }); +} diff --git a/app/router.js b/app/router.js index 4a4a34363c6..54aca0ef7ca 100644 --- a/app/router.js +++ b/app/router.js @@ -14,6 +14,7 @@ Router.map(function () { this.route('dependencies'); this.route('version', { path: '/:version_num' }); this.route('version-dependencies', { path: '/:version_num/dependencies' }); + this.route('rebuild-docs', { path: '/:version_num/rebuild-docs' }); this.route('range', { path: '/range/:range' }); this.route('reverse-dependencies', { path: 'reverse_dependencies' }); diff --git a/app/routes/crate/rebuild-docs.js b/app/routes/crate/rebuild-docs.js new file mode 100644 index 00000000000..ed634e0690a --- /dev/null +++ b/app/routes/crate/rebuild-docs.js @@ -0,0 +1,34 @@ +import { service } from '@ember/service'; + +import AuthenticatedRoute from '../-authenticated-route'; + +export default class RebuildDocsRoute extends AuthenticatedRoute { + @service router; + @service session; + @service store; + + async model(params) { + // Get the crate from parent route + let crate = this.modelFor('crate'); + + // Load the specific version + let version = await this.store.queryRecord('version', { + name: crate.id, + num: params.version_num, + }); + + return { crate, version }; + } + + async afterModel(model, transition) { + let user = this.session.currentUser; + let owners = await model.crate.owner_user; + let isOwner = owners.some(owner => owner.id === user.id); + if (!isOwner) { + this.router.replaceWith('catch-all', { + transition, + title: 'This page is only accessible by crate owners', + }); + } + } +} diff --git a/app/styles/crate/rebuild-docs.module.css b/app/styles/crate/rebuild-docs.module.css new file mode 100644 index 00000000000..7d5369f9860 --- /dev/null +++ b/app/styles/crate/rebuild-docs.module.css @@ -0,0 +1,35 @@ +.content { + max-width: 600px; + margin: var(--space-xl) auto; + + h1 { + margin-top: 0; + } +} + +.crate-info { + background-color: var(--orange-50); + border-radius: 8px; + padding: var(--space-m); + margin: var(--space-m) 0; + border: 1px solid var(--orange-200); + + h2 { + margin: 0 0 var(--space-s); + } +} + +.info-row { + margin-top: var(--space-xs); +} + +.description { + margin: var(--space-m) 0; +} + +.actions { + margin-top: var(--space-m); + display: flex; + flex-wrap: wrap; + gap: var(--space-s); +} diff --git a/app/templates/crate/rebuild-docs.hbs b/app/templates/crate/rebuild-docs.hbs new file mode 100644 index 00000000000..35639a39abc --- /dev/null +++ b/app/templates/crate/rebuild-docs.hbs @@ -0,0 +1,47 @@ +{{page-title 'Rebuild Documentation'}} + +
+

Rebuild Documentation

+ +
+

Crate Information

+
+ Crate: {{@model.crate.name}} +
+
+ Version: {{@model.version.num}} +
+
+ +
+

+ This will trigger a rebuild of the documentation for + + {{@model.crate.name}} {{@model.version.num}} + + on docs.rs. +

+

+ The rebuild process may take several minutes to complete. You can monitor the build progress at the docs.rs build queue. +

+
+ +
+ + + Cancel + +
+
\ No newline at end of file diff --git a/e2e/acceptance/rebuild-docs.spec.ts b/e2e/acceptance/rebuild-docs.spec.ts new file mode 100644 index 00000000000..f9bd14b49d8 --- /dev/null +++ b/e2e/acceptance/rebuild-docs.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@/e2e/helper'; + +test.describe('Acceptance | rebuild docs page', { tag: '@acceptance' }, () => { + test('navigates to rebuild docs confirmation page', async ({ page, msw }) => { + let user = msw.db.user.create(); + await msw.authenticateAs(user); + + let crate = msw.db.crate.create({ name: 'nanomsg' }); + msw.db.crateOwnership.create({ crate, user }); + + msw.db.version.create({ crate, num: '0.1.0', created_at: '2017-01-01' }); + msw.db.version.create({ crate, num: '0.2.0', created_at: '2018-01-01' }); + msw.db.version.create({ crate, num: '0.3.0', created_at: '2019-01-01', rust_version: '1.69' }); + msw.db.version.create({ crate, num: '0.2.1', created_at: '2020-01-01' }); + + await page.goto('/crates/nanomsg/versions'); + await expect(page).toHaveURL('/crates/nanomsg/versions'); + + await expect(page.locator('[data-test-version]')).toHaveCount(4); + let versions = await page.locator('[data-test-version]').evaluateAll(el => el.map(it => it.dataset.testVersion)); + expect(versions).toEqual(['0.2.1', '0.3.0', '0.2.0', '0.1.0']); + + let v021 = page.locator('[data-test-version="0.2.1"]'); + await v021.locator('[data-test-actions-toggle]').click(); + await v021.getByRole('link', { name: 'Rebuild Docs' }).click(); + + await expect(page).toHaveURL('/crates/nanomsg/0.2.1/rebuild-docs'); + await expect(page.locator('[data-test-title]')).toHaveText('Rebuild Documentation'); + }); + + test('rebuild docs confirmation page shows crate info and allows confirmation', async ({ page, msw }) => { + let user = msw.db.user.create(); + await msw.authenticateAs(user); + + let crate = msw.db.crate.create({ name: 'nanomsg' }); + msw.db.crateOwnership.create({ crate, user }); + + msw.db.version.create({ crate, num: '0.2.1', created_at: '2020-01-01' }); + + await page.goto('/crates/nanomsg/0.2.1/rebuild-docs'); + await expect(page).toHaveURL('/crates/nanomsg/0.2.1/rebuild-docs'); + + await expect(page.locator('[data-test-title]')).toHaveText('Rebuild Documentation'); + await expect(page.locator('[data-test-crate-name]')).toHaveText('nanomsg'); + await expect(page.locator('[data-test-version-num]')).toHaveText('0.2.1'); + + await page.getByRole('button', { name: 'Confirm Rebuild' }).click(); + + let message = 'Docs rebuild task was enqueued successfully!'; + await expect(page.locator('[data-test-notification-message="success"]')).toHaveText(message); + await expect(page).toHaveURL('/crates/nanomsg/versions'); + }); + + test('rebuild docs confirmation page redirects non-owners to error page', async ({ page, msw }) => { + let user = msw.db.user.create(); + await msw.authenticateAs(user); + + let crate = msw.db.crate.create({ name: 'nanomsg' }); + msw.db.version.create({ crate, num: '0.2.1', created_at: '2020-01-01' }); + + await page.goto('/crates/nanomsg/0.2.1/rebuild-docs'); + + // Non-owners should be redirected to the catch-all error page + await expect(page.getByText('This page is only accessible by crate owners')).toBeVisible(); + }); + + test('rebuild docs confirmation page shows authentication error for unauthenticated users', async ({ page, msw }) => { + let crate = msw.db.crate.create({ name: 'nanomsg' }); + msw.db.version.create({ crate, num: '0.2.1', created_at: '2020-01-01' }); + + await page.goto('/crates/nanomsg/0.2.1/rebuild-docs'); + + // Unauthenticated users should see authentication error + await expect(page).toHaveURL('/crates/nanomsg/0.2.1/rebuild-docs'); + await expect(page.locator('[data-test-title]')).toHaveText('This page requires authentication'); + await expect(page.locator('[data-test-login]')).toBeVisible(); + }); +}); diff --git a/e2e/acceptance/versions.spec.ts b/e2e/acceptance/versions.spec.ts index c895a34f7b6..c61d297a6b0 100644 --- a/e2e/acceptance/versions.spec.ts +++ b/e2e/acceptance/versions.spec.ts @@ -68,31 +68,4 @@ test.describe('Acceptance | crate versions page', { tag: '@acceptance' }, () => await expect(v020).not.toHaveClass(/.*latest/); await expect(v020).not.toHaveClass(/.yanked/); }); - - test('triggers a rebuild for crate documentation', async ({ page, msw }) => { - let user = msw.db.user.create(); - await msw.authenticateAs(user); - - let crate = msw.db.crate.create({ name: 'nanomsg' }); - msw.db.crateOwnership.create({ crate, user }); - - msw.db.version.create({ crate, num: '0.1.0', created_at: '2017-01-01' }); - msw.db.version.create({ crate, num: '0.2.0', created_at: '2018-01-01' }); - msw.db.version.create({ crate, num: '0.3.0', created_at: '2019-01-01', rust_version: '1.69' }); - msw.db.version.create({ crate, num: '0.2.1', created_at: '2020-01-01' }); - - await page.goto('/crates/nanomsg/versions'); - await expect(page).toHaveURL('/crates/nanomsg/versions'); - - await expect(page.locator('[data-test-version]')).toHaveCount(4); - let versions = await page.locator('[data-test-version]').evaluateAll(el => el.map(it => it.dataset.testVersion)); - expect(versions).toEqual(['0.2.1', '0.3.0', '0.2.0', '0.1.0']); - - let v021 = page.locator('[data-test-version="0.2.1"]'); - await v021.locator('[data-test-actions-toggle]').click(); - await v021.getByRole('button', { name: 'Rebuild Docs' }).click(); - - let message = 'Docs rebuild task was enqueued successfully!'; - await expect(page.locator('[data-test-notification-message="success"]')).toHaveText(message); - }); }); diff --git a/tests/acceptance/rebuild-docs-test.js b/tests/acceptance/rebuild-docs-test.js new file mode 100644 index 00000000000..7162408a3b5 --- /dev/null +++ b/tests/acceptance/rebuild-docs-test.js @@ -0,0 +1,82 @@ +import { click, currentURL, findAll } from '@ember/test-helpers'; +import { module, test } from 'qunit'; + +import { setupApplicationTest } from 'crates-io/tests/helpers'; + +import { visit } from '../helpers/visit-ignoring-abort'; + +module('Acceptance | rebuild docs page', function (hooks) { + setupApplicationTest(hooks); + + test('navigates to rebuild docs confirmation page', async function (assert) { + let user = this.db.user.create(); + this.authenticateAs(user); + + let crate = this.db.crate.create({ name: 'nanomsg' }); + this.db.crateOwnership.create({ crate, user }); + + this.db.version.create({ crate, num: '0.1.0', created_at: '2017-01-01' }); + this.db.version.create({ crate, num: '0.2.0', created_at: '2018-01-01' }); + this.db.version.create({ crate, num: '0.3.0', created_at: '2019-01-01', rust_version: '1.69' }); + this.db.version.create({ crate, num: '0.2.1', created_at: '2020-01-01' }); + + await visit('/crates/nanomsg/versions'); + assert.strictEqual(currentURL(), '/crates/nanomsg/versions'); + + let versions = findAll('[data-test-version]').map(it => it.dataset.testVersion); + assert.deepEqual(versions, ['0.2.1', '0.3.0', '0.2.0', '0.1.0']); + + await click('[data-test-version="0.2.1"] [data-test-actions-toggle]'); + await click('[data-test-version="0.2.1"] [data-test-id="btn-rebuild-docs"]'); + + assert.strictEqual(currentURL(), '/crates/nanomsg/0.2.1/rebuild-docs'); + assert.dom('[data-test-title]').hasText('Rebuild Documentation'); + }); + + test('rebuild docs confirmation page shows crate info and allows confirmation', async function (assert) { + let user = this.db.user.create(); + this.authenticateAs(user); + + let crate = this.db.crate.create({ name: 'nanomsg' }); + this.db.crateOwnership.create({ crate, user }); + + this.db.version.create({ crate, num: '0.2.1', created_at: '2020-01-01' }); + + await visit('/crates/nanomsg/0.2.1/rebuild-docs'); + assert.strictEqual(currentURL(), '/crates/nanomsg/0.2.1/rebuild-docs'); + + assert.dom('[data-test-title]').hasText('Rebuild Documentation'); + assert.dom('[data-test-crate-name]').hasText('nanomsg'); + assert.dom('[data-test-version-num]').hasText('0.2.1'); + + await click('[data-test-confirm-rebuild-button]'); + + let message = 'Docs rebuild task was enqueued successfully!'; + assert.dom('[data-test-notification-message="success"]').hasText(message); + assert.strictEqual(currentURL(), '/crates/nanomsg/versions'); + }); + + test('rebuilds docs confirmation page redirects non-owners to error page', async function (assert) { + let user = this.db.user.create(); + this.authenticateAs(user); + + let crate = this.db.crate.create({ name: 'nanomsg' }); + this.db.version.create({ crate, num: '0.2.1', created_at: '2020-01-01' }); + + await visit('/crates/nanomsg/0.2.1/rebuild-docs'); + assert.dom('[data-test-title]').hasText('This page is only accessible by crate owners'); + assert.dom('[data-test-go-back]').exists(); + }); + + test('rebuild docs confirmation page shows authentication error for unauthenticated users', async function (assert) { + let crate = this.db.crate.create({ name: 'nanomsg' }); + this.db.version.create({ crate, num: '0.2.1', created_at: '2020-01-01' }); + + await visit('/crates/nanomsg/0.2.1/rebuild-docs'); + + // Unauthenticated users should see authentication error + assert.strictEqual(currentURL(), '/crates/nanomsg/0.2.1/rebuild-docs'); + assert.dom('[data-test-title]').hasText('This page requires authentication'); + assert.dom('[data-test-login]').exists(); + }); +}); diff --git a/tests/acceptance/versions-test.js b/tests/acceptance/versions-test.js index 5cc09f13169..cff0df2e9b2 100644 --- a/tests/acceptance/versions-test.js +++ b/tests/acceptance/versions-test.js @@ -81,29 +81,4 @@ module('Acceptance | crate versions page', function (hooks) { .hasNoClass(/.*latest/) .hasNoClass(/.yanked/); }); - - test('triggers a rebuild for crate documentation', async function (assert) { - let user = this.db.user.create(); - this.authenticateAs(user); - - let crate = this.db.crate.create({ name: 'nanomsg' }); - this.db.crateOwnership.create({ crate, user }); - - this.db.version.create({ crate, num: '0.1.0', created_at: '2017-01-01' }); - this.db.version.create({ crate, num: '0.2.0', created_at: '2018-01-01' }); - this.db.version.create({ crate, num: '0.3.0', created_at: '2019-01-01', rust_version: '1.69' }); - this.db.version.create({ crate, num: '0.2.1', created_at: '2020-01-01' }); - - await visit('/crates/nanomsg/versions'); - assert.strictEqual(currentURL(), '/crates/nanomsg/versions'); - - let versions = findAll('[data-test-version]').map(it => it.dataset.testVersion); - assert.deepEqual(versions, ['0.2.1', '0.3.0', '0.2.0', '0.1.0']); - - await click('[data-test-version="0.2.1"] [data-test-actions-toggle]'); - await click('[data-test-version="0.2.1"] [data-test-id="btn-rebuild-docs"]'); - - let message = 'Docs rebuild task was enqueued successfully!'; - assert.dom('[data-test-notification-message="success"]').hasText(message); - }); });