aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorGravatar Joe Banks <[email protected]>2021-01-17 16:19:18 +0000
committerGravatar GitHub <[email protected]>2021-01-17 16:19:18 +0000
commitc1bee9cb0efba823740095380cfcca9bf47eb196 (patch)
tree18ed06bca3d17341e9eeabc5364023b9f3ee250b /src
parentMerge pull request #82 from python-discord/renovate/typescript-eslint-monorepo (diff)
parentCenters Title With No Description (diff)
Merge pull request #74 from python-discord/form-rendering
Diffstat (limited to 'src')
-rw-r--r--src/api/forms.ts38
-rw-r--r--src/api/question.ts2
-rw-r--r--src/colors.ts2
-rw-r--r--src/commonStyles.tsx60
-rw-r--r--src/components/HeaderBar/index.tsx139
-rw-r--r--src/components/HeaderBar/logo.svg3
-rw-r--r--src/components/InputTypes/Checkbox.tsx65
-rw-r--r--src/components/InputTypes/Code.tsx11
-rw-r--r--src/components/InputTypes/Radio.tsx39
-rw-r--r--src/components/InputTypes/Range.tsx117
-rw-r--r--src/components/InputTypes/Select.tsx204
-rw-r--r--src/components/InputTypes/ShortText.tsx12
-rw-r--r--src/components/InputTypes/TextArea.tsx21
-rw-r--r--src/components/InputTypes/index.tsx71
-rw-r--r--src/components/Question.tsx166
-rw-r--r--src/components/ScrollToTop.tsx93
-rw-r--r--src/pages/FormPage.tsx194
-rw-r--r--src/pages/LandingPage.tsx7
-rw-r--r--src/tests/components/FormListing.test.tsx12
-rw-r--r--src/tests/components/HeaderBar.test.tsx23
-rw-r--r--src/tests/pages/FormPage.test.tsx2
-rw-r--r--src/tests/pages/LandingPage.test.tsx6
22 files changed, 1201 insertions, 86 deletions
diff --git a/src/api/forms.ts b/src/api/forms.ts
index aec4b99..12b9abf 100644
--- a/src/api/forms.ts
+++ b/src/api/forms.ts
@@ -1,4 +1,4 @@
-import { Question, QuestionType } from "./question";
+import { Question } from "./question";
import ApiClient from "./client";
export enum FormFeatures {
@@ -6,38 +6,30 @@ export enum FormFeatures {
RequiresLogin = "REQUIRES_LOGIN",
Open = "OPEN",
CollectEmail = "COLLECT_EMAIL",
- DisableAntispam = "DISABLE_ANTISPAM"
+ DisableAntispam = "DISABLE_ANTISPAM",
+ WebhookEnabled = "WEBHOOK_ENABLED"
}
export interface Form {
id: string,
features: Array<FormFeatures>,
+ webhook: WebHook | null,
questions: Array<Question>,
name: string,
description: string
}
+export interface WebHook {
+ url: string,
+ message: string | null
+}
+
export async function getForms(): Promise<Form[]> {
- const resp = await ApiClient.get("forms/discoverable");
- return resp.data;
+ const fetch_response = await ApiClient.get("forms/discoverable");
+ return fetch_response.data;
}
-export function getForm(id: string): Promise<Form> {
- const data: Form = {
- name: "Ban Appeals",
- id: "ban-appeals",
- description: "Appealing bans from the Discord server",
- features: [FormFeatures.Discoverable, FormFeatures.Open],
- questions: [
- {
- id: "how-spanish-are-you",
- name: "How Spanish are you?",
- type: QuestionType.ShortText,
- data: {}
- }
- ]
- };
- return new Promise((resolve) => {
- setTimeout(() => resolve(data), 1500);
- });
-}
+export async function getForm(id: string): Promise<Form> {
+ const fetch_response = await ApiClient.get(`forms/${id}`);
+ return fetch_response.data;
+}
diff --git a/src/api/question.ts b/src/api/question.ts
index 18951b9..9824b60 100644
--- a/src/api/question.ts
+++ b/src/api/question.ts
@@ -13,6 +13,6 @@ export interface Question {
id: string,
name: string,
type: QuestionType,
- data: { [key: string]: any },
+ data: { [key: string]: string | string[] },
required: boolean
}
diff --git a/src/colors.ts b/src/colors.ts
index e9c74b1..52b48cb 100644
--- a/src/colors.ts
+++ b/src/colors.ts
@@ -1,8 +1,10 @@
export default {
blurple: "#7289DA",
+ darkerBlurple: "#4E609C",
darkButNotBlack: "#2C2F33",
notQuiteBlack: "#23272A",
greyple: "#99AAB5",
+ darkerGreyple: "#6E7D88",
error: "#f04747",
success: "#43b581"
};
diff --git a/src/commonStyles.tsx b/src/commonStyles.tsx
new file mode 100644
index 0000000..89a2746
--- /dev/null
+++ b/src/commonStyles.tsx
@@ -0,0 +1,60 @@
+import { css } from "@emotion/react";
+
+const selectable = css`
+ -moz-user-select: text;
+ -webkit-user-select: text;
+ -ms-user-select: text;
+ user-select: text;
+`;
+
+const unselectable = css`
+ -moz-user-select: none;
+ -webkit-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+`;
+
+const hiddenInput = css`
+ position: absolute;
+ opacity: 0;
+ height: 0;
+ width: 0;
+`;
+
+const multiSelectInput = css`
+ display: inline-block;
+ position: relative;
+
+ margin: 1rem 0.5rem 0 0;
+ border: whitesmoke 0.2rem solid;
+
+ background-color: whitesmoke;
+ transition: background-color 200ms;
+`;
+
+const textInputs = css`
+ display: inline-block;
+ width: min(20rem, 90%);
+ height: 100%;
+ min-height: 2rem;
+
+ background: whitesmoke;
+
+ color: black;
+ padding: 0.15rem 1rem 0 1rem;
+ font: inherit;
+
+ margin-bottom: 0;
+
+ border: 0.1rem solid black;
+ border-radius: 8px;
+`;
+
+
+export {
+ selectable,
+ unselectable,
+ hiddenInput,
+ multiSelectInput,
+ textInputs,
+};
diff --git a/src/components/HeaderBar/index.tsx b/src/components/HeaderBar/index.tsx
index dfe3957..5c536ef 100644
--- a/src/components/HeaderBar/index.tsx
+++ b/src/components/HeaderBar/index.tsx
@@ -1,59 +1,118 @@
/** @jsx jsx */
-import { css, jsx } from "@emotion/react";
+import { jsx, css } from "@emotion/react";
import Header1 from "./header_1.svg";
import Header2 from "./header_2.svg";
+import Logo from "./logo.svg";
+
+import { Link } from "react-router-dom";
interface HeaderBarProps {
- title?: string
+ title?: string
+ description?: string
}
const headerImageStyles = css`
-z-index: -1;
-top: 0;
-position: absolute;
-width: 100%;
-transition: height 1s;
+ * {
+ z-index: -1;
+ top: 0;
+ position: absolute;
+ width: 100%;
+ transition: height 1s;
+ }
+`;
+
+const headerTextStyles = css`
+ transition: margin 1s;
+ font-family: "Uni Sans", "Hind", "Arial", sans-serif;
+
+ margin: 0 2rem 10rem 2rem;
+
+ .title {
+ font-size: 3vmax;
+ margin-bottom: 0;
+ }
+
+ .full_size {
+ line-height: 200%;
+ }
+
+ .description {
+ font-size: 1.5vmax;
+ }
+
+ .title, .description {
+ transition: font-size 1s;
+ }
+
+ @media (max-width: 480px) {
+ margin-top: 7rem;
+ text-align: center;
+
+ .title {
+ font-size: 5vmax;
+ }
+
+ .full_size {
+ line-height: 100%;
+ }
+
+ .description {
+ font-size: 2vmax;
+ }
+ }
+`;
+
+const homeButtonStyles = css`
+ svg {
+ transform: scale(0.25);
+ transition: top 300ms, transform 300ms;
+
+ @media (max-width: 480px) {
+ transform: scale(0.15);
+ }
+ }
+
+ * {
+ position: absolute;
+ top: -10rem;
+ right: 1rem;
+
+ z-index: 0;
+ transform-origin: right;
+
+ @media (max-width: 700px) {
+ top: -11.5rem;
+ }
+
+ @media (max-width: 480px) {
+ top: -12.5rem;
+ }
+ }
`;
-function HeaderBar({ title }: HeaderBarProps): JSX.Element {
+function HeaderBar({ title, description }: HeaderBarProps): JSX.Element {
if (!title) {
title = "Python Discord Forms";
}
-
- return <div>
+
+ return (
<div>
- <Header1 css={headerImageStyles}/>
- <Header2 css={headerImageStyles}/>
+ <div css={headerImageStyles}>
+ <Header1/>
+ <Header2/>
+ </div>
+
+ <div css={css`${headerTextStyles}; margin-bottom: 12.5%;`}>
+ <h1 className={description ? "title" : "title full_size"}>{title}</h1>
+ {description ? <h1 className="description">{description}</h1> : null}
+ </div>
+
+ <Link to="/" css={homeButtonStyles}>
+ <Logo/>
+ </Link>
</div>
- <h1 css={css`
- font-size: 4vw;
- margin: 0;
- margin-top: 30px;
- margin-left: 30px;
- margin-bottom: 200px;
- transition-property: font-size, margin-bottom;
- transition-duration: 1s;
- font-family: "Uni Sans", "Hind", "Arial", sans-serif;
-
- @media (max-width: 1000px) {
- margin-top: 15px;
- font-size: 8vw;
- }
-
- @media (max-width: 770px) {
- margin-top: 15px;
- font-size: 6vw;
- margin-bottom: 100px;
- }
- @media (max-width: 450px) {
- text-align: center;
- margin-left: 0;
- }
- `}>
- {title}
- </h1>
- </div>;
+ );
}
export default HeaderBar;
diff --git a/src/components/HeaderBar/logo.svg b/src/components/HeaderBar/logo.svg
new file mode 100644
index 0000000..e7f43fc
--- /dev/null
+++ b/src/components/HeaderBar/logo.svg
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="463.86" height="463.86" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata>
+ <path d="m296.09 80.929-11.506 3.3086c-12.223-1.8051-24.757-2.597-36.895-2.5078-13.5 0.1-26.498 1.1992-37.898 3.1992-3.6998 0.65161-7.0474 1.386-10.107 2.1992h-35.893v18.801h4.5v7.6992h2.7832c-0.63936 3.7142-0.88476 7.7997-0.88476 12.301v4h-15.398l-2.2012 11 17.6 15.014v0.0859h79.201v10h-79.201-29.699c-23 0-43.2 13.8-49.5 40-7.3 30-7.6 48.801 0 80.201 0.49734 2.0782 1.0605 4.0985 1.6836 6.0625l-1.1836 10.438 13.346 11.549c7.032 7.5103 16.371 11.951 28.254 11.951h27.201v-36c0-26 22.6-49 49.5-49h79.199c22 0 39.6-18.102 39.6-40.102v-75.199c0-12.9-6.5819-23.831-16.516-31.273zm76.801 77.6-14.301 7.4004h-20.1v0.0996 10.301 24.699c0 27.2-23.1 50-49.5 50h-79.1c-21.7 0-39.6 18.5-39.6 40.1v75.102c0 21.4 18.7 34 39.6 40.1 25.1 7.3 49.1 8.7 79.1 0 19.9-5.7 39.6-17.3 39.6-40.1v-27.9-2.0996-0.10156h-0.11914l-11.721-10h11.84 39.6c23 0 31.602-16 39.602-40 8.3-24.7 7.9-48.499 0-80.199-3.6226-14.491-9.3525-26.71-18.947-33.699zm-123.4 167.6h57.5v10h-57.5z" fill-opacity=".20554"/><path class="st2" d="m229.99 66.629c-13.5 0.1-26.5 1.2-37.9 3.2-33.5 5.9-39.6 18.2-39.6 41v30.1h79.2v10h-79.2-29.7c-23 0-43.2 13.8-49.5 40-7.3 30-7.6 48.8 0 80.2 5.6 23.4 19.1 40 42.1 40h27.2v-36c0-26 22.6-49 49.5-49h79.1c22 0 39.6-18.1 39.6-40.1v-75.2c0-21.4-18.1-37.4-39.6-41-13.5-2.3-27.6-3.3-41.2-3.2zm-42.8 24.2c8.2 0 14.9 6.8 14.9 15.1 0 8.3-6.7 15-14.9 15s-14.9-6.7-14.9-15c0-8.4 6.7-15.1 14.9-15.1z" fill="#cbd6ff"/><path class="st3" d="m320.79 150.93v35c0 27.2-23.1 50-49.5 50h-79.1c-21.7 0-39.6 18.5-39.6 40.1v75.1c0 21.4 18.7 34 39.6 40.1 25.1 7.3 49.1 8.7 79.1 0 19.9-5.7 39.6-17.3 39.6-40.1v-30.1h-79.1v-10h79.1 39.6c23 0 31.6-16 39.6-40 8.3-24.7 7.9-48.5 0-80.2-5.7-22.8-16.6-40-39.6-40h-29.7zm-44.5 190.2c8.2 0 14.9 6.7 14.9 15s-6.7 15.1-14.9 15.1-14.9-6.8-14.9-15.1 6.7-15 14.9-15z" fill="#fff"/></svg>
diff --git a/src/components/InputTypes/Checkbox.tsx b/src/components/InputTypes/Checkbox.tsx
new file mode 100644
index 0000000..3093caf
--- /dev/null
+++ b/src/components/InputTypes/Checkbox.tsx
@@ -0,0 +1,65 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+import colors from "../../colors";
+import { multiSelectInput, hiddenInput } from "../../commonStyles";
+
+interface CheckboxProps {
+ index: number,
+ option: string,
+ handler: (event: ChangeEvent<HTMLInputElement>) => void
+}
+
+const generalStyles = css`
+ cursor: pointer;
+
+ label {
+ width: 1em;
+ height: 1em;
+ top: 0.3rem;
+
+ border-radius: 25%;
+ cursor: pointer;
+ }
+
+ .unselected {
+ background-color: white;
+ }
+
+ .unselected:focus-within, :hover .unselected {
+ background-color: lightgray;
+ }
+
+ .checkmark {
+ position: absolute;
+ }
+`;
+
+const activeStyles = css`
+ .selected {
+ background-color: ${colors.blurple};
+ }
+
+ .selected .checkmark {
+ width: 0.30rem;
+ height: 0.60rem;
+ left: 0.25em;
+
+ border: solid white;
+ border-width: 0 0.2rem 0.2rem 0;
+
+ transform: rotate(45deg);
+ }
+`;
+
+export default function Checkbox(props: CheckboxProps): JSX.Element {
+ return (
+ <label css={[generalStyles, activeStyles]}>
+ <label className="unselected" css={multiSelectInput}>
+ <input type="checkbox" value={props.option} css={hiddenInput} name={`${("000" + props.index).slice(-4)}. ${props.option}`} onChange={props.handler}/>
+ <span className="checkmark"/>
+ </label>
+ {props.option}<br/>
+ </label>
+ );
+}
diff --git a/src/components/InputTypes/Code.tsx b/src/components/InputTypes/Code.tsx
new file mode 100644
index 0000000..51ca98d
--- /dev/null
+++ b/src/components/InputTypes/Code.tsx
@@ -0,0 +1,11 @@
+/** @jsx jsx */
+import { jsx } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+
+interface CodeProps {
+ handler: (event: ChangeEvent<HTMLInputElement>) => void
+}
+
+export default function Code(props: CodeProps): JSX.Element {
+ return <input type="text" className="text" onChange={props.handler}/>;
+}
diff --git a/src/components/InputTypes/Radio.tsx b/src/components/InputTypes/Radio.tsx
new file mode 100644
index 0000000..3bf13ed
--- /dev/null
+++ b/src/components/InputTypes/Radio.tsx
@@ -0,0 +1,39 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+import colors from "../../colors";
+import { multiSelectInput, hiddenInput } from "../../commonStyles";
+
+interface RadioProps {
+ option: string,
+ question_id: string,
+ handler: (event: ChangeEvent<HTMLInputElement>) => void
+}
+
+const styles = css`
+ div {
+ width: 0.7em;
+ height: 0.75em;
+ top: 0.18rem;
+
+ border-radius: 50%;
+ }
+
+ :hover div, :focus-within div {
+ background-color: lightgray;
+ }
+
+ input:checked+div {
+ background-color: ${colors.blurple};
+ }
+`;
+
+export default function Radio(props: RadioProps): JSX.Element {
+ return (
+ <label css={styles}>
+ <input type="radio" name={props.question_id} onChange={props.handler} css={hiddenInput}/>
+ <div css={multiSelectInput}/>
+ {props.option}<br/>
+ </label>
+ );
+}
diff --git a/src/components/InputTypes/Range.tsx b/src/components/InputTypes/Range.tsx
new file mode 100644
index 0000000..e2f89f4
--- /dev/null
+++ b/src/components/InputTypes/Range.tsx
@@ -0,0 +1,117 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+import colors from "../../colors";
+import { hiddenInput, multiSelectInput } from "../../commonStyles";
+
+interface RangeProps {
+ question_id: string,
+ options: Array<string>,
+ handler: (event: ChangeEvent<HTMLInputElement>) => void
+}
+
+const containerStyles = css`
+ display: flex;
+ justify-content: space-between;
+ position: relative;
+ width: 100%;
+
+ @media (max-width: 800px) {
+ width: 20%;
+ display: block;
+ margin: 0 auto;
+
+ label span {
+ margin-left: 0;
+ transform: translateY(1.6rem) translateX(2rem);
+ }
+ }
+`;
+
+const optionStyles = css`
+ display: inline-block;
+ transform: translateX(-50%);
+ margin: 0 50%;
+
+ white-space: nowrap;
+
+ transition: transform 300ms;
+`;
+
+const selectorStyles = css`
+ cursor: pointer;
+
+ div {
+ width: 1rem;
+ height: 1rem;
+
+ background-color: whitesmoke;
+
+ border-radius: 50%;
+ margin: 0 100% 0 0;
+ }
+
+ :hover div, :focus-within div {
+ background-color: lightgray;
+ }
+
+ input:checked+div {
+ background-color: ${colors.blurple};
+ }
+`;
+
+const sliderContainerStyles = css`
+ display: flex;
+ justify-content: center;
+ width: 100%;
+
+ position: absolute;
+ z-index: -1;
+
+ top: 2.1rem;
+
+ transition: all 300ms;
+
+ @media (max-width: 800px) {
+ width: 0.5rem;
+ height: 80%;
+
+ left: 0.4rem;
+
+ background: whitesmoke;
+ }
+`;
+
+const sliderStyles = css`
+ width: 98%; /* Needs to be slightly smaller than container to work on all devices */
+ height: 0.5rem;
+ background-color: whitesmoke;
+
+ transition: transform 300ms;
+
+ @media (max-width: 800px) {
+ display: none;
+ }
+`;
+
+export default function Range(props: RangeProps): JSX.Element {
+ const range = props.options.map((option, index) => {
+ return (
+ <label css={[selectorStyles, css`width: 1rem`]} key={index}>
+ <span css={optionStyles}>{option}</span>
+ <input type="radio" name={props.question_id} css={hiddenInput} onChange={props.handler}/>
+ <div css={multiSelectInput}/>
+ </label>
+ );
+ });
+
+ return (
+ <div css={containerStyles}>
+ { range }
+
+ <div css={sliderContainerStyles}>
+ <div css={sliderStyles}/>
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/InputTypes/Select.tsx b/src/components/InputTypes/Select.tsx
new file mode 100644
index 0000000..e753357
--- /dev/null
+++ b/src/components/InputTypes/Select.tsx
@@ -0,0 +1,204 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React from "react";
+import { hiddenInput } from "../../commonStyles";
+
+interface SelectProps {
+ options: Array<string>,
+ state_dict: Map<string, string | boolean | null>
+}
+
+const containerStyles = css`
+ position: relative;
+ width: min(20rem, 90%);
+
+ color: black;
+ cursor: pointer;
+
+ :focus-within .selected_container {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+
+ border-bottom-color: transparent;
+ }
+`;
+
+const mainWindowStyles = css`
+ display: inline-block;
+ position: relative;
+ background: whitesmoke;
+
+ width: 100%;
+ height: 100%;
+ min-height: 2.5rem;
+
+ margin-bottom: 0;
+
+ overflow: hidden;
+ z-index: 1;
+
+ :hover, :focus-within {
+ background-color: lightgray;
+ }
+
+ .selected_option {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+
+ outline: none;
+ padding-left: 0.75rem;
+ line-height: 250%;
+ }
+
+ border: 0.1rem solid black;
+ border-radius: 8px;
+
+ transition: border-radius 200ms;
+`;
+
+const arrowStyles = css`
+ .arrow {
+ display: inline-block;
+ height: 0.5rem;
+ width: 0.5rem;
+
+ position: relative;
+ float: right;
+ right: 1em;
+ top: 0.7rem;
+
+ border: solid black;
+ border-width: 0 0.2rem 0.2rem 0;
+
+ transform: rotate(45deg);
+ transition: transform 200ms;
+ }
+
+ :focus-within .arrow {
+ transform: translateY(40%) rotate(225deg);
+ }
+`;
+
+const optionContainerStyles = css`
+ .option_container {
+ position: absolute;
+ width: 100%;
+ height: 0;
+
+ top: 2.3rem;
+ padding-top: 0.2rem;
+
+ visibility: hidden;
+ opacity: 0;
+
+ overflow: hidden;
+ background: whitesmoke;
+
+ border: 0.1rem solid black;
+ border-radius: 0 0 8px 8px;
+ border-top: none;
+
+ transition: opacity 200ms, visibility 200ms;
+
+ * {
+ cursor: pointer;
+ }
+ }
+
+ :focus-within .option_container {
+ height: auto;
+ visibility: visible;
+ opacity: 1;
+ }
+
+ .option_container .hidden {
+ display: none;
+ }
+`;
+
+const inputStyles = css`
+ position: absolute;
+ width: 100%;
+ height: 100%;
+
+ z-index: 2;
+
+ margin: 0;
+ border: none;
+ outline: none;
+`;
+
+const optionStyles = css`
+ position: relative;
+
+ :hover, :focus-within {
+ background-color: lightgray;
+ }
+
+ div {
+ padding: 0.75rem;
+ }
+`;
+
+class Select extends React.Component<SelectProps> {
+ handler(selected_option: React.RefObject<HTMLDivElement>, event: React.ChangeEvent<HTMLInputElement>): void {
+ const option_container = event.target.parentElement;
+ if (!option_container || !option_container.parentElement || !selected_option.current) {
+ return;
+ }
+
+ // Update stored value
+ this.props.state_dict.set("value", option_container.textContent);
+
+ // Close the menu
+ selected_option.current.focus();
+ selected_option.current.blur();
+ selected_option.current.textContent = option_container.textContent;
+ }
+
+ handle_click(
+ container: React.RefObject<HTMLDivElement>,
+ selected_option: React.RefObject<HTMLDivElement>,
+ event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>
+ ): void {
+ if (!container.current || !selected_option.current || (event.type === "keydown" && (event as React.KeyboardEvent).code !== "Space")) {
+ return;
+ }
+
+ // Check if menu is open
+ if (container.current.contains(document.activeElement)) {
+ // Close menu
+ selected_option.current.focus();
+ selected_option.current.blur();
+ event.preventDefault();
+ }
+ }
+
+ render(): JSX.Element {
+ const container_ref: React.RefObject<HTMLDivElement> = React.createRef();
+ const selected_option_ref: React.RefObject<HTMLDivElement> = React.createRef();
+
+ const handle_click = (event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => this.handle_click(container_ref, selected_option_ref, event);
+
+ return (
+ <div css={[containerStyles, arrowStyles, optionContainerStyles]} ref={container_ref}>
+ <div className="selected_container" css={mainWindowStyles}>
+ <span className="arrow"/>
+ <div tabIndex={0} className="selected_option" ref={selected_option_ref} onMouseDown={handle_click} onKeyDown={handle_click}>...</div>
+ </div>
+
+ <div className="option_container" tabIndex={-1} css={css`outline: none;`}>
+ { this.props.options.map((option, index) => (
+ <div key={index} css={optionStyles}>
+ <input type="checkbox" css={[hiddenInput, inputStyles]} onChange={event => this.handler.call(this, selected_option_ref, event)}/>
+ <div>{option}</div>
+ </div>
+ )) }
+ </div>
+ </div>
+ );
+ }
+}
+
+export default Select;
diff --git a/src/components/InputTypes/ShortText.tsx b/src/components/InputTypes/ShortText.tsx
new file mode 100644
index 0000000..1e38bcd
--- /dev/null
+++ b/src/components/InputTypes/ShortText.tsx
@@ -0,0 +1,12 @@
+/** @jsx jsx */
+import { jsx } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+import { textInputs } from "../../commonStyles";
+
+interface ShortTextProps {
+ handler: (event: ChangeEvent<HTMLInputElement>) => void
+}
+
+export default function ShortText(props: ShortTextProps): JSX.Element {
+ return <input type="text" css={textInputs} placeholder="Enter Text..." onChange={props.handler}/>;
+}
diff --git a/src/components/InputTypes/TextArea.tsx b/src/components/InputTypes/TextArea.tsx
new file mode 100644
index 0000000..6e46c27
--- /dev/null
+++ b/src/components/InputTypes/TextArea.tsx
@@ -0,0 +1,21 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+import { textInputs } from "../../commonStyles";
+
+interface TextAreaProps {
+ handler: (event: ChangeEvent<HTMLTextAreaElement>) => void
+}
+
+const styles = css`
+ min-height: 20rem;
+ width: 100%;
+ box-sizing: border-box;
+ resize: vertical;
+
+ padding: 1rem;
+`;
+
+export default function TextArea(props: TextAreaProps): JSX.Element {
+ return <textarea css={[textInputs, styles]} placeholder="Enter Text..." onChange={props.handler}/>;
+}
diff --git a/src/components/InputTypes/index.tsx b/src/components/InputTypes/index.tsx
new file mode 100644
index 0000000..f1e0b30
--- /dev/null
+++ b/src/components/InputTypes/index.tsx
@@ -0,0 +1,71 @@
+import Checkbox from "./Checkbox";
+import Code from "./Code";
+import Radio from "./Radio";
+import Range from "./Range";
+import Select from "./Select";
+import ShortText from "./ShortText";
+import TextArea from "./TextArea";
+
+import React, { ChangeEvent } from "react";
+
+import { QuestionType } from "../../api/question";
+import { QuestionProp } from "../Question";
+
+const require_options: Array<QuestionType> = [
+ QuestionType.Radio,
+ QuestionType.Checkbox,
+ QuestionType.Select,
+ QuestionType.Range
+];
+
+export default function create_input({ question, public_state }: QuestionProp, handler: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void): JSX.Element | JSX.Element[] {
+ let result: JSX.Element | JSX.Element[];
+
+ // eslint-disable-next-line
+ // @ts-ignore
+ let options: string[] = question.data["options"];
+
+ // Catch input types that require options but don't have any
+ if ((options === undefined || typeof options !== "object") && require_options.includes(question.type)) {
+ // TODO: Implement some sort of warning here
+ options = [];
+ }
+
+ /* eslint-disable react/react-in-jsx-scope */
+ switch (question.type) {
+ case QuestionType.TextArea:
+ result = <TextArea handler={handler}/>;
+ break;
+
+ case QuestionType.Checkbox:
+ result = options.map((option, index) => <Checkbox index={index} option={option} handler={handler} key={index}/>);
+ break;
+
+ case QuestionType.Radio:
+ result = options.map((option, index) => <Radio option={option} question_id={question.id} handler={handler} key={index}/>);
+ break;
+
+ case QuestionType.Select:
+ result = <Select options={options} state_dict={public_state}/>;
+ break;
+
+ case QuestionType.ShortText:
+ result = <ShortText handler={handler}/>;
+ break;
+
+ case QuestionType.Range:
+ result = <Range question_id={question.id} options={options} handler={handler}/>;
+ break;
+
+ case QuestionType.Code:
+ // TODO: Implement
+ result = <Code handler={handler}/>;
+ break;
+
+ default:
+ result = <TextArea handler={handler}/>;
+ }
+ /* eslint-enable react/react-in-jsx-scope */
+
+ return result;
+}
diff --git a/src/components/Question.tsx b/src/components/Question.tsx
new file mode 100644
index 0000000..735d69b
--- /dev/null
+++ b/src/components/Question.tsx
@@ -0,0 +1,166 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React, { ChangeEvent } from "react";
+
+import { Question, QuestionType } from "../api/question";
+import { selectable } from "../commonStyles";
+import create_input from "./InputTypes";
+
+const skip_normal_state: Array<QuestionType> = [
+ QuestionType.Radio,
+ QuestionType.Checkbox,
+ QuestionType.Select,
+ QuestionType.Section,
+ QuestionType.Range
+];
+
+export type QuestionProp = {
+ question: Question,
+ public_state: Map<string, string | boolean | null>,
+}
+
+class RenderedQuestion extends React.Component<QuestionProp> {
+ constructor(props: QuestionProp) {
+ super(props);
+ if (props.question.type === QuestionType.TextArea) {
+ this.handler = this.text_area_handler.bind(this);
+ } else {
+ this.handler = this.normal_handler.bind(this);
+ }
+
+ if (!skip_normal_state.includes(props.question.type)) {
+ this.setPublicState("value", "");
+ }
+ }
+
+ setPublicState(target: string, value: string | boolean | null, callback?:() => void): void {
+ this.setState({[target]: value}, callback);
+ this.props.public_state.set(target, value);
+ }
+
+ // This is here to allow dynamic selection between the general handler, and the textarea handler.
+ handler(_: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void {} // eslint-disable-line
+
+ normal_handler(event: ChangeEvent<HTMLInputElement>): void {
+ let target: string;
+ let value: string | boolean;
+
+ switch (event.target.type) {
+ case "checkbox":
+ target = event.target.name;
+ value = event.target.checked;
+ break;
+
+ case "radio":
+ // This handles radios and ranges, as they are both based on the same fundamental input type
+ target = "value";
+ if (event.target.parentElement) {
+ value = event.target.parentElement.innerText.trimEnd();
+ } else {
+ value = event.target.value;
+ }
+ break;
+
+ default:
+ target = "value";
+ value = event.target.value;
+ }
+
+ this.setPublicState(target, value);
+
+ // Toggle checkbox class
+ if (event.target.type == "checkbox" && event.target.parentElement !== null) {
+ event.target.parentElement.classList.toggle("unselected");
+ event.target.parentElement.classList.toggle("selected");
+ }
+ }
+
+ text_area_handler(event: ChangeEvent<HTMLTextAreaElement>): void {
+ this.setPublicState("value", event.target.value);
+ }
+
+ componentDidMount(): void {
+ // Initialize defaults for complex and nested fields
+ const options: string | string[] = this.props.question.data["options"];
+
+ if (this.props.public_state.size === 0) {
+ switch (this.props.question.type) {
+ case QuestionType.Checkbox:
+ if (typeof options === "string") {
+ return;
+ }
+
+ options.forEach((option, index) => {
+ this.setPublicState(`${("000" + index).slice(-4)}. ${option}`, false);
+ });
+ break;
+
+ case QuestionType.Range:
+ case QuestionType.Radio:
+ case QuestionType.Select:
+ this.setPublicState("value", null);
+ break;
+ }
+ }
+ }
+
+ render(): JSX.Element {
+ const question = this.props.question;
+
+ if (question.type === QuestionType.Section) {
+ const styles = css`
+ h1 {
+ margin-bottom: 0;
+ }
+
+ h3 {
+ margin-top: 0;
+ }
+
+ h1, h3 {
+ text-align: center;
+ padding: 0 2rem;
+ }
+
+ @media (max-width: 500px) {
+ h1, h3 {
+ padding: 0;
+ }
+ }
+ `;
+
+ return <div css={styles}>
+ <h1 css={[selectable, css`line-height: 2.5rem;`]}>{question.name}</h1>
+ { question.data["text"] ? <h3 css={selectable}>{question.data["text"]}</h3> : "" }
+ <hr css={css`color: gray; margin: 3rem 0;`}/>
+ </div>;
+
+ } else {
+ const requiredStarStyles = css`
+ span {
+ display: none;
+ }
+
+ .required {
+ display: inline-block;
+ position: relative;
+
+ color: red;
+
+ top: -0.2rem;
+ margin-left: 0.2rem;
+ }
+ `;
+
+ return <div>
+ <h2 css={[selectable, requiredStarStyles]}>
+ {question.name}<span className={question.required ? "required" : ""}>*</span>
+ </h2>
+ { create_input(this.props, this.handler) }
+ <hr css={css`color: gray; margin: 3rem 0;`}/>
+ </div>;
+ }
+ }
+}
+
+export default RenderedQuestion;
diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx
new file mode 100644
index 0000000..231c9e8
--- /dev/null
+++ b/src/components/ScrollToTop.tsx
@@ -0,0 +1,93 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+import React from "react";
+import colors from "../colors";
+
+import smoothscroll from "smoothscroll-polyfill";
+smoothscroll.polyfill();
+
+const styles = css`
+ width: 2.5rem;
+ height: 2.5rem;
+
+ position: fixed;
+ bottom: 3rem;
+ right: 3rem;
+
+ background-color: ${colors.blurple};
+ border-radius: 50%;
+
+ opacity: 0;
+ transition: opacity 300ms;
+
+ :after {
+ display: inline-block;
+ content: "";
+
+ position: fixed;
+ bottom: 3.5rem;
+ right: 3.65rem;
+
+ border: solid whitesmoke;
+ border-width: 0.35rem 0.35rem 0 0;
+ padding: 0.4rem;
+
+ transform: rotate(-45deg);
+ }
+
+ @media (max-width: 800px) {
+ bottom: 1.5rem;
+ right: 1.5rem;
+
+ :after {
+ bottom: 2rem;
+ right: 2.15rem;
+ }
+ }
+`;
+
+let last_ref: React.RefObject<HTMLDivElement>;
+
+class ScrollToTop extends React.Component {
+ constructor(props: Record<string, never>) {
+ super(props);
+ last_ref = React.createRef();
+ }
+
+ handleScroll(): void {
+ if (!last_ref.current) return;
+
+ if (window.pageYOffset > 250) {
+ last_ref.current.style.opacity = "1";
+ last_ref.current.style.cursor = "pointer";
+ } else {
+ last_ref.current.style.opacity = "0";
+ last_ref.current.style.cursor = "default";
+ }
+ }
+
+ componentDidMount(): void {
+ // Register event handler
+ window.addEventListener("scroll", this.handleScroll, {passive: true});
+ }
+
+ componentDidUpdate(): void {
+ // Hide previous iterations, and register handler for current one
+ if (last_ref.current) {
+ last_ref.current.style.opacity = "0";
+ }
+
+ window.addEventListener("scroll", this.handleScroll, {passive: true});
+ }
+
+ componentWillUnmount(): void {
+ // Unregister handler
+ window.removeEventListener("scroll", this.handleScroll);
+ }
+
+ render(): JSX.Element {
+ return <div css={styles} key={Date.now()} ref={last_ref} onClick={() => window.scrollTo({top: 0, behavior: "smooth"})}/>;
+ }
+}
+
+export default ScrollToTop;
diff --git a/src/pages/FormPage.tsx b/src/pages/FormPage.tsx
index 1805897..c49b9fd 100644
--- a/src/pages/FormPage.tsx
+++ b/src/pages/FormPage.tsx
@@ -1,17 +1,157 @@
/** @jsx jsx */
-import { jsx } from "@emotion/react";
+import { jsx, css } from "@emotion/react";
import { Link } from "react-router-dom";
+import React, { SyntheticEvent, useEffect, useState } from "react";
import { useParams } from "react-router";
+
import HeaderBar from "../components/HeaderBar";
-import { useEffect, useState } from "react";
-import { Form, getForm } from "../api/forms";
+import RenderedQuestion from "../components/Question";
import Loading from "../components/Loading";
+import ScrollToTop from "../components/ScrollToTop";
+
+import { Form, FormFeatures, getForm } from "../api/forms";
+import colors from "../colors";
+import { unselectable } from "../commonStyles";
+
interface PathParams {
id: string
}
+interface NavigationProps {
+ form_state: boolean // Whether the form is open or not
+}
+
+class Navigation extends React.Component<NavigationProps> {
+ containerStyles = css`
+ margin: auto;
+ width: 50%;
+
+ text-align: center;
+ font-size: 1.5rem;
+ white-space: nowrap;
+
+ > div {
+ display: inline-block;
+ margin: 2rem auto;
+ width: 50%;
+ }
+
+ @media (max-width: 850px) {
+ width: 100%;
+
+ > div {
+ display: flex;
+ justify-content: center;
+
+ margin: 0 auto;
+ }
+ }
+
+ .return_button {
+ text-align: left;
+ }
+
+ .return_button.closed {
+ text-align: center;
+ }
+ `;
+
+ separatorStyles = css`
+ height: 0;
+ display: none;
+
+ @media (max-width: 850px) {
+ display: block;
+ }
+ `;
+
+ returnStyles = css`
+ padding: 0.5rem 2rem;
+ border-radius: 8px;
+
+ color: white;
+ text-decoration: none;
+
+ background-color: ${colors.greyple};
+ transition: background-color 300ms;
+
+ :hover {
+ background-color: ${colors.darkerGreyple};
+ }
+ }
+ `;
+
+ submitStyles = css`
+ text-align: right;
+
+ button {
+ padding: 0.5rem 4rem;
+ cursor: pointer;
+
+ border: none;
+ border-radius: 8px;
+
+ color: white;
+ font: inherit;
+
+ background-color: ${colors.blurple};
+ transition: background-color 300ms;
+ }
+
+ button:hover {
+ background-color: ${colors.darkerBlurple};
+ }
+ `;
+
+ render(): JSX.Element {
+ let submit = null;
+ if (this.props.form_state) {
+ submit = (
+ <div css={this.submitStyles}>
+ <button form="form" type="submit">Submit</button>
+ </div>
+ );
+ }
+
+ return (
+ <div css={[unselectable, this.containerStyles]}>
+ <div className={ "return_button" + (this.props.form_state ? "" : " closed") }>
+ <Link to="/" css={this.returnStyles}>Return Home</Link>
+ </div>
+ <br css={this.separatorStyles}/>
+ { submit }
+ </div>
+ );
+ }
+}
+
+const formStyles = css`
+ margin: auto;
+ width: 50%;
+
+ @media (max-width: 800px) {
+ /* Make form larger on mobile and tablet screens */
+ width: 80%;
+ }
+`;
+
+const closedHeaderStyles = css`
+ margin-bottom: 2rem;
+ padding: 1rem 4rem;
+ border-radius: 8px;
+
+ text-align: center;
+ font-size: 1.5rem;
+
+ background-color: ${colors.error};
+
+ @media (max-width: 500px) {
+ padding: 1rem 1.5rem;
+ }
+`;
+
function FormPage(): JSX.Element {
const { id } = useParams<PathParams>();
@@ -21,19 +161,53 @@ function FormPage(): JSX.Element {
getForm(id).then(form => {
setForm(form);
});
- });
+ }, []);
if (!form) {
return <Loading/>;
}
- return <div>
- <HeaderBar title={form.name}/>
- <div css={{marginLeft: "20px"}}>
- <h1>{form.description}</h1>
- <Link to="/" css={{color: "white"}}>Return home</Link>
+ const questions = form.questions.map((question, index) => {
+ return <RenderedQuestion question={question} public_state={new Map()} key={index + Date.now()}/>;
+ });
+
+ function handleSubmit(event: SyntheticEvent) {
+ questions.forEach(prop => {
+ const question = prop.props.question;
+
+ // TODO: Parse input from each question, and submit
+ switch (question.type) {
+ default:
+ console.log(question.id, prop.props.public_state);
+ }
+ });
+
+ event.preventDefault();
+ }
+
+ const open: boolean = form.features.includes(FormFeatures.Open);
+
+ let closed_header = null;
+ if (!open) {
+ closed_header = <div css={closedHeaderStyles}>This form is now closed. You will not be able to submit your response.</div>;
+ }
+
+ return (
+ <div>
+ <HeaderBar title={form.name} description={form.description}/>
+
+ <div>
+ <form id="form" onSubmit={handleSubmit} css={[formStyles, unselectable]}>
+ { closed_header }
+ { questions }
+ </form>
+ <Navigation form_state={open}/>
+ </div>
+
+ <div css={css`margin-bottom: 10rem`}/>
+ <ScrollToTop/>
</div>
- </div>;
+ );
}
export default FormPage;
diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx
index 124bbcf..af968ad 100644
--- a/src/pages/LandingPage.tsx
+++ b/src/pages/LandingPage.tsx
@@ -4,10 +4,11 @@ import { useEffect, useState } from "react";
import HeaderBar from "../components/HeaderBar";
import FormListing from "../components/FormListing";
-
-import { getForms, Form } from "../api/forms";
import OAuth2Button from "../components/OAuth2Button";
import Loading from "../components/Loading";
+import ScrollToTop from "../components/ScrollToTop";
+
+import { getForms, Form } from "../api/forms";
function LandingPage(): JSX.Element {
const [forms, setForms] = useState<Form[]>();
@@ -25,8 +26,8 @@ function LandingPage(): JSX.Element {
return <div>
<HeaderBar/>
+ <ScrollToTop/>
<div>
-
<div css={css`
display: flex;
align-items: center;
diff --git a/src/tests/components/FormListing.test.tsx b/src/tests/components/FormListing.test.tsx
index ad76381..f071c33 100644
--- a/src/tests/components/FormListing.test.tsx
+++ b/src/tests/components/FormListing.test.tsx
@@ -17,9 +17,11 @@ const openFormListing: Form = {
"id": "my-question",
"name": "My question",
"type": QuestionType.ShortText,
- "data": {}
+ "data": {},
+ required: false
}
- ]
+ ],
+ webhook: null
};
const closedFormListing: Form = {
@@ -32,9 +34,11 @@ const closedFormListing: Form = {
"id": "what-should-i-ask",
"name": "What should I ask?",
"type": QuestionType.ShortText,
- "data": {}
+ "data": {},
+ required: false
}
- ]
+ ],
+ webhook: null
};
test("renders form listing with specified title", () => {
diff --git a/src/tests/components/HeaderBar.test.tsx b/src/tests/components/HeaderBar.test.tsx
index 9c232ad..dd77c8b 100644
--- a/src/tests/components/HeaderBar.test.tsx
+++ b/src/tests/components/HeaderBar.test.tsx
@@ -2,16 +2,35 @@ import React from "react";
import { render } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import HeaderBar from "../../components/HeaderBar";
+import { MemoryRouter } from "react-router-dom";
test("renders header bar with title", () => {
- const { getByText } = render(<HeaderBar />);
+ const { getByText } = render(<MemoryRouter><HeaderBar /></MemoryRouter>);
const formListing = getByText(/Python Discord Forms/i);
expect(formListing).toBeInTheDocument();
});
test("renders header bar with custom title", () => {
- const { getByText } = render(<HeaderBar title="Testing title"/>);
+ const { getByText } = render(<MemoryRouter><HeaderBar title="Testing title"/></MemoryRouter>);
const formListing = getByText(/Testing title/i);
expect(formListing).toBeInTheDocument();
});
+test("renders header bar with custom description", () => {
+ const { getByText } = render(<MemoryRouter><HeaderBar description="Testing description"/></MemoryRouter>);
+ const formListing = getByText(/Testing description/i);
+ expect(formListing).toBeInTheDocument();
+});
+
+test("renders header bar with custom title and description", () => {
+ const { getByText } = render(
+ <MemoryRouter>
+ <HeaderBar title="Testing Title" description="Testing description"/>
+ </MemoryRouter>
+ );
+
+ const title = getByText(/Testing title/i);
+ const description = getByText(/Testing description/i);
+ expect(title).toBeInTheDocument();
+ expect(description).toBeInTheDocument();
+});
diff --git a/src/tests/pages/FormPage.test.tsx b/src/tests/pages/FormPage.test.tsx
index f7ecc32..62577cd 100644
--- a/src/tests/pages/FormPage.test.tsx
+++ b/src/tests/pages/FormPage.test.tsx
@@ -27,6 +27,6 @@ test("calls api method to load form", () => {
Object.defineProperty(forms, "getForm", {value: jest.fn(oldImpl)});
render(<Router><Route history={history}><FormPage /></Route></Router>);
-
+
expect(forms.getForm).toBeCalled();
});
diff --git a/src/tests/pages/LandingPage.test.tsx b/src/tests/pages/LandingPage.test.tsx
index 5635e63..e461815 100644
--- a/src/tests/pages/LandingPage.test.tsx
+++ b/src/tests/pages/LandingPage.test.tsx
@@ -17,9 +17,11 @@ const testingForm: forms.Form = {
"id": "my-question",
"name": "My Question",
"type": QuestionType.ShortText,
- "data": {}
+ "data": {},
+ required: true
}
- ]
+ ],
+ "webhook": null
};
test("renders landing page", () => {