aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorGravatar Joe Banks <[email protected]>2024-07-11 02:23:51 +0100
committerGravatar Joe Banks <[email protected]>2024-07-11 02:23:51 +0100
commit58310afba4e89db6966fbf8f32cd4a44a52a6ca6 (patch)
treedd9d5e3d1e75629871e57e7ce97381eca1bffd58 /src
parentAdd new package for animated list reorders (diff)
Add new vote component and voting redux store
Diffstat (limited to 'src')
-rw-r--r--src/components/InputTypes/Vote.tsx226
-rw-r--r--src/slices/votes.ts73
-rw-r--r--src/utils.ts24
3 files changed, 323 insertions, 0 deletions
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<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,
+}: {
+ cards: string[];
+ questionId: string;
+ handler: VoteProps["handler"];
+}) {
+ const votes = useSelector<
+ { vote: VoteSliceState },
+ Record<string, number | null>
+ >((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 (
+ <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 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}
+ />
+ </div>
+ );
+}
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<string, Record<string, number | null>>;
+}
+
+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<VoteRegister>,
+ ) => {
+ state.votes[action.payload.questionId] = {};
+ action.payload.questionSlugs.forEach((value) => {
+ state.votes[action.payload.questionId][value] = null;
+ });
+ },
+ changeVote: (
+ state: VoteSliceState,
+ action: PayloadAction<VoteChange>,
+ ) => {
+ 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";
+}