Feature/260 email the upload link #292
@@ -14,6 +14,7 @@ const { Paragraph, Text } = Typography;
|
|||||||
interface UploadLink {
|
interface UploadLink {
|
||||||
id: number;
|
id: number;
|
||||||
label: string;
|
label: string;
|
||||||
|
contact_email: string | null;
|
||||||
revoked_at: string | null;
|
revoked_at: string | null;
|
||||||
submission_count: number;
|
submission_count: number;
|
||||||
max_submissions: number | null;
|
max_submissions: number | null;
|
||||||
@@ -36,11 +37,16 @@ const DEFAULT_CAP = '25';
|
|||||||
export default function UploadLinks() {
|
export default function UploadLinks() {
|
||||||
const [links, setLinks] = useState<UploadLink[]>([]);
|
const [links, setLinks] = useState<UploadLink[]>([]);
|
||||||
const [label, setLabel] = useState('');
|
const [label, setLabel] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
const [cap, setCap] = useState(DEFAULT_CAP);
|
const [cap, setCap] = useState(DEFAULT_CAP);
|
||||||
const [unlimited, setUnlimited] = useState(false);
|
const [unlimited, setUnlimited] = useState(false);
|
||||||
// Held only in component state and shown once. A refresh loses it, which is
|
// Held only in component state and shown once. A refresh loses it, which is
|
||||||
// the honest behaviour: the server genuinely cannot produce it again.
|
// the honest behaviour: the server genuinely cannot produce it again.
|
||||||
const [issued, setIssued] = useState<string | null>(null);
|
const [issued, setIssued] = useState<string | null>(null);
|
||||||
|
// Whether the link that is currently on screen was actually emailed. Separate
|
||||||
|
// from `issued` so the one-time display of the token keeps working exactly as
|
||||||
|
// it did; this only adds a note beside it.
|
||||||
|
const [mailed, setMailed] = useState<boolean | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
@@ -57,6 +63,7 @@ export default function UploadLinks() {
|
|||||||
async function create() {
|
async function create() {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setMailed(null);
|
||||||
|
|
||||||
const res = await fetch('/api/admin/upload-links', {
|
const res = await fetch('/api/admin/upload-links', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -66,6 +73,7 @@ export default function UploadLinks() {
|
|||||||
// that are not this screen.
|
// that are not this screen.
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
label,
|
label,
|
||||||
|
email,
|
||||||
maxSubmissions: unlimited ? null : Number(cap)
|
maxSubmissions: unlimited ? null : Number(cap)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -80,7 +88,9 @@ export default function UploadLinks() {
|
|||||||
|
|
||||||
const created = await res.json();
|
const created = await res.json();
|
||||||
setIssued(created.url);
|
setIssued(created.url);
|
||||||
|
setMailed(created.mail.sent);
|
||||||
setLabel('');
|
setLabel('');
|
||||||
|
setEmail('');
|
||||||
setCap(DEFAULT_CAP);
|
setCap(DEFAULT_CAP);
|
||||||
setUnlimited(false);
|
setUnlimited(false);
|
||||||
await load();
|
await load();
|
||||||
@@ -106,6 +116,13 @@ export default function UploadLinks() {
|
|||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
style={{ width: 260 }}
|
style={{ width: 260 }}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
aria-label="Contributor email"
|
||||||
|
placeholder="Where should the link be sent?"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
style={{ width: 260 }}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Max uses"
|
placeholder="Max uses"
|
||||||
aria-label="Maximum uses"
|
aria-label="Maximum uses"
|
||||||
@@ -117,13 +134,27 @@ export default function UploadLinks() {
|
|||||||
<Checkbox checked={unlimited} onChange={(e) => setUnlimited(e.target.checked)}>
|
<Checkbox checked={unlimited} onChange={(e) => setUnlimited(e.target.checked)}>
|
||||||
No limit
|
No limit
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Button type="primary" onClick={create} loading={creating} disabled={label.trim() === ''}>
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={create}
|
||||||
|
loading={creating}
|
||||||
|
disabled={label.trim() === '' || email.trim() === ''}
|
||||||
|
>
|
||||||
Create link
|
Create link
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
{error && <Alert type="error" message={error} showIcon />}
|
{error && <Alert type="error" message={error} showIcon />}
|
||||||
|
|
||||||
|
{issued && mailed === false && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="The link was not emailed"
|
||||||
|
description="Copy it below and send it yourself. This is normal where no mail is configured, and in QA, where delivery is restricted to a fixed list of addresses."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{issued && (
|
{issued && (
|
||||||
<Alert
|
<Alert
|
||||||
type="success"
|
type="success"
|
||||||
@@ -151,6 +182,7 @@ export default function UploadLinks() {
|
|||||||
pagination={false}
|
pagination={false}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: 'Label', dataIndex: 'label' },
|
{ title: 'Label', dataIndex: 'label' },
|
||||||
|
{ title: 'Sent to', dataIndex: 'contact_email' },
|
||||||
{
|
{
|
||||||
title: 'Used',
|
title: 'Used',
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ let token: string;
|
|||||||
test.beforeAll(async ({ playwright }) => {
|
test.beforeAll(async ({ playwright }) => {
|
||||||
const api = await createAdminContext(playwright);
|
const api = await createAdminContext(playwright);
|
||||||
const res = await api.post('/api/admin/upload-links', {
|
const res = await api.post('/api/admin/upload-links', {
|
||||||
data: { label: `Review queue spec ${RUN}` }
|
data: { label: `Review queue spec ${RUN}`, email: `draft-queue-${RUN}@example.com` }
|
||||||
});
|
});
|
||||||
expect(res.status(), 'creating the upload link').toBe(201);
|
expect(res.status(), 'creating the upload link').toBe(201);
|
||||||
token = (await res.json()).token;
|
token = (await res.json()).token;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ test.describe('Managing upload links', () => {
|
|||||||
await page.getByRole('tab', { name: 'Upload links' }).click();
|
await page.getByRole('tab', { name: 'Upload links' }).click();
|
||||||
|
|
||||||
await page.getByLabel('Link label').fill(label);
|
await page.getByLabel('Link label').fill(label);
|
||||||
|
await page.getByLabel('Contributor email').fill(`${uniqueSuffix()}@example.com`);
|
||||||
await page.getByRole('button', { name: 'Create link' }).click();
|
await page.getByRole('button', { name: 'Create link' }).click();
|
||||||
|
|
||||||
// Shown exactly once. The server keeps only a digest, so there is no
|
// Shown exactly once. The server keeps only a digest, so there is no
|
||||||
@@ -38,6 +39,7 @@ test.describe('Managing upload links', () => {
|
|||||||
await admin.goto();
|
await admin.goto();
|
||||||
await page.getByRole('tab', { name: 'Upload links' }).click();
|
await page.getByRole('tab', { name: 'Upload links' }).click();
|
||||||
await page.getByLabel('Link label').fill(label);
|
await page.getByLabel('Link label').fill(label);
|
||||||
|
await page.getByLabel('Contributor email').fill(`${uniqueSuffix()}@example.com`);
|
||||||
await page.getByRole('button', { name: 'Create link' }).click();
|
await page.getByRole('button', { name: 'Create link' }).click();
|
||||||
|
|
||||||
const row = page.getByRole('row', { name: new RegExp(label) });
|
const row = page.getByRole('row', { name: new RegExp(label) });
|
||||||
@@ -61,6 +63,7 @@ test.describe('Managing upload links', () => {
|
|||||||
await admin.goto();
|
await admin.goto();
|
||||||
await page.getByRole('tab', { name: 'Upload links' }).click();
|
await page.getByRole('tab', { name: 'Upload links' }).click();
|
||||||
await page.getByLabel('Link label').fill(label);
|
await page.getByLabel('Link label').fill(label);
|
||||||
|
await page.getByLabel('Contributor email').fill(`${uniqueSuffix()}@example.com`);
|
||||||
await page.getByText('No limit').click();
|
await page.getByText('No limit').click();
|
||||||
await page.getByRole('button', { name: 'Create link' }).click();
|
await page.getByRole('button', { name: 'Create link' }).click();
|
||||||
|
|
||||||
@@ -69,4 +72,14 @@ test.describe('Managing upload links', () => {
|
|||||||
// A bare count rather than "0 of N".
|
// A bare count rather than "0 of N".
|
||||||
await expect(row.getByText('0 of', { exact: false })).toHaveCount(0);
|
await expect(row.getByText('0 of', { exact: false })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The address is the point of the change: a link nobody can be sent is the
|
||||||
|
// thing this replaced.
|
||||||
|
test('will not create a link without an address', async ({ page, admin }) => {
|
||||||
|
await admin.goto();
|
||||||
|
await page.getByRole('tab', { name: 'Upload links' }).click();
|
||||||
|
await page.getByLabel('Link label').fill(`Nameless ${uniqueSuffix()}`);
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: 'Create link' })).toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ test.beforeAll(async ({ playwright }) => {
|
|||||||
const api = await createAdminContext(playwright);
|
const api = await createAdminContext(playwright);
|
||||||
|
|
||||||
const res = await api.post('/api/admin/upload-links', {
|
const res = await api.post('/api/admin/upload-links', {
|
||||||
data: { label: `Intake spec ${RUN}` }
|
data: { label: `Intake spec ${RUN}`, email: `intake-${RUN}@example.com` }
|
||||||
});
|
});
|
||||||
expect(res.status(), 'creating the upload link').toBe(201);
|
expect(res.status(), 'creating the upload link').toBe(201);
|
||||||
token = (await res.json()).token;
|
token = (await res.json()).token;
|
||||||
|
|||||||
Reference in New Issue
Block a user