Skip to content
Open
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
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.
145 changes: 134 additions & 11 deletions src/components/Feedback.astro
Original file line number Diff line number Diff line change
@@ -1,26 +1,149 @@
---
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();

// Properties
type Props = {
lang: string;
frontmatter: Frontmatter;
headings: { depth: number; slug: string; text: string }[];
};
const { lang, frontmatter, headings } = Astro.props satisfies Props;
const { lang } = 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)}`;
// 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 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 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';
87 changes: 87 additions & 0 deletions src/scripts/modules/feedback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// @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} rating
* @param {string} comment
* @returns {Promise<void>}
*/
async function submit(rating, comment) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really a "hack" to get the UI in before actually wiring up the real deal.

We should look at GTM or something to publish on click of thumbs up / down etc and maybe look at a different mechanism for the content rather than forms.

const body = new URLSearchParams();
body.set(FIELD_PAGE, window.location.href);
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 });
}

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(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