diff options
author | 2021-01-17 16:19:18 +0000 | |
---|---|---|
committer | 2021-01-17 16:19:18 +0000 | |
commit | c1bee9cb0efba823740095380cfcca9bf47eb196 (patch) | |
tree | 18ed06bca3d17341e9eeabc5364023b9f3ee250b /src | |
parent | Merge pull request #82 from python-discord/renovate/typescript-eslint-monorepo (diff) | |
parent | Centers Title With No Description (diff) |
Merge pull request #74 from python-discord/form-rendering
Diffstat (limited to 'src')
-rw-r--r-- | src/api/forms.ts | 38 | ||||
-rw-r--r-- | src/api/question.ts | 2 | ||||
-rw-r--r-- | src/colors.ts | 2 | ||||
-rw-r--r-- | src/commonStyles.tsx | 60 | ||||
-rw-r--r-- | src/components/HeaderBar/index.tsx | 139 | ||||
-rw-r--r-- | src/components/HeaderBar/logo.svg | 3 | ||||
-rw-r--r-- | src/components/InputTypes/Checkbox.tsx | 65 | ||||
-rw-r--r-- | src/components/InputTypes/Code.tsx | 11 | ||||
-rw-r--r-- | src/components/InputTypes/Radio.tsx | 39 | ||||
-rw-r--r-- | src/components/InputTypes/Range.tsx | 117 | ||||
-rw-r--r-- | src/components/InputTypes/Select.tsx | 204 | ||||
-rw-r--r-- | src/components/InputTypes/ShortText.tsx | 12 | ||||
-rw-r--r-- | src/components/InputTypes/TextArea.tsx | 21 | ||||
-rw-r--r-- | src/components/InputTypes/index.tsx | 71 | ||||
-rw-r--r-- | src/components/Question.tsx | 166 | ||||
-rw-r--r-- | src/components/ScrollToTop.tsx | 93 | ||||
-rw-r--r-- | src/pages/FormPage.tsx | 194 | ||||
-rw-r--r-- | src/pages/LandingPage.tsx | 7 | ||||
-rw-r--r-- | src/tests/components/FormListing.test.tsx | 12 | ||||
-rw-r--r-- | src/tests/components/HeaderBar.test.tsx | 23 | ||||
-rw-r--r-- | src/tests/pages/FormPage.test.tsx | 2 | ||||
-rw-r--r-- | src/tests/pages/LandingPage.test.tsx | 6 |
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", () => { |