diff options
author | 2024-07-11 04:14:48 +0100 | |
---|---|---|
committer | 2024-07-11 04:14:48 +0100 | |
commit | 1d1afff8c0e7a5ee6f39a0d3888d8acd6d459cf7 (patch) | |
tree | 7267f41035be6d62c92729577aec98183e25cf39 | |
parent | Merge pull request #637 from python-discord/dependabot/npm_and_yarn/sentry/re... (diff) | |
parent | Re-map slugged vote options to human form when upstreaming to form (diff) |
Merge pull request #638 from python-discord/jb3/components/vote-field
Vote component
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | src/api/question.ts | 1 | ||||
-rw-r--r-- | src/components/InputTypes/Vote.tsx | 241 | ||||
-rw-r--r-- | src/components/InputTypes/index.tsx | 5 | ||||
-rw-r--r-- | src/components/Question.tsx | 18 | ||||
-rw-r--r-- | src/index.tsx | 32 | ||||
-rw-r--r-- | src/slices/votes.ts | 73 | ||||
-rw-r--r-- | src/store.ts | 4 | ||||
-rw-r--r-- | src/utils.ts | 24 | ||||
-rw-r--r-- | yarn.lock | 5 |
10 files changed, 383 insertions, 21 deletions
diff --git a/package.json b/package.json index 4e0ed86..7c03acf 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 56706e2..0fcea03 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 0000000..c630d8c --- /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<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> + ); +} diff --git a/src/components/InputTypes/index.tsx b/src/components/InputTypes/index.tsx index ee92ef1..1880bcd 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 = <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}/>; } diff --git a/src/components/Question.tsx b/src/components/Question.tsx index fb5c419..66784d0 100644 --- a/src/components/Question.tsx +++ b/src/components/Question.tsx @@ -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 @@ -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); } @@ -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) { @@ -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({ @@ -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; diff --git a/src/index.tsx b/src/index.tsx index a7fdd2a..a63c9e1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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(); 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/store.ts b/src/store.ts index 1b9807b..e516bba 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<RootState>) => { 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"; +} @@ -6749,6 +6749,11 @@ [email protected]: 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" |