Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/components/Feedback.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { accelerator } from '@lib/accelerator';
import type { Frontmatter } from 'astro-accelerator-utils/types/Frontmatter';
import { Lang, Translations } from '@util/Languages';
import Button from './Button.astro';
import { RATING_NO, RATING_YES } from '../scripts/modules/feedback-form.js';
import {
prefillUrl,
RATING_NO,
RATING_YES,
} from '../scripts/modules/feedback-form.js';

const stats = new accelerator.statistics('components/Feedback.astro');
stats.start();
Expand All @@ -21,6 +25,11 @@ const pageTitle = await accelerator.markdown.getTextFrom(frontmatter.title);
// Language
const _ = Lang(lang);

// The link the reader follows before any of the widget's own script has run,
// carrying what is known at build time. The script adds the rating, the comment
// and the full address once someone starts answering.
const feedbackUrl = prefillUrl(pageTitle, null, '');

stats.stop();
---

Expand Down Expand Up @@ -62,13 +71,10 @@ stats.stop();
label={_(Translations.octopus_feedback.send)}
size="small"
importance="loud"
href={feedbackUrl}
data-feedback-send
/>
</div>

<p class="feedback__thanks" data-feedback-thanks role="status" hidden>
{_(Translations.octopus_feedback.thanks)}
</p>
</section>

<script>
Expand Down
4 changes: 2 additions & 2 deletions src/data/language.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@
"en": "No"
},
"comment_label": {
"en": "Why did you give this rating? (optional)"
"en": "Why did you give this rating? (required)"
},
"send": {
"en": "Send"
"en": "Send feedback"
},
"thanks": {
"en": "Thanks for your feedback!"
Expand Down
29 changes: 28 additions & 1 deletion src/scripts/modules/feedback-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,36 @@
// Yes maps to 5 and No to 1 - the form has no yes/no field to send to.
export const FORM_ID =
'1FAIpQLSehVdN2w6tgSvp5QX7lHGnHDmgKi2Yfvko7bM2izgWQaqg-Wg';
export const FORM_URL = `https://docs.google.com/forms/d/e/${FORM_ID}/formResponse`;

// The form as a reader sees it. Google fills a field from the query string when
// `usp=pp_url` is set, so the widget hands over what has been answered so far
// and the reader presses submit on Google's own page.
export const VIEW_URL = `https://docs.google.com/forms/d/e/${FORM_ID}/viewform`;
export const FIELD_PAGE = 'entry.336432709';
export const FIELD_RATING = 'entry.128617088';
export const FIELD_COMMENT = 'entry.434783109';
export const RATING_YES = '5';
export const RATING_NO = '1';

// A prefilled answer travels in the URL, and browsers stop honouring one a few
// thousand characters in, so a very long one is cut. The reader sees the cut
// text sitting in the form and can finish it there.
export const COMMENT_LIMIT = 1500;

/**
* @param {string} page
* @param {string | null} rating
* @param {string} comment
* @returns {string}
*/
export function prefillUrl(page, rating, comment) {
const params = new URLSearchParams({ usp: 'pp_url' });
params.set(FIELD_PAGE, page);

if (rating) params.set(FIELD_RATING, rating);

const answer = comment.trim().slice(0, COMMENT_LIMIT);
if (answer) params.set(FIELD_COMMENT, answer);

return `${VIEW_URL}?${params}`;
}
68 changes: 16 additions & 52 deletions src/scripts/modules/feedback.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,6 @@
// @ts-check
import { qs, qsa } from './query.js';
import {
FIELD_COMMENT,
FIELD_PAGE,
FIELD_RATING,
FORM_URL,
} from './feedback-form.js';

/**
* Google Forms sends no CORS headers, so the POST has to go out as no-cors and
* the response comes back opaque. There is no way to read whether the form
* accepted it - a rejected submission looks identical to an accepted one, so
* anything Google answered at all counts as accepted. A change to the form's
* fields would therefore go unnoticed here.
*
* @param {string} page
* @param {string} rating
* @param {string} comment
* @returns {Promise<void>}
*/
async function submit(page, rating, comment) {
const body = new URLSearchParams();
body.set(FIELD_PAGE, page);
body.set(FIELD_RATING, rating);
// The comment is marked required on the form, so a blank box still has to
// send something for the submission to be accepted at all.
body.set(FIELD_COMMENT, comment.trim() || ' ');

await fetch(FORM_URL, { method: 'POST', mode: 'no-cors', body });
}
import { prefillUrl } from './feedback-form.js';

/**
* The title alone is ambiguous - "Overview" and "Prerequisites" repeat across
Expand All @@ -46,6 +18,13 @@ function pageLabel(title) {
return title ? `${title} - ${url}` : url;
}

/**
* Nothing is submitted from here. The widget collects the rating and the
* comment, then hands the reader to the form with both filled in, and the
* submission happens there. Send is an ordinary link, so it still reaches the
* form with the page prefilled if this script never runs, and the site's
* external link handling gives it its own tab.
*/
class Feedback {
/** @param {HTMLElement} root */
constructor(root) {
Expand All @@ -54,18 +33,18 @@ class Feedback {
this.comment = qs('[data-feedback-comment]', root);
this.textarea = qs('textarea', root);
this.send = qs('[data-feedback-send]', root);
this.thanks = qs('[data-feedback-thanks]', root);
/** @type {string | null} */
this.rating = null;

this.addListeners();
this.updateLink();
}

addListeners() {
this.votes.forEach((button) => {
button.addEventListener('click', () => this.vote(button));
});
this.send.addEventListener('click', () => this.submit());
this.textarea.addEventListener('input', () => this.updateLink());
}

/** @param {HTMLElement} chosen */
Expand All @@ -75,33 +54,18 @@ class Feedback {
button.setAttribute('aria-pressed', String(button === chosen));
});
this.comment.hidden = false;
this.updateLink();
}

async submit() {
if (!this.rating) return;

// Guards against a second submission while the first is in flight.
this.send.setAttribute('disabled', '');

try {
await submit(
updateLink() {
this.send.setAttribute(
'href',
prefillUrl(
pageLabel(this.root.dataset.feedbackPage ?? ''),
this.rating,
this.textarea.value
);
} catch (err) {
// Offline, or blocked by an extension - it never left the browser.
console.warn('[feedback] submission failed', err);
this.send.removeAttribute('disabled');
return;
}

this.root.querySelectorAll('.feedback__vote, .feedback__comment').forEach(
/** @param {Element} el */ (el) => {
/** @type {HTMLElement} */ (el).hidden = true;
}
)
);
this.thanks.hidden = false;
}
}

Expand Down
103 changes: 66 additions & 37 deletions tests/feedback.spec.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
import { test, expect } from '@playwright/test';
import { test, expect, type Page } from '@playwright/test';
import {
COMMENT_LIMIT,
FIELD_COMMENT,
FIELD_PAGE,
FIELD_RATING,
RATING_NO,
RATING_YES,
VIEW_URL,
} from '../src/scripts/modules/feedback-form.js';

const PAGE = '/docs/kubernetes/steps/kustomize';
const TITLE = 'Deploy with Kustomize';

/** The prefilled form the Send link points at, in parts. */
async function sendLink(page: Page) {
const href = await page.locator('[data-feedback-send]').getAttribute('href');

return new URL(href ?? '');
}

test.describe('feedback widget', () => {
test.beforeEach(async ({ page }) => {
// Blocked for every test, so no run can post to the live form. The success
// case below overrides this: Playwright matches the newest route first.
// Nothing is posted from the page any more, so an attempt to reach Google
// fails the run rather than arriving.
await page.route('**/docs.google.com/**', (route) => route.abort());
await page.goto(PAGE);
});

test('asks the question alone until a rating is given', async ({ page }) => {
await expect(page.locator('.feedback__question')).toBeVisible();
await expect(page.locator('.feedback__comment')).toBeHidden();
await expect(page.locator('.feedback__thanks')).toBeHidden();
});

test('discloses the comment box on a rating', async ({ page }) => {
Expand All @@ -47,55 +56,75 @@ test.describe('feedback widget', () => {
).toHaveAttribute('aria-pressed', 'true');
});

test('thanks the reader once the submission has gone out', async ({
test('points at the form with the page filled in before anything is answered', async ({
page,
baseURL,
}) => {
await page.route('**/docs.google.com/**', (route) =>
route.fulfill({ status: 200, body: '' })
const link = await sendLink(page);

expect(link.origin + link.pathname).toBe(VIEW_URL);
expect(link.searchParams.get('usp')).toBe('pp_url');
expect(link.searchParams.get(FIELD_PAGE)).toBe(
`${TITLE} - ${baseURL}${PAGE}`
);
expect(link.searchParams.has(FIELD_RATING)).toBe(false);
expect(link.searchParams.has(FIELD_COMMENT)).toBe(false);
});

await page.locator(`[data-feedback-vote="${RATING_YES}"]`).click();
await page.locator('[data-feedback-send]').click();
test('carries the rating and the comment as they are answered', async ({
page,
baseURL,
}) => {
await page.locator(`[data-feedback-vote="${RATING_NO}"]`).click();
await page.locator('.feedback__textarea').fill('the diagram is wrong');

await expect(page.locator('.feedback__thanks')).toBeVisible();
await expect(page.locator('.feedback__vote')).toBeHidden();
await expect(page.locator('.feedback__comment')).toBeHidden();
const link = await sendLink(page);

expect(link.searchParams.get(FIELD_PAGE)).toBe(
`${TITLE} - ${baseURL}${PAGE}`
);
expect(link.searchParams.get(FIELD_RATING)).toBe(RATING_NO);
expect(link.searchParams.get(FIELD_COMMENT)).toBe('the diagram is wrong');
});

test('sends the page title and url, the rating and the comment', async ({
test('leaves a long comment for the reader to finish on the form', async ({
page,
}) => {
/** @type {string | null} */
let body = null;
await page.route('**/docs.google.com/**', (route) => {
body = route.request().postData();
return route.fulfill({ status: 200, body: '' });
});

await page.locator(`[data-feedback-vote="${RATING_YES}"]`).click();
await page.locator('.feedback__textarea').fill('the kustomize page');
await page.locator('[data-feedback-send]').click();
await expect(page.locator('.feedback__thanks')).toBeVisible();
await page
.locator('.feedback__textarea')
.fill('x'.repeat(COMMENT_LIMIT * 2));

const link = await sendLink(page);

expect(link.searchParams.get(FIELD_COMMENT)).toHaveLength(COMMENT_LIMIT);
});

const sent = new URLSearchParams(body ?? '');
expect(sent.get(FIELD_PAGE)).toBe(
`Deploy with Kustomize - http://localhost:3000${PAGE}`
test('opens the form in its own tab', async ({ page }) => {
// The site's external link handling owns this, so the widget inherits it.
await expect(page.locator('[data-feedback-send]')).toHaveAttribute(
'target',
'_blank'
);
await expect(page.locator('[data-feedback-send]')).toHaveAttribute(
'rel',
'noopener'
);
expect(sent.get(FIELD_RATING)).toBe(RATING_YES);
expect(sent.get(FIELD_COMMENT)).toBe('the kustomize page');
});

test('keeps the form up when the submission never leaves the browser', async ({
test('posts nothing itself, however the widget is worked', async ({
page,
}) => {
const reached: string[] = [];
page.on('request', (request) => {
if (request.url().includes('docs.google.com'))
reached.push(request.url());
});

await page.locator(`[data-feedback-vote="${RATING_YES}"]`).click();
await page.locator('.feedback__textarea').fill('anything at all');
await page.locator(`[data-feedback-vote="${RATING_NO}"]`).click();
await page.locator('[data-feedback-send]').click();

// Send is disabled for the attempt and only comes back on the failure, so
// this settles after the handler has run. The two checks below would each
// pass against a handler that had not reached its catch yet.
await expect(page.locator('[data-feedback-send]')).toBeEnabled();
await expect(page.locator('.feedback__thanks')).toBeHidden();
await expect(page.locator('.feedback__vote')).toBeVisible();

expect(reached).toEqual([]);
});
});