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 (
+
+ );
+}
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 =
}
- showDialog={true}
- dialogOptions={{
- title: "You've found a bug in PyDis forms!"
- }}
- onError={(err) => {
- if(process.env.NODE_ENV === "development")
- console.log(err);
- }}
- >
-
-
-
-
-
+ An error has occurred with Python Discord Forms. Please let us know in the Discord server at discord.gg/python}
+ showDialog={true}
+ dialogOptions={{
+ title: "You've found a bug in PyDis forms!"
+ }}
+ onError={(err) => {
+ if(process.env.NODE_ENV === "development")
+ console.log(err);
+ }}
+ >
+
+
+
+
);
serviceWorker.unregister();
diff --git a/src/slices/votes.ts b/src/slices/votes.ts
new file mode 100644
index 00000000..87d84e84
--- /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/store.ts b/src/store.ts
index 1b9807b9..e516bbab 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -1,9 +1,11 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import authorizationReducer from "./slices/authorization";
+import voteReducer from "./slices/votes";
const rootReducer = combineReducers({
- authorization: authorizationReducer
+ authorization: authorizationReducer,
+ vote: voteReducer
});
export const setupStore = (preloadedState?: Partial) => {
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 00000000..80670452
--- /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";
+}
diff --git a/yarn.lock b/yarn.lock
index 84e1c7ed..f526c4d1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6749,6 +6749,11 @@ react-dom@18.3.1:
loose-envify "^1.1.0"
scheduler "^0.23.2"
+react-flip-move@^3.0.5:
+ version "3.0.5"
+ resolved "https://registry.yarnpkg.com/react-flip-move/-/react-flip-move-3.0.5.tgz#8b87510ad32ebef01ebca94902b445f456bbc0f7"
+ integrity sha512-Mf4XpbkUNZy9eu80iXXFIjToDvw+bnHxmKHVoositbMpV87O/EQswnXUqVovRHoTx/F+4dE+p//PyJnAT7OtPA==
+
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"