diff --git a/app/components/button-with-dialog.hbs b/app/components/button-with-dialog.hbs
new file mode 100644
index 00000000000..2c7220fd7da
--- /dev/null
+++ b/app/components/button-with-dialog.hbs
@@ -0,0 +1,19 @@
+
+
+
+ {{#if this.isOpen}}
+
+ {{yield this.toggleDialog this.isOpen}}
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/app/components/button-with-dialog.js b/app/components/button-with-dialog.js
new file mode 100644
index 00000000000..9d48244cd34
--- /dev/null
+++ b/app/components/button-with-dialog.js
@@ -0,0 +1,11 @@
+import { action } from '@ember/object';
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+
+export default class ButtonWithConfirmationDialog extends Component {
+ @tracked isOpen = false;
+
+ @action toggleDialog(state) {
+ this.isOpen = state === undefined ? !this.isOpen : !!state;
+ }
+}
diff --git a/app/components/button-with-dialog.module.css b/app/components/button-with-dialog.module.css
new file mode 100644
index 00000000000..b896dbbfc9f
--- /dev/null
+++ b/app/components/button-with-dialog.module.css
@@ -0,0 +1,24 @@
+.dialog {
+ inset: 0px;
+ overflow-y: auto;
+ display: flex;
+ justify-content: center;
+ z-index: 20;
+ transition: all var(--transition-fast) ease-in;
+ opacity: 0;
+
+ &.open {
+ position: fixed;
+ opacity: 1;
+ }
+}
+
+.dialog__content {
+ --shadow: 0 25px 50px light-dark(hsla(51, 50%, 44%, 0.35), #232321);
+
+ height: min-content;
+ border-radius: var(--space-3xs);
+ box-shadow: var(--shadow);
+ outline: 1px solid var(--gray-border);
+ margin-top: 12.5vh;
+}
diff --git a/app/components/crate-report-form.hbs b/app/components/crate-report-form.hbs
new file mode 100644
index 00000000000..73fd5ec94d2
--- /dev/null
+++ b/app/components/crate-report-form.hbs
@@ -0,0 +1,66 @@
+
\ No newline at end of file
diff --git a/app/components/crate-report-form.js b/app/components/crate-report-form.js
new file mode 100644
index 00000000000..06acc9076a0
--- /dev/null
+++ b/app/components/crate-report-form.js
@@ -0,0 +1,105 @@
+import { action } from '@ember/object';
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+
+export default class CrateReportForm extends Component {
+ @tracked selectedReasons = [];
+ @tracked detail = '';
+ @tracked reasonsInvalid;
+ @tracked detailInvalid;
+
+ reasons = [
+ {
+ reason: 'spam',
+ description: 'it contains spam',
+ },
+ {
+ reason: 'name-squatting',
+ description: 'it is name-squatting (reserving a crate name without content)',
+ },
+ {
+ reason: 'abuse',
+ description: 'it is abusive or otherwise harmful',
+ },
+ {
+ reason: 'security',
+ description: 'it contains a vulnerability (please try to contact the crate author first)',
+ },
+ {
+ reason: 'other',
+ description: 'it is violating the usage policy in some other way (please specify below)',
+ },
+ ];
+
+ constructor() {
+ super(...arguments);
+ this.reset();
+ }
+
+ reset() {
+ this.reasonsInvalid = false;
+ this.detailInvalid = false;
+ }
+
+ validate() {
+ this.reasonsInvalid = this.selectedReasons.length === 0;
+ this.detailInvalid = this.selectedReasons.includes('other') && !this.detail?.trim();
+ return !this.reasonsInvalid && !this.detailInvalid;
+ }
+
+ @action resetDetailValidation() {
+ this.detailInvalid = false;
+ }
+
+ @action isReasonSelected(reason) {
+ return this.selectedReasons.includes(reason);
+ }
+
+ @action toggleReason(reason) {
+ this.selectedReasons = this.selectedReasons.includes(reason)
+ ? this.selectedReasons.filter(it => it !== reason)
+ : [...this.selectedReasons, reason];
+ this.reasonsInvalid = false;
+ }
+
+ @action updateDetail(event) {
+ let { value } = event?.target ?? {};
+ this.detail = value ?? '';
+ }
+
+ @action cancel() {
+ this.args.close?.();
+ }
+
+ @action submit() {
+ if (!this.validate()) {
+ return;
+ }
+
+ let mailto = this.composeMail();
+ window.open(mailto, '_self');
+ this.args.close?.();
+ }
+
+ composeMail() {
+ let name = this.args.crate ?? '';
+ let reasons = this.reasons
+ .map(({ reason, description }) => {
+ let selected = this.isReasonSelected(reason);
+ return `${selected ? '- [x]' : '- [ ]'} ${description}`;
+ })
+ .join('\n');
+ let body = `I'm reporting the https://crates.io/crates/${name} crate because:
+
+${reasons}
+
+Additional details:
+
+${this.detail}
+`;
+ let subject = `The "${name}" crate`;
+ let address = 'help@crates.io';
+ let mailto = `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ return mailto;
+ }
+}
diff --git a/app/components/crate-report-form.module.css b/app/components/crate-report-form.module.css
new file mode 100644
index 00000000000..429767e1155
--- /dev/null
+++ b/app/components/crate-report-form.module.css
@@ -0,0 +1,69 @@
+.report-form {
+ background-color: var(--main-bg);
+ padding: 0.5rem 1rem;
+}
+
+.form-group {
+ border: none;
+ margin: 0;
+ padding: 0;
+
+ & + & {
+ margin-top: 1rem;
+ }
+}
+
+.form-group-name {
+ composes: form-group-name from '../styles/settings/tokens/new.module.css';
+}
+
+.reasons-list {
+ composes: scopes-list from '../styles/settings/tokens/new.module.css';
+ label {
+ flex-wrap: nowrap;
+ }
+ input {
+ align-self: center;
+ }
+}
+
+.detail {
+ border: 1px solid var(--gray-border);
+ border-radius: var(--space-3xs);
+ resize: vertical;
+ width: 100%;
+
+ &.invalid {
+ background: light-dark(#fff2f2, #170808);
+ border-color: red;
+ }
+}
+
+.form-group-error {
+ composes: form-group-error from '../styles/settings/tokens/new.module.css';
+}
+
+.buttons {
+ composes: buttons from '../styles/settings/tokens/new.module.css';
+ justify-content: end;
+ gap: 2rem;
+}
+
+.button {
+ &:focus {
+ outline: 1px solid var(--bg-color-top-dark);
+ outline-offset: 2px;
+ }
+}
+
+.report-button {
+ composes: button;
+ composes: button small from '../styles/shared/buttons.module.css';
+ border-radius: var(--space-3xs);
+}
+
+.cancel-button {
+ composes: button;
+ composes: tan-button small from '../styles/shared/buttons.module.css';
+ border-radius: var(--space-3xs);
+}
diff --git a/app/components/crate-sidebar.hbs b/app/components/crate-sidebar.hbs
index 1c1d068437b..ff60896827c 100644
--- a/app/components/crate-sidebar.hbs
+++ b/app/components/crate-sidebar.hbs
@@ -130,11 +130,16 @@
{{/unless}}
{{/if}}
-
- Report crate
-
+
+
\ No newline at end of file
diff --git a/e2e/acceptance/crate-report-dialog.spec.ts b/e2e/acceptance/crate-report-dialog.spec.ts
new file mode 100644
index 00000000000..734faf9bf49
--- /dev/null
+++ b/e2e/acceptance/crate-report-dialog.spec.ts
@@ -0,0 +1,85 @@
+import { test, expect } from '@/e2e/helper';
+
+test.describe('Acceptance | crate report dialog', { tag: '@acceptance' }, () => {
+ test.beforeEach(async ({ mirage }) => {
+ await mirage.addHook(server => {
+ let crate = server.create('crate', { name: 'nanomsg' });
+ server.create('version', { crate, num: '0.6.0' });
+ });
+ });
+
+ test('display a report form in dialog', async ({ page, percy, a11y }) => {
+ await page.goto('/crates/nanomsg');
+ await page.click('[data-test-report-button]');
+
+ const dialogContent = page.locator('[data-test-dialog-content]');
+ await expect(dialogContent.locator('[data-test-reasons-group]')).toBeVisible();
+ await expect(dialogContent.locator('[data-test-detail-group]')).toBeVisible();
+ await expect(dialogContent.locator('[data-test-cancel]')).toHaveText('Cancel');
+ await expect(dialogContent.locator('[data-test-report]')).toHaveText('Report');
+
+ await percy.snapshot();
+ await a11y.audit();
+ });
+
+ test('empty reasons selected shows an error', async ({ page }) => {
+ await page.goto('/crates/nanomsg/');
+ await page.click('[data-test-report-button]');
+
+ const dialogContent = page.locator('[data-test-dialog-content]');
+ await dialogContent.locator('[data-test-report]').click();
+ await expect(dialogContent.locator('[data-test-reasons-group] [data-test-error]')).toBeVisible();
+ await expect(dialogContent.locator('[data-test-detail-group] [data-test-error]')).toHaveCount(0);
+ });
+
+ test('other reason selected without given detail shows an error', async ({ page }) => {
+ await page.goto('/crates/nanomsg/');
+ await page.click('[data-test-report-button]');
+
+ const dialogContent = page.locator('[data-test-dialog-content]');
+ await dialogContent.locator('[data-test-reason="spam"]').click();
+ await dialogContent.locator('[data-test-reason="other"]').click();
+ await dialogContent.locator('[data-test-report]').click();
+ await expect(dialogContent.locator('[data-test-reasons-group] [data-test-error]')).toHaveCount(0);
+ await expect(dialogContent.locator('[data-test-detail-group] [data-test-error]')).toBeVisible();
+ });
+
+ test('valid report form should compose a mail and open', async ({ page }) => {
+ // mock `window.open()`
+ await page.addInitScript(() => {
+ globalThis.open = (url, target, features) => {
+ globalThis.openKwargs = { url, target, features };
+ return { document: { write() {}, close() {} }, close() {} } as ReturnType<(typeof globalThis)['open']>;
+ };
+ });
+
+ await page.goto('/crates/nanomsg/');
+ await page.click('[data-test-report-button]');
+
+ const dialogContent = page.locator('[data-test-dialog-content]');
+ await dialogContent.locator('[data-test-reason="spam"]').click();
+ await dialogContent.locator('[data-test-reason="other"]').click();
+ await dialogContent.locator('[data-test-detail]').fill('test detail');
+ await dialogContent.locator('[data-test-report]').click();
+
+ let body = `I'm reporting the https://crates.io/crates/nanomsg crate because:
+
+- [x] it contains spam
+- [ ] it is name-squatting (reserving a crate name without content)
+- [ ] it is abusive or otherwise harmful
+- [ ] it contains a vulnerability (please try to contact the crate author first)
+- [x] it is violating the usage policy in some other way (please specify below)
+
+Additional details:
+
+test detail
+`;
+ let subject = `The "nanomsg" crate`;
+ let address = 'help@crates.io';
+ let mailto = `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ // wait for `window.open()` to be called
+ await page.waitForFunction(expect => globalThis.openKwargs.url === expect, mailto);
+ await page.waitForFunction(expect => globalThis.openKwargs.target === expect, '_self');
+ await expect(dialogContent).not.toBeVisible();
+ });
+});
diff --git a/tests/acceptance/crate-report-dialog.js b/tests/acceptance/crate-report-dialog.js
new file mode 100644
index 00000000000..721d3d7f7f9
--- /dev/null
+++ b/tests/acceptance/crate-report-dialog.js
@@ -0,0 +1,97 @@
+import { click, fillIn } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+
+import percySnapshot from '@percy/ember';
+import a11yAudit from 'ember-a11y-testing/test-support/audit';
+import window from 'ember-window-mock';
+import { setupWindowMock } from 'ember-window-mock/test-support';
+
+import { setupApplicationTest } from 'crates-io/tests/helpers';
+
+import axeConfig from '../axe-config';
+import { visit } from '../helpers/visit-ignoring-abort';
+
+module('Acceptance | create report dialog', function (hooks) {
+ setupApplicationTest(hooks);
+ setupWindowMock(hooks);
+
+ function prepare(context) {
+ let server = context.server;
+ let crate = server.create('crate', { name: 'nanomsg', newest_version: '0.6.0' });
+ server.create('version', { crate, num: '0.6.0' });
+ }
+
+ test('display a report form in dialog', async function (assert) {
+ prepare(this);
+
+ await visit('/crates/nanomsg');
+ await click('[data-test-report-button]');
+
+ assert.dom('[data-test-dialog-content] [data-test-reasons-group]').exists();
+ assert.dom('[data-test-dialog-content] [data-test-detail-group]').exists();
+ assert.dom('[data-test-dialog-content] [data-test-cancel]').hasText('Cancel');
+ assert.dom('[data-test-dialog-content] [data-test-report]').hasText('Report');
+
+ await percySnapshot(assert);
+ await a11yAudit(axeConfig);
+ });
+
+ test('empty reasons selected shows an error', async function (assert) {
+ prepare(this);
+
+ await visit('/crates/nanomsg');
+ await click('[data-test-report-button]');
+
+ await fillIn('[data-test-name]', 'test detail');
+ await click('[data-test-dialog-content] [data-test-report]');
+ assert.dom('[data-test-dialog-content] [data-test-reasons-group] [data-test-error]').exists();
+ assert.dom('[data-test-dialog-content] [data-test-detail-group] [data-test-error]').doesNotExist();
+ });
+
+ test('other reason selected without given detail shows an error', async function (assert) {
+ prepare(this);
+
+ await visit('/crates/nanomsg');
+ await click('[data-test-report-button]');
+
+ await click('[data-test-dialog-content] [data-test-reason="spam"]');
+ await click('[data-test-dialog-content] [data-test-reason="other"]');
+ await click('[data-test-dialog-content] [data-test-report]');
+ assert.dom('[data-test-dialog-content] [data-test-reasons-group] [data-test-error]').doesNotExist();
+ assert.dom('[data-test-dialog-content] [data-test-detail-group] [data-test-error]').exists();
+ });
+
+ test('valid report form should compose a mail and open', async function (assert) {
+ prepare(this);
+ let fakeWindow = { document: { write() {}, close() {} }, close() {} };
+ window.open = (url, target) => {
+ let body = `I'm reporting the https://crates.io/crates/nanomsg crate because:
+
+- [x] it contains spam
+- [ ] it is name-squatting (reserving a crate name without content)
+- [ ] it is abusive or otherwise harmful
+- [ ] it contains a vulnerability (please try to contact the crate author first)
+- [x] it is violating the usage policy in some other way (please specify below)
+
+Additional details:
+
+test detail
+`;
+ let subject = `The "nanomsg" crate`;
+ let address = 'help@crates.io';
+ let mailto = `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ assert.strictEqual(url, mailto);
+ assert.strictEqual(target, '_self');
+ return fakeWindow;
+ };
+
+ await visit('/crates/nanomsg');
+ await click('[data-test-report-button]');
+
+ await click('[data-test-dialog-content] [data-test-reason="spam"]');
+ await click('[data-test-dialog-content] [data-test-reason="other"]');
+ await fillIn('[data-test-name]', 'test detail');
+ await click('[data-test-dialog-content] [data-test-report]');
+ assert.dom('[data-test-dialog-content]').doesNotExist();
+ });
+});