From 58310afba4e89db6966fbf8f32cd4a44a52a6ca6 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 11 Jul 2024 02:23:51 +0100 Subject: Add new vote component and voting redux store --- src/components/InputTypes/Vote.tsx | 226 +++++++++++++++++++++++++++++++++++++ src/slices/votes.ts | 73 ++++++++++++ src/utils.ts | 24 ++++ 3 files changed, 323 insertions(+) create mode 100644 src/components/InputTypes/Vote.tsx create mode 100644 src/slices/votes.ts create mode 100644 src/utils.ts diff --git a/src/components/InputTypes/Vote.tsx b/src/components/InputTypes/Vote.tsx new file mode 100644 index 0000000..67a7cac --- /dev/null +++ b/src/components/InputTypes/Vote.tsx @@ -0,0 +1,226 @@ +/** @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, +}: { + cards: string[]; + questionId: string; + handler: VoteProps["handler"]; +}) { + const votes = useSelector< + { vote: VoteSliceState }, + Record + >((state) => { + const votes = state.vote?.votes; + const questionVotes = votes?.[questionId]; + return questionVotes; + }); + + useEffect(() => { + handler(votes); + }, [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 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/slices/votes.ts b/src/slices/votes.ts new file mode 100644 index 0000000..87d84e8 --- /dev/null +++ b/src/slices/votes.ts @@ -0,0 +1,73 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +export interface VoteSliceState { + votes: Record>; +} + +interface VoteChange { + questionId: string; + optionId: string; + change: 1 | -1; +} + +interface VoteRegister { + questionId: string; + questionSlugs: string[]; +} + +const voteSlice = createSlice({ + name: "vote", + initialState: { + votes: {}, + }, + reducers: { + registerVote: ( + state: VoteSliceState, + action: PayloadAction, + ) => { + state.votes[action.payload.questionId] = {}; + action.payload.questionSlugs.forEach((value) => { + state.votes[action.payload.questionId][value] = null; + }); + }, + changeVote: ( + state: VoteSliceState, + action: PayloadAction, + ) => { + const foundVote = + state.votes[action.payload.questionId][action.payload.optionId]; + + if (foundVote !== null) { + if (foundVote === 1 && action.payload.change <= 0) { + return; + } + if ( + foundVote >= + Object.keys(state.votes[action.payload.questionId]) + .length && + action.payload.change >= 0 + ) { + state.votes[action.payload.questionId][ + action.payload.optionId + ] = null; + return; + } + state.votes[action.payload.questionId][ + action.payload.optionId + ] += action.payload.change; + } else { + if (action.payload.change <= 0) { + state.votes[action.payload.questionId][ + action.payload.optionId + ] = Object.keys( + state.votes[action.payload.questionId], + ).length; + } + } + }, + }, +}); + +export const { registerVote, changeVote } = voteSlice.actions; + +export default voteSlice.reducer; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..8067045 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,24 @@ +export function slugify(str: string) { + str = str.replace(/^\s+|\s+$/g, ""); + str = str.toLowerCase(); + str = str + .replace(/[^a-z0-9 -]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-"); + return str; +} + +export function humanize(num: number) { + if (num % 100 >= 11 && num % 100 <= 13) return num + "th"; + + switch (num % 10) { + case 1: + return num + "st"; + case 2: + return num + "nd"; + case 3: + return num + "rd"; + } + + return num + "th"; +} -- cgit v1.2.3