diff --git a/package.json b/package.json index 4e0ed86c..7c03acfc 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "react": "18.3.1", "react-app-polyfill": "3.0.0", "react-dom": "18.3.1", + "react-flip-move": "^3.0.5", "react-redux": "^9.1.2", "react-router-dom": "6.24.1", "react-spinners": "0.14.1", diff --git a/src/api/question.ts b/src/api/question.ts index 56706e2e..0fcea036 100644 --- a/src/api/question.ts +++ b/src/api/question.ts @@ -8,6 +8,7 @@ export enum QuestionType { Range = "range", Section = "section", TimeZone = "timezone", + Vote = "vote" } export interface Question { diff --git a/src/components/InputTypes/Vote.tsx b/src/components/InputTypes/Vote.tsx new file mode 100644 index 00000000..c630d8c0 --- /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; + valid: boolean; + question: React.RefObject; + questionId: string; + handler: (event: Record) => 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 ( +
+ + +
+ ); +}; + +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 ? ( +
+ No preference +
+ ) : ( +
+ {humanize(foundIndex)} +
+ ); + + return ( +
+ {indexMarker} +

{content}

+
+ +
+ ); +}; + +const CardList = React.memo(function CardList({ + cards, + questionId, + handler, + reverseMap +}: { + cards: string[]; + questionId: string; + handler: VoteProps["handler"]; + reverseMap: Record; +}) { + const votes = useSelector< + { vote: VoteSliceState }, + Record + >((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 ( + + {cards.map((cardContent: string) => ( +
+ +
+ ))} +
+ ); +}); + +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 ( +
+

{COPY}

+ +
+ ); +} diff --git a/src/components/InputTypes/index.tsx b/src/components/InputTypes/index.tsx index ee92ef18..1880bcd6 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 = ; break; + case QuestionType.Vote: + result = ; + break; + default: result =