Skip to content

Commit 7e15662

Browse files
committed
UI: Implement /crates/:name/delete route
1 parent 2bced57 commit 7e15662

File tree

9 files changed

+356
-0
lines changed

9 files changed

+356
-0
lines changed

app/controllers/crate/delete.js

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Controller from '@ember/controller';
2+
import { action } from '@ember/object';
3+
import { inject as service } from '@ember/service';
4+
import { tracked } from '@glimmer/tracking';
5+
6+
import { task } from 'ember-concurrency';
7+
8+
export default class CrateSettingsController extends Controller {
9+
@service notifications;
10+
@service router;
11+
12+
@tracked isConfirmed;
13+
14+
@action toggleConfirmation() {
15+
this.isConfirmed = !this.isConfirmed;
16+
}
17+
18+
deleteTask = task(async () => {
19+
try {
20+
await this.model.destroyRecord();
21+
this.notifications.success(`The crate ${this.model.name} has been successfully deleted.`);
22+
this.router.transitionTo('index');
23+
} catch (error) {
24+
let detail = error.errors?.[0]?.detail;
25+
if (detail && !detail.startsWith('{')) {
26+
this.notifications.error(`Failed to delete crate: ${detail}`);
27+
} else {
28+
this.notifications.error('Failed to delete crate');
29+
}
30+
}
31+
});
32+
}

app/router.js

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Router.map(function () {
2020

2121
this.route('owners');
2222
this.route('settings');
23+
this.route('delete');
2324

2425
// Well-known routes
2526
this.route('docs');

app/routes/crate/delete.js

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import AuthenticatedRoute from '../-authenticated-route';
2+
3+
export default class SettingsRoute extends AuthenticatedRoute {
4+
setupController(controller) {
5+
super.setupController(...arguments);
6+
controller.set('isConfirmed', false);
7+
}
8+
}

app/styles/application.module.css

+2
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
--orange-800: #9a3412;
1919
--orange-900: #7c2d12;
2020

21+
--yellow100: hsl(44, 100%, 90%);
2122
--yellow500: hsl(44, 100%, 60%);
2223
--yellow700: hsl(44, 67%, 50%);
24+
--yellow800: hsl(44, 67%, 20%);
2325

2426
--header-bg-color: light-dark(hsl(115, 31%, 20%), #141413);
2527

app/styles/crate/delete.module.css

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
.wrapper {
2+
display: grid;
3+
grid-template-columns: minmax(0, 1fr);
4+
place-items: center;
5+
margin: var(--space-s);
6+
}
7+
8+
.content {
9+
max-width: 100%;
10+
overflow-wrap: break-word;
11+
}
12+
13+
.title {
14+
margin-top: 0;
15+
}
16+
17+
.warning-block {
18+
background: light-dark(var(--yellow100), var(--yellow800));
19+
border-color: var(--yellow500);
20+
border-left-style: solid;
21+
border-left-width: 4px;
22+
border-top-right-radius: var(--space-3xs);
23+
border-bottom-right-radius: var(--space-3xs);
24+
padding: var(--space-xs);
25+
}
26+
27+
.warning {
28+
composes: warning-block;
29+
display: flex;
30+
31+
svg {
32+
flex-shrink: 0;
33+
width: 1em;
34+
height: 1em;
35+
color: var(--yellow500);
36+
}
37+
38+
p {
39+
margin: 0 0 0 var(--space-xs);
40+
text-wrap: pretty;
41+
}
42+
}
43+
44+
.confirmation {
45+
composes: warning-block;
46+
display: block;
47+
48+
input {
49+
margin-right: var(--space-3xs);
50+
}
51+
}
52+
53+
.actions {
54+
margin-top: var(--space-m);
55+
display: flex;
56+
justify-content: center;
57+
align-items: center;
58+
}
59+
60+
.delete-button {
61+
composes: red-button from '../shared/buttons.module.css';
62+
}
63+
64+
.spinner-wrapper {
65+
position: relative;
66+
}
67+
68+
.spinner {
69+
position: absolute;
70+
--spinner-size: 1.5em;
71+
top: calc(-.5 * var(--spinner-size));
72+
margin-left: var(--space-xs);
73+
}

app/templates/crate/delete.hbs

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<div local-class="wrapper">
2+
<div local-class="content">
3+
<h1 local-class="title" data-test-title>Delete the {{@model.name}} crate?</h1>
4+
5+
<p>Are you sure you want to delete the crate "{{@model.name}}"?</p>
6+
7+
<div local-class="warning">
8+
{{svg-jar "triangle-exclamation"}}
9+
<p><strong>Important:</strong> This action will permanently delete the crate and its associated versions. Deleting a crate is&nbsp;irreversible!</p>
10+
</div>
11+
12+
<div class="impact">
13+
<h3>Potential Impact:</h3>
14+
<ul>
15+
<li>Users will no longer be able to download this crate.</li>
16+
<li>Any dependencies or projects relying on this crate will be broken.</li>
17+
<li>Deleted crates cannot be reinstated.</li>
18+
</ul>
19+
</div>
20+
21+
<div class="requirements">
22+
<h3>Requirements:</h3>
23+
<p>A crate can only be deleted if:</p>
24+
<ul>
25+
<li>the crate has been published for less than 72 hours, or</li>
26+
<li>the crate only has a single owner,</li>
27+
<li>the crate has been downloaded less than 100 times for each month it has been published,</li>
28+
<li>and the crate is not depended upon by any other crate on crates.io.</li>
29+
</ul>
30+
</div>
31+
32+
<label local-class="confirmation">
33+
<Input
34+
@type="checkbox"
35+
@checked={{this.isConfirmed}}
36+
disabled={{this.deleteTask.isRunning}}
37+
data-test-confirmation-checkbox
38+
{{on "change" this.toggleConfirmation}}
39+
/>
40+
I understand that deleting this crate is permanent and cannot be undone.
41+
</label>
42+
43+
<div local-class="actions">
44+
<button
45+
type="submit"
46+
disabled={{or (not this.isConfirmed) this.deleteTask.isRunning}}
47+
local-class="delete-button"
48+
data-test-delete-button
49+
{{on "click" (perform this.deleteTask)}}
50+
>
51+
Delete this crate
52+
</button>
53+
{{#if this.deleteTask.isRunning}}
54+
<div local-class="spinner-wrapper">
55+
<LoadingSpinner local-class="spinner" data-test-spinner />
56+
</div>
57+
{{/if}}
58+
</div>
59+
</div>
60+
</div>

e2e/routes/crate/delete.spec.ts

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { expect, test } from '@/e2e/helper';
2+
3+
test.describe('Route: crate.delete', { tag: '@routes' }, () => {
4+
async function prepare({ mirage }) {
5+
await mirage.addHook(server => {
6+
let user = server.create('user');
7+
8+
let crate = server.create('crate', { name: 'foo' });
9+
server.create('version', { crate });
10+
server.create('crate-ownership', { crate, user });
11+
12+
authenticateAs(user);
13+
});
14+
}
15+
16+
test('unauthenticated', async ({ mirage, page }) => {
17+
await mirage.addHook(server => {
18+
let crate = server.create('crate', { name: 'foo' });
19+
server.create('version', { crate });
20+
});
21+
22+
await page.goto('/crates/foo/delete');
23+
await expect(page).toHaveURL('/crates/foo/delete');
24+
await expect(page.locator('[data-test-title]')).toHaveText('This page requires authentication');
25+
await expect(page.locator('[data-test-login]')).toBeVisible();
26+
});
27+
28+
test('happy path', async ({ mirage, page, percy }) => {
29+
await prepare({ mirage });
30+
31+
await page.goto('/crates/foo/delete');
32+
await expect(page).toHaveURL('/crates/foo/delete');
33+
await expect(page.locator('[data-test-title]')).toHaveText('Delete the foo crate?');
34+
await percy.snapshot();
35+
36+
await expect(page.locator('[data-test-delete-button]')).toBeDisabled();
37+
await page.click('[data-test-confirmation-checkbox]');
38+
await expect(page.locator('[data-test-delete-button]')).toBeEnabled();
39+
await page.click('[data-test-delete-button]');
40+
41+
await expect(page).toHaveURL('/');
42+
43+
let message = 'The crate foo has been successfully deleted.';
44+
await expect(page.locator('[data-test-notification-message="success"]')).toHaveText(message);
45+
46+
let crate = await page.evaluate(() => server.schema.crates.findBy({ name: 'foo' }));
47+
expect(crate).toBeNull();
48+
});
49+
50+
test('loading state', async ({ page, mirage }) => {
51+
await prepare({ mirage });
52+
await mirage.addHook(server => {
53+
globalThis.deferred = require('rsvp').defer();
54+
server.delete('/api/v1/crates/foo', () => globalThis.deferred.promise);
55+
});
56+
57+
await page.goto('/crates/foo/delete');
58+
await page.click('[data-test-confirmation-checkbox]');
59+
await page.click('[data-test-delete-button]');
60+
await expect(page.locator('[data-test-spinner]')).toBeVisible();
61+
await expect(page.locator('[data-test-confirmation-checkbox]')).toBeDisabled();
62+
await expect(page.locator('[data-test-delete-button]')).toBeDisabled();
63+
64+
await page.evaluate(async () => globalThis.deferred.resolve());
65+
await expect(page).toHaveURL('/');
66+
});
67+
68+
test('error state', async ({ page, mirage }) => {
69+
await prepare({ mirage });
70+
await mirage.addHook(server => {
71+
let payload = { errors: [{ detail: 'only crates without reverse dependencies can be deleted after 72 hours' }] };
72+
server.delete('/api/v1/crates/foo', payload, 422);
73+
});
74+
75+
await page.goto('/crates/foo/delete');
76+
await page.click('[data-test-confirmation-checkbox]');
77+
await page.click('[data-test-delete-button]');
78+
await expect(page).toHaveURL('/crates/foo/delete');
79+
80+
let message = 'Failed to delete crate: only crates without reverse dependencies can be deleted after 72 hours';
81+
await expect(page.locator('[data-test-notification-message="error"]')).toHaveText(message);
82+
});
83+
});
+4
Loading

tests/routes/crate/delete-test.js

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { click, currentURL, waitFor } from '@ember/test-helpers';
2+
import { module, test } from 'qunit';
3+
4+
import { defer } from 'rsvp';
5+
6+
import percySnapshot from '@percy/ember';
7+
import { Response } from 'miragejs';
8+
9+
import { setupApplicationTest } from 'crates-io/tests/helpers';
10+
11+
import { visit } from '../../helpers/visit-ignoring-abort';
12+
13+
module('Route: crate.delete', function (hooks) {
14+
setupApplicationTest(hooks);
15+
16+
function prepare(context) {
17+
let user = context.server.create('user');
18+
19+
let crate = context.server.create('crate', { name: 'foo' });
20+
context.server.create('version', { crate });
21+
context.server.create('crate-ownership', { crate, user });
22+
23+
context.authenticateAs(user);
24+
25+
return { user };
26+
}
27+
28+
test('unauthenticated', async function (assert) {
29+
let crate = this.server.create('crate', { name: 'foo' });
30+
this.server.create('version', { crate });
31+
32+
await visit('/crates/foo/delete');
33+
assert.strictEqual(currentURL(), '/crates/foo/delete');
34+
assert.dom('[data-test-title]').hasText('This page requires authentication');
35+
assert.dom('[data-test-login]').exists();
36+
});
37+
38+
test('happy path', async function (assert) {
39+
prepare(this);
40+
41+
await visit('/crates/foo/delete');
42+
assert.strictEqual(currentURL(), '/crates/foo/delete');
43+
assert.dom('[data-test-title]').hasText('Delete the foo crate?');
44+
await percySnapshot(assert);
45+
46+
assert.dom('[data-test-delete-button]').isDisabled();
47+
await click('[data-test-confirmation-checkbox]');
48+
assert.dom('[data-test-delete-button]').isEnabled();
49+
await click('[data-test-delete-button]');
50+
51+
assert.strictEqual(currentURL(), '/');
52+
53+
let message = 'The crate foo has been successfully deleted.';
54+
assert.dom('[data-test-notification-message="success"]').hasText(message);
55+
56+
let crate = this.server.schema.crates.findBy({ name: 'foo' });
57+
assert.strictEqual(crate, null);
58+
});
59+
60+
test('loading state', async function (assert) {
61+
prepare(this);
62+
63+
let deferred = defer();
64+
this.server.delete('/api/v1/crates/foo', deferred.promise);
65+
66+
await visit('/crates/foo/delete');
67+
await click('[data-test-confirmation-checkbox]');
68+
let clickPromise = click('[data-test-delete-button]');
69+
await waitFor('[data-test-spinner]');
70+
assert.dom('[data-test-confirmation-checkbox]').isDisabled();
71+
assert.dom('[data-test-delete-button]').isDisabled();
72+
73+
deferred.resolve(new Response(204));
74+
await clickPromise;
75+
76+
assert.strictEqual(currentURL(), '/');
77+
});
78+
79+
test('error state', async function (assert) {
80+
prepare(this);
81+
82+
let payload = { errors: [{ detail: 'only crates without reverse dependencies can be deleted after 72 hours' }] };
83+
this.server.delete('/api/v1/crates/foo', payload, 422);
84+
85+
await visit('/crates/foo/delete');
86+
await click('[data-test-confirmation-checkbox]');
87+
await click('[data-test-delete-button]');
88+
assert.strictEqual(currentURL(), '/crates/foo/delete');
89+
90+
let message = 'Failed to delete crate: only crates without reverse dependencies can be deleted after 72 hours';
91+
assert.dom('[data-test-notification-message="error"]').hasText(message);
92+
});
93+
});

0 commit comments

Comments
 (0)