Skip to content

Commit

Permalink
Add feedback form with FeedbackBlock
Browse files Browse the repository at this point in the history
- Adds a feedback form allowing visitors to enter their name and e-mail,
  and optionally their company. Data will be submitted to an API
  (pending).

- Adds a Sentiment component which allows visitors to select how they
  feed about the page.

- Moves EmoticonButton into the Sentiment module since it's unlikely to
  be used elsewhere.

Ideally, I think I'd like to lift the calls to `fetch` out to a parent
component for both Sentiment and SubscriptionForm, but for now this
feels like a nice and simple way of doing what we need.
  • Loading branch information
antw committed Jul 5, 2022
1 parent d050124 commit 36cb4a1
Show file tree
Hide file tree
Showing 19 changed files with 4,815 additions and 13,867 deletions.
1 change: 1 addition & 0 deletions frontend/.env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
6 changes: 4 additions & 2 deletions frontend/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"extends": [
"next/core-web-vitals",
"next/core-web-vitals",
"plugin:react/recommended"
],
"parserOptions": {
Expand All @@ -11,5 +11,7 @@
"sourceType": "module"
},
"plugins": ["react"],
"rules": {}
"rules": {
"react/react-in-jsx-scope": "off"
}
}
8 changes: 7 additions & 1 deletion frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next

## Getting Started

First, run the development server:
Copy the .env.sample file to .env.local:

```bash
cp .env.sample .env.local
```

Next run the development server:

```bash
npm run dev
Expand Down
6 changes: 5 additions & 1 deletion frontend/components/Buttons/Button.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,18 @@ describe("Button", () => {
});

describe("rendering an Icon outside of a button", () => {
afterAll(jest.restoreAllMocks);

it("throws an error", () => {
jest.spyOn(console, "error").mockImplementation(() => jest.fn());

expect(() => {
render(
<Button.Icon>
<div>Something</div>
</Button.Icon>
);
}).toThrow(/must be used within a Button/);
}).toThrow();
});
});
});
19 changes: 13 additions & 6 deletions frontend/components/Buttons/HolonButton.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,35 @@
import React, { createContext, useContext } from "react";
import PropTypes from "prop-types";

const variants = {
darkmode:
"border-white text-white bg-holon-blue-900 shadow-holon-white hover:translate-x-holon-bh-x hover:translate-y-holon-bh-y hover:bg-holon-blue-500 hover:shadow-holon-white-hover",
gold: "bg-holon-gold-200 border-holon-blue-900 shadow-holon-blue hover:translate-x-holon-bh-x hover:translate-y-holon-bh-y hover:bg-holon-gold-600 hover:shadow-holon-blue-hover",
blue: "bg-holon-blue-200 border-holon-blue-900 shadow-holon-blue hover:translate-x-holon-bh-x hover:translate-y-holon-bh-y hover:bg-holon-blue-500 hover:shadow-holon-blue-hover hover:text-white",
"border-white text-white bg-holon-blue-900 shadow-holon-white enabled:hover:bg-holon-blue-500 enabled:active:shadow-holon-white-hover",
gold: "bg-holon-gold-200 border-holon-blue-900 shadow-holon-blue hover:bg-holon-gold-600 active:shadow-holon-blue-hover",
blue: "bg-holon-blue-200 border-holon-blue-900 shadow-holon-blue hover:bg-holon-blue-500 active:shadow-holon-blue-hover hover:text-white",
darkblue:
"text-white bg-holon-blue-500 border-holon-blue-900 shadow-holon-blue hover:translate-x-holon-bh-x hover:translate-y-holon-bh-y hover:bg-holon-blue-900 hover:shadow-holon-blue-hover",
"text-white bg-holon-blue-500 border-holon-blue-900 shadow-holon-blue hover:bg-holon-blue-900 active:shadow-holon-blue-hover",
};

const ButtonContext = createContext();

export default function Button({ children, variant = "darkmode", ...rest }) {
export default function Button({ children, className, variant = "darkmode", ...rest }) {
const colorClasses = variants[variant] || variants.darkmode;
return (
<button
className={`${colorClasses} relative m-2 w-72 rounded-md border-2 p-3 font-medium leading-5`}
className={`${className} ${colorClasses} relative m-2 w-72 rounded-md border-2 p-3 font-medium leading-5 transition enabled:active:translate-x-holon-bh-x enabled:active:translate-y-holon-bh-y disabled:opacity-50`.trim()}
{...rest}
>
<ButtonContext.Provider value={variant}>{children}</ButtonContext.Provider>
</button>
);
}

Button.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
variant: PropTypes.oneOf(Object.keys(variants)),
};

/**
* Hook which provides access to the button variant.
*/
Expand Down
73 changes: 73 additions & 0 deletions frontend/components/FeedbackBlock/ButtonLoadingIcon.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
export default function ButtonLoadingIcon() {
return (
// By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL
<svg width="60" height="15" viewBox="0 0 120 30" xmlns="http://www.w3.org/2000/svg" fill="#fff">
<circle cx="15" cy="15" r="15">
<animate
attributeName="r"
from="15"
to="15"
begin="0s"
dur="0.8s"
values="15;9;15"
calcMode="linear"
repeatCount="indefinite"
/>
<animate
attributeName="fill-opacity"
from="1"
to="1"
begin="0s"
dur="0.8s"
values="1;.5;1"
calcMode="linear"
repeatCount="indefinite"
/>
</circle>
<circle cx="60" cy="15" r="9" fillOpacity="0.3">
<animate
attributeName="r"
from="9"
to="9"
begin="0s"
dur="0.8s"
values="9;15;9"
calcMode="linear"
repeatCount="indefinite"
/>
<animate
attributeName="fill-opacity"
from="0.5"
to="0.5"
begin="0s"
dur="0.8s"
values=".5;1;.5"
calcMode="linear"
repeatCount="indefinite"
/>
</circle>
<circle cx="105" cy="15" r="15">
<animate
attributeName="r"
from="15"
to="15"
begin="0s"
dur="0.8s"
values="15;9;15"
calcMode="linear"
repeatCount="indefinite"
/>
<animate
attributeName="fill-opacity"
from="1"
to="1"
begin="0s"
dur="0.8s"
values="1;.5;1"
calcMode="linear"
repeatCount="indefinite"
/>
</circle>
</svg>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { createContext, useContext } from "react";
import PropTypes from "prop-types";

const variants = {
heart: (
Expand All @@ -7,14 +8,11 @@ const variants = {
data-name="Layer 1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 122.88 107.39"
className="ml-auto mr-auto mt-1 h-14 w-14 fill-holon-slated-blue-300 group-hover:fill-white group-focus:fill-white"
className="ml-auto mr-auto mt-1 h-14 w-14"
fill="currentColor"
>
<title>red-heart</title>
<path
class="cls-1"
d="M60.83,17.18c8-8.35,13.62-15.57,26-17C110-2.46,131.27,21.26,119.57,44.61c-3.33,6.65-10.11,14.56-17.61,22.32-8.23,8.52-17.34,16.87-23.72,23.2l-17.4,17.26L46.46,93.55C29.16,76.89,1,55.92,0,29.94-.63,11.74,13.73.08,30.25.29c14.76.2,21,7.54,30.58,16.89Z"
/>
<path d="M60.83,17.18c8-8.35,13.62-15.57,26-17C110-2.46,131.27,21.26,119.57,44.61c-3.33,6.65-10.11,14.56-17.61,22.32-8.23,8.52-17.34,16.87-23.72,23.2l-17.4,17.26L46.46,93.55C29.16,76.89,1,55.92,0,29.94-.63,11.74,13.73.08,30.25.29c14.76.2,21,7.54,30.58,16.89Z" />
</svg>
),
thumbsdown: (
Expand All @@ -25,13 +23,11 @@ const variants = {
x="0px"
y="0px"
viewBox="0 0 122.88 106.16"
className="ml-auto mr-auto mt-2 h-14 w-14 fill-holon-slated-blue-300 group-hover:fill-white group-focus:fill-white"
className="ml-auto mr-auto mt-2 h-14 w-14"
fill="currentColor"
>
<g>
<path
class="st0"
d="M4.03,61.56h27.36c2.21,0,4.02-1.81,4.02-4.02V4.03C35.41,1.81,33.6,0,31.39,0H4.03C1.81,0,0,1.81,0,4.03 v53.51C0,59.75,1.81,61.56,4.03,61.56L4.03,61.56z M63.06,101.7c2.12,10.75,19.72,0.85,20.88-16.48c0.35-5.3-0.2-11.47-1.5-18.36 l25.15,0c10.46-0.41,19.59-7.9,13.14-20.2c1.47-5.36,1.69-11.65-2.3-14.13c0.5-8.46-1.84-13.7-6.22-17.84 c-0.29-4.23-1.19-7.99-3.23-10.88c-3.38-4.77-6.12-3.63-11.44-3.63H55.07c-6.73,0-10.4,1.85-14.8,7.37v47.31 c12.66,3.42,19.39,20.74,22.79,32.11V101.7L63.06,101.7L63.06,101.7z"
/>
<path d="M4.03,61.56h27.36c2.21,0,4.02-1.81,4.02-4.02V4.03C35.41,1.81,33.6,0,31.39,0H4.03C1.81,0,0,1.81,0,4.03 v53.51C0,59.75,1.81,61.56,4.03,61.56L4.03,61.56z M63.06,101.7c2.12,10.75,19.72,0.85,20.88-16.48c0.35-5.3-0.2-11.47-1.5-18.36 l25.15,0c10.46-0.41,19.59-7.9,13.14-20.2c1.47-5.36,1.69-11.65-2.3-14.13c0.5-8.46-1.84-13.7-6.22-17.84 c-0.29-4.23-1.19-7.99-3.23-10.88c-3.38-4.77-6.12-3.63-11.44-3.63H55.07c-6.73,0-10.4,1.85-14.8,7.37v47.31 c12.66,3.42,19.39,20.74,22.79,32.11V101.7L63.06,101.7L63.06,101.7z" />
</g>
</svg>
),
Expand All @@ -43,11 +39,12 @@ const variants = {
x="0px"
y="0px"
viewBox="0 0 122.88 106.16"
className="ml-auto mr-auto mb-3 h-14 w-14 fill-holon-slated-blue-300 group-hover:fill-white group-focus:fill-white"
className="ml-auto mr-auto mb-3 h-14 w-14"
fill="currentColor"
>
<g>
<path
class="st0"
className="st0"
d="M4.02,44.6h27.36c2.21,0,4.02,1.81,4.02,4.03v53.51c0,2.21-1.81,4.03-4.02,4.03H4.02 c-2.21,0-4.02-1.81-4.02-4.03V48.63C0,46.41,1.81,44.6,4.02,44.6L4.02,44.6z M63.06,4.46c2.12-10.75,19.72-0.85,20.88,16.48 c0.35,5.3-0.2,11.47-1.5,18.36l25.15,0c10.46,0.41,19.59,7.9,13.14,20.2c1.47,5.36,1.69,11.65-2.3,14.13 c0.5,8.46-1.84,13.7-6.22,17.84c-0.29,4.23-1.19,7.99-3.23,10.88c-3.38,4.77-6.12,3.63-11.44,3.63H55.07 c-6.73,0-10.4-1.85-14.8-7.37V51.31c12.66-3.42,19.39-20.74,22.79-32.11V4.46L63.06,4.46z"
/>
</g>
Expand All @@ -58,25 +55,26 @@ const variants = {
xmlns="http://www.w3.org/2000/svg"
x="0"
y="0"
className="ml-auto mr-auto mb-3 h-14 w-14 fill-holon-slated-blue-300 stroke-holon-slated-blue-300 group-hover:fill-white group-hover:stroke-white group-focus:fill-white group-focus:stroke-white"
className="ml-auto mr-auto mb-1 h-14 w-14"
fill="currentColor"
>
<g>
<title>Layer 1</title>
<g id="svg_10">
<line
stroke="current"
stroke-width="6"
stroke-linecap="undefined"
stroke-linejoin="undefined"
stroke="currentColor"
strokeWidth="6"
strokeLinecap="undefined"
strokeLinejoin="undefined"
id="svg_1"
y2="45"
x2="48"
y1="45"
x1="8"
fill="none"
/>
<ellipse ry="8" rx="8" id="svg_9" cy="19" cx="42" fill="current" />
<ellipse ry="8" rx="8" id="svg_9" cy="19" cx="15" fill="current" />
<ellipse ry="8" rx="8" id="svg_9" cy="19" cx="42" />
<ellipse ry="8" rx="8" id="svg_9" cy="19" cx="15" />
</g>
</g>
</svg>
Expand All @@ -85,23 +83,29 @@ const variants = {

const ButtonContext = createContext();

export default function EmoticonButton({
children,
variant = "thumbsdown",
...rest
}) {
export default function EmoticonButton({ children, checked, variant = "thumbsdown", ...rest }) {
const svg = variants[variant] || variants.heart;
const colors = checked
? "text-gray-100 border-gray-100 bg-holon-blue-500"
: "text-holon-slated-blue-300 border-holon-slated-blue-300";

return (
<button
className="group aspect-square h-20 w-20 rounded-full border-2 border-holon-slated-blue-300 hover:border-white focus:border-white"
{...rest}
className={`aspect-square h-20 w-20 rounded-full border-2 transition hover:border-white hover:text-white focus:border-white focus:text-white ${colors}`}
>
{svg}
<ButtonContext.Provider value={variant}>{children}</ButtonContext.Provider>
</button>
);
}

EmoticonButton.propTypes = {
children: PropTypes.node,
checked: PropTypes.bool,
variant: PropTypes.oneOf(Object.keys(variants)),
};

/**
* Hook which provides access to the button variant.
*/
Expand Down
49 changes: 49 additions & 0 deletions frontend/components/FeedbackBlock/Sentiment/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useState } from "react";

import { RadioGroup } from "@headlessui/react";
import EmoticonButton from "./EmoticonButton";

export default function Sentiment() {
const [choice, setChoice] = useState();

const onChange = (selected) => {
if (!selected) {
return;
}

setChoice(selected);

fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL}/update-ratings/`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({ rating: selected }),
})
.then(() => {})
.catch(() => {});
};

return (
<RadioGroup
value={choice}
onChange={onChange}
className="flex w-full justify-center gap-6 pt-12 pb-24"
>
<RadioGroup.Label className="sr-only">What did you think?</RadioGroup.Label>
<RadioGroup.Option value="heart">
{({ checked }) => <EmoticonButton variant="heart" checked={checked} />}
</RadioGroup.Option>
<RadioGroup.Option value="thumbsup">
{({ checked }) => <EmoticonButton variant="thumbsup" checked={checked} />}
</RadioGroup.Option>
<RadioGroup.Option value="neutral">
{({ checked }) => <EmoticonButton variant="even" checked={checked} />}
</RadioGroup.Option>
<RadioGroup.Option value="thumbsdown">
{({ checked }) => <EmoticonButton variant="thumbsdown" checked={checked} />}
</RadioGroup.Option>
</RadioGroup>
);
}
50 changes: 50 additions & 0 deletions frontend/components/FeedbackBlock/Sentiment/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { render, screen, fireEvent, prettyDOM } from "@testing-library/react";

import Sentiment from ".";

describe("Sentiment", () => {
beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({}),
})
);
});

afterEach(jest.restoreAllMocks);

it("renders with no option selected", () => {
render(<Sentiment />);

const radios = screen.queryAllByRole("radio");

expect(radios).toHaveLength(4);

radios.forEach((radio) => {
expect(radio.getAttribute("aria-checked")).toEqual("false");
});
});

it("allows selecting an option", () => {
const screen = render(<Sentiment />);
const radios = screen.queryAllByRole("radio");

fireEvent.click(radios[0]);

expect(radios).toHaveLength(4);
expect(radios[0].getAttribute("aria-checked")).toEqual("true");
});

describe("when submitting a sentiment", () => {
it("sends data to the API", () => {
render(<Sentiment />);

fireEvent.click(screen.getAllByRole("radio")[0]);

expect(global.fetch).toHaveBeenCalledTimes(1);

const sentData = JSON.parse(global.fetch.mock.calls[0][1].body);
expect(sentData).toEqual({ rating: "heart" });
});
});
});
Loading

0 comments on commit 36cb4a1

Please sign in to comment.