Skip to content

Commit

Permalink
Merge pull request #638 from python-discord/jb3/components/vote-field
Browse files Browse the repository at this point in the history
Vote component
  • Loading branch information
jb3 authored Jul 11, 2024
2 parents 06a7b9f + dcf2413 commit 1d1afff
Show file tree
Hide file tree
Showing 10 changed files with 383 additions and 21 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/api/question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export enum QuestionType {
Range = "range",
Section = "section",
TimeZone = "timezone",
Vote = "vote"
}

export interface Question {
Expand Down
241 changes: 241 additions & 0 deletions src/components/InputTypes/Vote.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
5 changes: 5 additions & 0 deletions src/components/InputTypes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}/>;
}
Expand Down
18 changes: 15 additions & 3 deletions src/components/Question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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;
Expand Down
32 changes: 15 additions & 17 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,23 +62,21 @@ const rootDocument = document.getElementById("root");

const root = createRoot(rootDocument!);
root.render(
<React.StrictMode>
<Sentry.ErrorBoundary
fallback={<p>An error has occurred with Python Discord Forms. Please let us know in the Discord server at <a href="https://discord.gg/python">discord.gg/python</a></p>}
showDialog={true}
dialogOptions={{
title: "You've found a bug in PyDis forms!"
}}
onError={(err) => {
if(process.env.NODE_ENV === "development")
console.log(err);
}}
>
<Provider store={formsStore}>
<App/>
</Provider>
</Sentry.ErrorBoundary>
</React.StrictMode>
<Sentry.ErrorBoundary
fallback={<p>An error has occurred with Python Discord Forms. Please let us know in the Discord server at <a href="https://discord.gg/python">discord.gg/python</a></p>}
showDialog={true}
dialogOptions={{
title: "You've found a bug in PyDis forms!"
}}
onError={(err) => {
if(process.env.NODE_ENV === "development")
console.log(err);
}}
>
<Provider store={formsStore}>
<App/>
</Provider>
</Sentry.ErrorBoundary>
);

serviceWorker.unregister();
Loading

0 comments on commit 1d1afff

Please sign in to comment.