Skip to content

Commit

Permalink
UI: Implement /crates/:name/delete route
Browse files Browse the repository at this point in the history
  • Loading branch information
Turbo87 committed Dec 11, 2024
1 parent aafc6ee commit 796d3f7
Show file tree
Hide file tree
Showing 9 changed files with 422 additions and 0 deletions.
32 changes: 32 additions & 0 deletions app/controllers/crate/delete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

import { task } from 'ember-concurrency';

export default class CrateSettingsController extends Controller {
@service notifications;
@service router;

@tracked isConfirmed;

@action toggleConfirmation() {
this.isConfirmed = !this.isConfirmed;
}

deleteTask = task(async () => {
try {
await this.model.destroyRecord();
this.notifications.success(`Crate ${this.model.name} has been successfully deleted.`);
this.router.transitionTo('index');
} catch (error) {
let detail = error.errors?.[0]?.detail;
if (detail && !detail.startsWith('{')) {
this.notifications.error(`Failed to delete crate: ${detail}`);
} else {
this.notifications.error('Failed to delete crate');
}
}
});
}
1 change: 1 addition & 0 deletions app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Router.map(function () {

this.route('owners');
this.route('settings');
this.route('delete');

// Well-known routes
this.route('docs');
Expand Down
25 changes: 25 additions & 0 deletions app/routes/crate/delete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { inject as service } from '@ember/service';

import AuthenticatedRoute from '../-authenticated-route';

export default class SettingsRoute extends AuthenticatedRoute {
@service router;
@service session;

async afterModel(crate, transition) {
let user = this.session.currentUser;
let owners = await 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',
});
}
}

setupController(controller) {
super.setupController(...arguments);
controller.set('isConfirmed', false);
}
}
2 changes: 2 additions & 0 deletions app/styles/application.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
--orange-800: #9a3412;
--orange-900: #7c2d12;

--yellow100: hsl(44, 100%, 90%);
--yellow500: hsl(44, 100%, 60%);
--yellow700: hsl(44, 67%, 50%);
--yellow800: hsl(44, 67%, 20%);

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

Expand Down
86 changes: 86 additions & 0 deletions app/styles/crate/delete.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
.wrapper {
display: grid;
grid-template-columns: minmax(0, 1fr);
place-items: center;
margin: var(--space-s);
}

.content {
max-width: 100%;
overflow-wrap: break-word;
}

.title {
margin-top: 0;
}

.warning-block {
background: light-dark(var(--yellow100), var(--yellow800));
border-color: var(--yellow500);
border-left-style: solid;
border-left-width: 4px;
border-top-right-radius: var(--space-3xs);
border-bottom-right-radius: var(--space-3xs);
padding: var(--space-xs);
}

.warning {
composes: warning-block;
display: flex;

svg {
flex-shrink: 0;
width: 1em;
height: 1em;
color: var(--yellow500);
}

p {
margin: 0 0 0 var(--space-xs);
text-wrap: pretty;
}
}

.impact, .requirements {
li {
margin-bottom: var(--space-2xs);
}
}

.requirements {
ul {
list-style: none;
padding-left: 0;
}
}

.confirmation {
composes: warning-block;
display: block;

input {
margin-right: var(--space-3xs);
}
}

.actions {
margin-top: var(--space-m);
display: flex;
justify-content: center;
align-items: center;
}

.delete-button {
composes: red-button from '../shared/buttons.module.css';
}

.spinner-wrapper {
position: relative;
}

.spinner {
position: absolute;
--spinner-size: 1.5em;
top: calc(-.5 * var(--spinner-size));
margin-left: var(--space-xs);
}
64 changes: 64 additions & 0 deletions app/templates/crate/delete.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<div local-class="wrapper">
<div local-class="content">
<h1 local-class="title" data-test-title>Delete the {{@model.name}} crate?</h1>

<p>Are you sure you want to delete the crate "{{@model.name}}"?</p>

<div local-class="warning">
{{svg-jar "triangle-exclamation"}}
<p><strong>Important:</strong> This action will permanently delete the crate and its associated versions. Deleting a crate cannot be reversed!</p>
</div>

<div local-class="impact">
<h3>Potential Impact:</h3>
<ul>
<li>Users will no longer be able to download this crate.</li>
<li>Any dependencies or projects relying on this crate will be broken.</li>
<li>Deleted crates cannot be restored.</li>
</ul>
</div>

<div local-class="requirements">
<h3>Requirements:</h3>
<p>A crate can only be deleted if:</p>
<ol>
<li>the crate has been published for less than 72 hours, or</li>
<li>
<ul>
<li>the crate only has a single owner, and</li>
<li>the crate has been downloaded less than 100 times for each month it has been published, and</li>
<li>the crate is not depended upon by any other crate on crates.io.</li>
</ul>
</li>
</ol>
</div>

<label local-class="confirmation">
<Input
@type="checkbox"
@checked={{this.isConfirmed}}
disabled={{this.deleteTask.isRunning}}
data-test-confirmation-checkbox
{{on "change" this.toggleConfirmation}}
/>
I understand that deleting this crate is permanent and cannot be undone.
</label>

<div local-class="actions">
<button
type="submit"
disabled={{or (not this.isConfirmed) this.deleteTask.isRunning}}
local-class="delete-button"
data-test-delete-button
{{on "click" (perform this.deleteTask)}}
>
Delete this crate
</button>
{{#if this.deleteTask.isRunning}}
<div local-class="spinner-wrapper">
<LoadingSpinner local-class="spinner" data-test-spinner />
</div>
{{/if}}
</div>
</div>
</div>
100 changes: 100 additions & 0 deletions e2e/routes/crate/delete.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { expect, test } from '@/e2e/helper';

test.describe('Route: crate.delete', { tag: '@routes' }, () => {
async function prepare({ mirage }) {
await mirage.addHook(server => {
let user = server.create('user');

let crate = server.create('crate', { name: 'foo' });
server.create('version', { crate });
server.create('crate-ownership', { crate, user });

authenticateAs(user);
});
}

test('unauthenticated', async ({ mirage, page }) => {
await mirage.addHook(server => {
let crate = server.create('crate', { name: 'foo' });
server.create('version', { crate });
});

await page.goto('/crates/foo/delete');
await expect(page).toHaveURL('/crates/foo/delete');
await expect(page.locator('[data-test-title]')).toHaveText('This page requires authentication');
await expect(page.locator('[data-test-login]')).toBeVisible();
});

test('not an owner', async ({ mirage, page }) => {
await mirage.addHook(server => {
let user1 = server.create('user');
authenticateAs(user1);

let user2 = server.create('user');
let crate = server.create('crate', { name: 'foo' });
server.create('version', { crate });
server.create('crate-ownership', { crate, user: user2 });
});

await page.goto('/crates/foo/delete');
await expect(page).toHaveURL('/crates/foo/delete');
await expect(page.locator('[data-test-title]')).toHaveText('This page is only accessible by crate owners');
await expect(page.locator('[data-test-go-back]')).toBeVisible();
});

test('happy path', async ({ mirage, page, percy }) => {
await prepare({ mirage });

await page.goto('/crates/foo/delete');
await expect(page).toHaveURL('/crates/foo/delete');
await expect(page.locator('[data-test-title]')).toHaveText('Delete the foo crate?');
await percy.snapshot();

await expect(page.locator('[data-test-delete-button]')).toBeDisabled();
await page.click('[data-test-confirmation-checkbox]');
await expect(page.locator('[data-test-delete-button]')).toBeEnabled();
await page.click('[data-test-delete-button]');

await expect(page).toHaveURL('/');

let message = 'Crate foo has been successfully deleted.';
await expect(page.locator('[data-test-notification-message="success"]')).toHaveText(message);

let crate = await page.evaluate(() => server.schema.crates.findBy({ name: 'foo' }));
expect(crate).toBeNull();
});

test('loading state', async ({ page, mirage }) => {
await prepare({ mirage });
await mirage.addHook(server => {
globalThis.deferred = require('rsvp').defer();
server.delete('/api/v1/crates/foo', () => globalThis.deferred.promise);
});

await page.goto('/crates/foo/delete');
await page.click('[data-test-confirmation-checkbox]');
await page.click('[data-test-delete-button]');
await expect(page.locator('[data-test-spinner]')).toBeVisible();
await expect(page.locator('[data-test-confirmation-checkbox]')).toBeDisabled();
await expect(page.locator('[data-test-delete-button]')).toBeDisabled();

await page.evaluate(async () => globalThis.deferred.resolve());
await expect(page).toHaveURL('/');
});

test('error state', async ({ page, mirage }) => {
await prepare({ mirage });
await mirage.addHook(server => {
let payload = { errors: [{ detail: 'only crates without reverse dependencies can be deleted after 72 hours' }] };
server.delete('/api/v1/crates/foo', payload, 422);
});

await page.goto('/crates/foo/delete');
await page.click('[data-test-confirmation-checkbox]');
await page.click('[data-test-delete-button]');
await expect(page).toHaveURL('/crates/foo/delete');

let message = 'Failed to delete crate: only crates without reverse dependencies can be deleted after 72 hours';
await expect(page.locator('[data-test-notification-message="error"]')).toHaveText(message);
});
});
4 changes: 4 additions & 0 deletions public/assets/triangle-exclamation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 796d3f7

Please sign in to comment.