diff options
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", () => { | 
