Skip to content
Open
6 changes: 6 additions & 0 deletions src/assets/icons/thumbs-down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions src/assets/icons/thumbs-up.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
151 changes: 142 additions & 9 deletions src/components/Feedback.astro
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
---
import { accelerator } from '@lib/accelerator';
import type { Frontmatter } from 'astro-accelerator-utils/types/Frontmatter';
import { SITE } from '@config';
import { Lang, Translations } from '@util/Languages';
import Button from './Button.astro';
import { RATING_NO, RATING_YES } from '../scripts/modules/feedback-form.js';

const stats = new accelerator.statistics('components/Feedback.astro');
stats.start();
Expand All @@ -10,17 +12,148 @@ stats.start();
type Props = {
lang: string;
frontmatter: Frontmatter;
headings: { depth: number; slug: string; text: string }[];
};
const { lang, frontmatter, headings } = Astro.props satisfies Props;
const { lang, frontmatter } = Astro.props satisfies Props;

// Logic
const heading = await accelerator.markdown.getTextFrom(frontmatter.title);
const formUrl = `https://docs.google.com/forms/d/e/1FAIpQLSehVdN2w6tgSvp5QX7lHGnHDmgKi2Yfvko7bM2izgWQaqg-Wg/viewform?usp=pp_url&entry.336432709=${encodeURIComponent(heading)}`;
// Titles can carry markdown, so they are flattened to text as everywhere else.
const pageTitle = await accelerator.markdown.getTextFrom(frontmatter.title);

// Language
const _ = Lang(lang);

stats.stop();
---

<h2>Help us continuously improve</h2>
<p>Please let us know if you have any feedback about this page.</p>
<p><a href={formUrl} class="button button--primary">Send feedback</a></p>
<section
class="feedback"
data-feedback
data-feedback-page={pageTitle}
aria-labelledby="feedback-question"
>
<div class="feedback__vote">
<p class="feedback__question" id="feedback-question">
{_(Translations.octopus_feedback.question)}
</p>
<div class="feedback__actions">
<Button
label={_(Translations.octopus_feedback.yes)}
icon="feedback__icon feedback__icon--yes"
size="small"
aria-pressed="false"
data-feedback-vote={RATING_YES}
/>
<Button
label={_(Translations.octopus_feedback.no)}
icon="feedback__icon feedback__icon--no"
size="small"
aria-pressed="false"
data-feedback-vote={RATING_NO}
/>
</div>
</div>

<div class="feedback__comment" data-feedback-comment hidden>
<label class="feedback__label" for="feedback-comment">
{_(Translations.octopus_feedback.comment_label)}
</label>
<textarea class="feedback__textarea" id="feedback-comment" rows="3"
></textarea>
<Button
label={_(Translations.octopus_feedback.send)}
size="small"
importance="loud"
data-feedback-send
/>
</div>

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

<script>
import '../scripts/modules/feedback.js';
</script>

<style>
.feedback {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space16);
margin-block-start: var(--space16);
padding: var(--space16);
color: var(--colorTextPrimary);
font: var(--textBodyRegularMedium);
}

.feedback__vote,
.feedback__comment {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space8);
width: 100%;
}

/* Two classes, so this beats the `display: flex` above. */
.feedback [hidden] {
display: none;
}

.feedback__actions {
display: flex;
gap: var(--space8);
}

/* Button.astro renders the icon span, so these sit outside this component's
scope. 20px slot, 16x14 glyph, as the design insets them, and masked
because the exported icons carry a fixed fill. */
.feedback :global(.feedback__icon) {
background-color: var(--colorIconPrimary);
mask-position: center;
mask-repeat: no-repeat;
mask-size: 1rem 0.875rem;
}

.feedback :global(.feedback__icon--yes) {
mask-image: url('../assets/icons/thumbs-up.svg');
}

/* Mirrored, as the design pairs the thumbs facing each other. */
.feedback :global(.feedback__icon--no) {
mask-image: url('../assets/icons/thumbs-down.svg');
transform: scaleX(-1);
}

/* The design calls this out: the label stays regular weight. */
.feedback__label {
font: var(--textBodyRegularMedium);
}

.feedback__textarea {
box-sizing: border-box;
width: 100%;
min-height: 4.375rem;
padding: var(--space8);
border: var(--borderWidth1) solid var(--colorBorderBold);
border-radius: var(--borderRadiusSmall);
background: var(--colorBackgroundPrimaryDefault);
color: var(--colorTextPrimary);
font: var(--textBodyRegularMedium);
resize: vertical;
}

.feedback__textarea:focus-visible {
outline: var(--borderWidth2) solid var(--colorBorderSelected);
outline-offset: var(--borderWidth1);
}

/* The table of contents column restacks above the article below this width, so
the widget would ask the question before anything had been read. */
@media (max-width: 1130px) {
.feedback {
display: none;
}
}
</style>
20 changes: 20 additions & 0 deletions src/data/language.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@
"en": "Legal and support"
}
},
"octopus_feedback": {
"question": {
"en": "Was this page helpful?"
},
"yes": {
"en": "Yes"
},
"no": {
"en": "No"
},
"comment_label": {
"en": "Why did you give this rating? (optional)"
},
"send": {
"en": "Send"
},
"thanks": {
"en": "Thanks for your feedback!"
}
},
"octopus_github": {
"edit_on_github": {
"en": "Edit on GitHub"
Expand Down
8 changes: 2 additions & 6 deletions src/layouts/Default.astro
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import Taxonomy from '@components/Taxonomy.astro';
// Custom components
import ArticleHeader from '../components/ArticleHeader.astro';
import ArticleNav from '../components/ArticleNav.astro';
import Feedback from '../components/Feedback.astro';
import Header from '../components/Header.astro';
import Related from '../components/Related.astro';
import ArticleJourney from '../components/ArticleJourney.astro';
import Feedback from '../components/Feedback.astro';
import EditOnGithub from '../components/EditOnGithub.astro';
import CopyMarkdown from '../components/CopyMarkdown.astro';
import Plausible from 'src/components/Plausible.astro';
Expand Down Expand Up @@ -91,11 +91,6 @@ const lastUpdated = frontmatter.modDate ?? frontmatter.pubDate ?? null;
</div>
<div class="page-content anim-show-parent" itemprop="articleBody">
<slot />
<Feedback
frontmatter={frontmatter}
headings={headings}
lang={lang}
/>
<Authors frontmatter={frontmatter} lang={lang} />
<Taxonomy frontmatter={frontmatter} lang={lang} />
<CopyMarkdown lang={lang} />
Expand Down Expand Up @@ -125,6 +120,7 @@ const lastUpdated = frontmatter.modDate ?? frontmatter.pubDate ?? null;
lang={lang}
/> -->
<ArticleNav headings={headings} lang={lang} />
<Feedback frontmatter={frontmatter} lang={lang} />
</div>
<Footer lang={lang} lastUpdated={lastUpdated} />
</div>
Expand Down
16 changes: 16 additions & 0 deletions src/scripts/modules/feedback-form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// @ts-check

// The Google Form behind the feedback widget. Its three fields, read off the
// live form:
// entry.336432709 "Which page did you view?" short text, optional
// entry.128617088 "How useful was the content?" linear scale 1-5, required
// entry.434783109 "...what we did well, or what we could improve" required
// 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`;
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';
108 changes: 108 additions & 0 deletions src/scripts/modules/feedback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// @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 });
}

/**
* The title alone is ambiguous - "Overview" and "Prerequisites" repeat across
* the docs - and the URL alone is unreadable in a spreadsheet, so the field
* carries both. The hash says which section was open. The query string is
* dropped, as campaign parameters say nothing about the page.
*
* @param {string} title
* @returns {string}
*/
function pageLabel(title) {
const { origin, pathname, hash } = window.location;
const url = `${origin}${pathname}${hash}`;

return title ? `${title} - ${url}` : url;
}

class Feedback {
/** @param {HTMLElement} root */
constructor(root) {
this.root = root;
this.votes = qsa('[data-feedback-vote]', root);
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();
}

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

/** @param {HTMLElement} chosen */
vote(chosen) {
this.rating = chosen.dataset.feedbackVote ?? null;
this.votes.forEach((button) => {
button.setAttribute('aria-pressed', String(button === chosen));
});
this.comment.hidden = false;
}

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

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

try {
await submit(
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;
}
}

qsa('[data-feedback]').forEach((root) => new Feedback(root));
4 changes: 2 additions & 2 deletions src/scripts/modules/headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { copyOnClick } from './copy-button.js';
const REST = 'Copy URL';

/**
* Scoped to .page-content headings with an id: the feedback prompt and the
* navigation render their own headings, and those have nothing to link to.
* Scoped to .page-content headings with an id: the navigation renders its own
* headings, and those have nothing to link to.
*
* Each heading is given an aria-label of its own text. A heading is named from
* its contents, and those contents include a nested control's name, so without
Expand Down
5 changes: 5 additions & 0 deletions src/styles/button.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ img.btn__icon {
background: var(--colorBackgroundPrimaryPressed);
}

/* A toggle button that is currently on holds the pressed background. */
.btn[aria-pressed='true']:not(:disabled, [aria-disabled='true']) {
background: var(--colorBackgroundPrimaryPressed);
}

.btn:is(:disabled, [aria-disabled='true']) {
background: var(--colorButtonBackgroundDisabled);
border-color: var(--colorButtonBorderDisabled);
Expand Down
Loading