Skip to content

Commit 796d3f7

Browse files
committed
UI: Implement /crates/:name/delete route
1 parent aafc6ee commit 796d3f7

File tree

9 files changed

+422
-0
lines changed

9 files changed

+422
-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(`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

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { inject as service } from '@ember/service';
2+
3+
import AuthenticatedRoute from '../-authenticated-route';
4+
5+
export default class SettingsRoute extends AuthenticatedRoute {
6+
@service router;
7+
@service session;
8+
9+
async afterModel(crate, transition) {
10+
let user = this.session.currentUser;
11+
let owners = await crate.owner_user;
12+
let isOwner = owners.some(owner => owner.id === user.id);
13+
if (!isOwner) {
14+
this.router.replaceWith('catch-all', {
15+
transition,
16+
title: 'This page is only accessible by crate owners',
17+
});
18+
}
19+
}
20+
21+
setupController(controller) {
22+
super.setupController(...arguments);
23+
controller.set('isConfirmed', false);
24+
}
25+
}

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

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
.impact, .requirements {
45+
li {
46+
margin-bottom: var(--space-2xs);
47+
}
48+
}
49+
50+
.requirements {
51+
ul {
52+
list-style: none;
53+
padding-left: 0;
54+
}
55+
}
56+
57+
.confirmation {
58+
composes: warning-block;
59+
display: block;
60+
61+
input {
62+
margin-right: var(--space-3xs);
63+
}
64+
}
65+
66+
.actions {
67+
margin-top: var(--space-m);
68+
display: flex;
69+
justify-content: center;
70+
align-items: center;
71+
}
72+
73+
.delete-button {
74+
composes: red-button from '../shared/buttons.module.css';
75+
}
76+
77+
.spinner-wrapper {
78+
position: relative;
79+
}
80+
81+
.spinner {
82+
position: absolute;
83+
--spinner-size: 1.5em;
84+
top: calc(-.5 * var(--spinner-size));
85+
margin-left: var(--space-xs);
86+
}

app/templates/crate/delete.hbs

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 cannot be reversed!</p>
10+
</div>
11+
12+
<div local-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 restored.</li>
18+
</ul>
19+
</div>
20+
21+
<div local-class="requirements">
22+
<h3>Requirements:</h3>
23+
<p>A crate can only be deleted if:</p>
24+
<ol>
25+
<li>the crate has been published for less than 72 hours, or</li>
26+
<li>
27+
<ul>
28+
<li>the crate only has a single owner, and</li>
29+
<li>the crate has been downloaded less than 100 times for each month it has been published, and</li>
30+
<li>the crate is not depended upon by any other crate on crates.io.</li>
31+
</ul>
32+
</li>
33+
</ol>
34+
</div>
35+
36+
<label local-class="confirmation">
37+
<Input
38+
@type="checkbox"
39+
@checked={{this.isConfirmed}}
40+
disabled={{this.deleteTask.isRunning}}
41+
data-test-confirmation-checkbox
42+
{{on "change" this.toggleConfirmation}}
43+
/>
44+
I understand that deleting this crate is permanent and cannot be undone.
45+
</label>
46+
47+
<div local-class="actions">
48+
<button
49+
type="submit"
50+
disabled={{or (not this.isConfirmed) this.deleteTask.isRunning}}
51+
local-class="delete-button"
52+
data-test-delete-button
53+
{{on "click" (perform this.deleteTask)}}
54+
>
55+
Delete this crate
56+
</button>
57+
{{#if this.deleteTask.isRunning}}
58+
<div local-class="spinner-wrapper">
59+
<LoadingSpinner local-class="spinner" data-test-spinner />
60+
</div>
61+
{{/if}}
62+
</div>
63+
</div>
64+
</div>

e2e/routes/crate/delete.spec.ts

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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('not an owner', async ({ mirage, page }) => {
29+
await mirage.addHook(server => {
30+
let user1 = server.create('user');
31+
authenticateAs(user1);
32+
33+
let user2 = server.create('user');
34+
let crate = server.create('crate', { name: 'foo' });
35+
server.create('version', { crate });
36+
server.create('crate-ownership', { crate, user: user2 });
37+
});
38+
39+
await page.goto('/crates/foo/delete');
40+
await expect(page).toHaveURL('/crates/foo/delete');
41+
await expect(page.locator('[data-test-title]')).toHaveText('This page is only accessible by crate owners');
42+
await expect(page.locator('[data-test-go-back]')).toBeVisible();
43+
});
44+
45+
test('happy path', async ({ mirage, page, percy }) => {
46+
await prepare({ mirage });
47+
48+
await page.goto('/crates/foo/delete');
49+
await expect(page).toHaveURL('/crates/foo/delete');
50+
await expect(page.locator('[data-test-title]')).toHaveText('Delete the foo crate?');
51+
await percy.snapshot();
52+
53+
await expect(page.locator('[data-test-delete-button]')).toBeDisabled();
54+
await page.click('[data-test-confirmation-checkbox]');
55+
await expect(page.locator('[data-test-delete-button]')).toBeEnabled();
56+
await page.click('[data-test-delete-button]');
57+
58+
await expect(page).toHaveURL('/');
59+
60+
let message = 'Crate foo has been successfully deleted.';
61+
await expect(page.locator('[data-test-notification-message="success"]')).toHaveText(message);
62+
63+
let crate = await page.evaluate(() => server.schema.crates.findBy({ name: 'foo' }));
64+
expect(crate).toBeNull();
65+
});
66+
67+
test('loading state', async ({ page, mirage }) => {
68+
await prepare({ mirage });
69+
await mirage.addHook(server => {
70+
globalThis.deferred = require('rsvp').defer();
71+
server.delete('/api/v1/crates/foo', () => globalThis.deferred.promise);
72+
});
73+
74+
await page.goto('/crates/foo/delete');
75+
await page.click('[data-test-confirmation-checkbox]');
76+
await page.click('[data-test-delete-button]');
77+
await expect(page.locator('[data-test-spinner]')).toBeVisible();
78+
await expect(page.locator('[data-test-confirmation-checkbox]')).toBeDisabled();
79+
await expect(page.locator('[data-test-delete-button]')).toBeDisabled();
80+
81+
await page.evaluate(async () => globalThis.deferred.resolve());
82+
await expect(page).toHaveURL('/');
83+
});
84+
85+
test('error state', async ({ page, mirage }) => {
86+
await prepare({ mirage });
87+
await mirage.addHook(server => {
88+
let payload = { errors: [{ detail: 'only crates without reverse dependencies can be deleted after 72 hours' }] };
89+
server.delete('/api/v1/crates/foo', payload, 422);
90+
});
91+
92+
await page.goto('/crates/foo/delete');
93+
await page.click('[data-test-confirmation-checkbox]');
94+
await page.click('[data-test-delete-button]');
95+
await expect(page).toHaveURL('/crates/foo/delete');
96+
97+
let message = 'Failed to delete crate: only crates without reverse dependencies can be deleted after 72 hours';
98+
await expect(page.locator('[data-test-notification-message="error"]')).toHaveText(message);
99+
});
100+
});
+4
Loading

0 commit comments

Comments
 (0)