aboutsummaryrefslogtreecommitdiffstats
path: root/src/components
diff options
context:
space:
mode:
authorGravatar Joe Banks <[email protected]>2024-07-11 04:14:48 +0100
committerGravatar GitHub <[email protected]>2024-07-11 04:14:48 +0100
commit1d1afff8c0e7a5ee6f39a0d3888d8acd6d459cf7 (patch)
tree7267f41035be6d62c92729577aec98183e25cf39 /src/components
parentMerge pull request #637 from python-discord/dependabot/npm_and_yarn/sentry/re... (diff)
parentRe-map slugged vote options to human form when upstreaming to form (diff)
Merge pull request #638 from python-discord/jb3/components/vote-field
Vote component
Diffstat (limited to 'src/components')
-rw-r--r--src/components/InputTypes/Vote.tsx241
-rw-r--r--src/components/InputTypes/index.tsx5
-rw-r--r--src/components/Question.tsx18
3 files changed, 261 insertions, 3 deletions
diff --git a/src/components/InputTypes/Vote.tsx b/src/components/InputTypes/Vote.tsx
new file mode 100644
index 0000000..c630d8c
--- /dev/null
+++ b/src/components/InputTypes/Vote.tsx
@@ -0,0 +1,241 @@
+/** @jsx jsx */
+import { jsx, css } from "@emotion/react";
+
+import React, { useEffect, useState } from "react";
+import RenderedQuestion from "../Question";
+import { slugify, humanize } from "../../utils";
+import colors from "../../colors";
+import { useDispatch, useSelector } from "react-redux";
+import { registerVote, changeVote, VoteSliceState } from "../../slices/votes";
+import FlipMove from "react-flip-move";
+
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faCaretUp, faCaretDown } from "@fortawesome/free-solid-svg-icons";
+
+interface VoteProps {
+ options: Array<string>;
+ valid: boolean;
+ question: React.RefObject<RenderedQuestion>;
+ questionId: string;
+ handler: (event: Record<string, number | null>) => void;
+}
+
+const baseCardStyles = css`
+ display: flex;
+ align-items: center;
+ background-color: ${colors.darkerGreyple};
+ padding: 10px;
+ border-radius: 5px;
+ margin-bottom: 10px;
+`;
+
+const buttonStyles = css`
+ border: none;
+ background: ${colors.greyple};
+ cursor: pointer;
+ margin-right: 20px;
+ margin-left: 20px;
+ border-radius: 5px;
+ transition: transform 0.1s ease;
+ transform: none;
+
+ :hover {
+ transform: scale(1.1);
+ }
+`;
+
+const iconStyles = css`
+ font-size: 2em;
+ color: white;
+`;
+
+const markerStyles = css`
+ border-radius: 5px;
+ padding: 5px;
+ margin-right: 10px;
+`;
+
+const votingStyles = css`
+ button {
+ background-color: ${colors.darkerBlurple};
+ }
+`;
+
+const Buttons = (base: { questionId: string; optionId: string }) => {
+ const dispatch = useDispatch();
+
+ return (
+ <div>
+ <button
+ type="button"
+ css={buttonStyles}
+ onClick={() => dispatch(changeVote({ ...base, change: -1 }))}
+ >
+ <FontAwesomeIcon css={iconStyles} icon={faCaretUp} />
+ </button>
+ <button
+ type="button"
+ css={buttonStyles}
+ onClick={() => dispatch(changeVote({ ...base, change: +1 }))}
+ >
+ <FontAwesomeIcon css={iconStyles} icon={faCaretDown} />
+ </button>
+ </div>
+ );
+};
+
+const Card = ({
+ id,
+ content,
+ questionId,
+}: {
+ id: string;
+ content: string;
+ questionId: string;
+}) => {
+ const foundIndex = useSelector<{ vote: VoteSliceState }, number | null>(
+ (state) => {
+ const votes = state.vote?.votes;
+ const questionVotes = votes?.[questionId];
+ return questionVotes ? questionVotes[id] : null;
+ },
+ );
+
+ const indexMarker =
+ foundIndex === null ? (
+ <div
+ css={css`
+ ${markerStyles}
+ background-color: ${colors.greyple};
+ `}
+ >
+ No preference
+ </div>
+ ) : (
+ <div
+ css={css`
+ ${markerStyles}
+ background-color: ${colors.darkerBlurple};
+ padding-left: 10px;
+ padding-right: 10px;
+ `}
+ >
+ {humanize(foundIndex)}
+ </div>
+ );
+
+ return (
+ <div
+ css={css`
+ ${baseCardStyles}
+ ${foundIndex ? votingStyles : null}
+ background-color: ${foundIndex === null
+ ? colors.darkerGreyple
+ : colors.blurple};
+ `}
+ >
+ {indexMarker}
+ <p>{content}</p>
+ <div css={{ flexGrow: 1 }} />
+ <Buttons questionId={questionId} optionId={id} />
+ </div>
+ );
+};
+
+const CardList = React.memo(function CardList({
+ cards,
+ questionId,
+ handler,
+ reverseMap
+}: {
+ cards: string[];
+ questionId: string;
+ handler: VoteProps["handler"];
+ reverseMap: Record<string, string>;
+}) {
+ const votes = useSelector<
+ { vote: VoteSliceState },
+ Record<string, number | null>
+ >((state) => {
+ const votes = state.vote?.votes;
+ const questionVotes = votes?.[questionId];
+ return questionVotes;
+ });
+
+ useEffect(() => {
+ if (!votes) {
+ return;
+ }
+
+ const updated = Object.fromEntries(
+ Object.entries(votes).map(([slug, vote]) => [reverseMap[slug], vote])
+ );
+
+ handler(updated);
+ }, [votes]);
+
+ if (votes) {
+ cards = cards.sort((a, b) => {
+ if (!votes[slugify(a)]) {
+ return 1;
+ }
+
+ if (!votes[slugify(b)]) {
+ return -1;
+ }
+ return votes[slugify(a)] - votes[slugify(b)];
+ });
+ }
+
+ return (
+ <FlipMove duration={500}>
+ {cards.map((cardContent: string) => (
+ <div key={slugify(cardContent)}>
+ <Card
+ content={cardContent}
+ questionId={questionId}
+ id={slugify(cardContent)}
+ />
+ </div>
+ ))}
+ </FlipMove>
+ );
+});
+
+export default function Vote(props: VoteProps): JSX.Element {
+ const [state,] = useState({
+ cards: props.options
+ .map((value) => ({ value, sort: Math.random() }))
+ .sort((a, b) => a.sort - b.sort)
+ .map(({ value }) => value),
+ });
+
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ dispatch(
+ registerVote({
+ questionId: props.questionId,
+ questionSlugs: state.cards.map((value) => slugify(value)),
+ }),
+ );
+ }, [props.questionId]);
+
+ const reverseMap = Object.fromEntries(props.options.map(value => {
+ return [slugify(value), value];
+ }));
+
+ const COPY = "Use the buttons to organise options into your preferred order. You can have multiple options with the same ranking. Additionally, you can leave some or all options as \"No preference\" if you do not wish to order them.";
+
+ return (
+ <div>
+ <p>{COPY}</p>
+ <CardList
+ questionId={props.questionId}
+ handler={props.handler}
+ cards={state.cards}
+ reverseMap={reverseMap}
+ />
+ </div>
+ );
+}
diff --git a/src/components/InputTypes/index.tsx b/src/components/InputTypes/index.tsx
index ee92ef1..1880bcd 100644
--- a/src/components/InputTypes/index.tsx
+++ b/src/components/InputTypes/index.tsx
@@ -11,6 +11,7 @@ import {QuestionType} from "../../api/question";
import RenderedQuestion from "../Question";
import Code from "./Code";
import TimeZone from "./TimeZone";
+import Vote from "./Vote";
export default function create_input(
{props: renderedQuestionProps, realState}: RenderedQuestion,
@@ -67,6 +68,10 @@ export default function create_input(
result = <TimeZone question={renderedQuestionProps.selfRef} valid={valid} onBlurHandler={onBlurHandler}/>;
break;
+ case QuestionType.Vote:
+ result = <Vote question={renderedQuestionProps.selfRef} handler={handler} questionId={question.id} valid={valid} options={options}/>;
+ break;
+
default:
result = <TextArea handler={handler} valid={valid} onBlurHandler={onBlurHandler} focus_ref={focus_ref}/>;
}
diff --git a/src/components/Question.tsx b/src/components/Question.tsx
index fb5c419..66784d0 100644
--- a/src/components/Question.tsx
+++ b/src/components/Question.tsx
@@ -13,12 +13,13 @@ const skip_normal_state: Array<QuestionType> = [
QuestionType.Select,
QuestionType.TimeZone,
QuestionType.Section,
- QuestionType.Range
+ QuestionType.Range,
+ QuestionType.Vote
];
export interface QuestionState {
// Common keys
- value: string | null | Map<string, boolean>
+ value: string | null | Map<string, boolean> | Record<string, number | null>
// Validation
valid: boolean
@@ -56,6 +57,8 @@ class RenderedQuestion extends React.Component<QuestionProp> {
this.handler = this.text_area_handler.bind(this);
} else if (props.question.type === QuestionType.Code) {
this.handler = this.code_field_handler.bind(this);
+ } else if (props.question.type === QuestionType.Vote) {
+ this.handler = this.vote_handler.bind(this);
} else {
this.handler = this.normal_handler.bind(this);
}
@@ -74,7 +77,7 @@ class RenderedQuestion extends React.Component<QuestionProp> {
}
// This is here to allow dynamic selection between the general handler, textarea, and code field handlers.
- handler(_: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string): void {} // eslint-disable-line
+ handler(_: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string | Record<string, number | null>): void {} // eslint-disable-line
blurHandler(): void {
if (this.props.question.required) {
@@ -150,6 +153,14 @@ class RenderedQuestion extends React.Component<QuestionProp> {
}
}
+ vote_handler(event: Record<string, number | null>) {
+ this.setState({
+ value: event,
+ valid: true,
+ error: ""
+ });
+ }
+
text_area_handler(event: ChangeEvent<HTMLTextAreaElement>): void {
// We will validate again when focusing out.
this.setState({
@@ -194,6 +205,7 @@ class RenderedQuestion extends React.Component<QuestionProp> {
case QuestionType.Select:
case QuestionType.Range:
case QuestionType.TimeZone:
+ case QuestionType.Vote:
case QuestionType.Radio:
if (!this.realState.value) {
valid = false;