Desktop poll code clean up and refinements (#120)

This commit is contained in:
Luke Vella 2022-04-21 13:01:29 +01:00 committed by GitHub
parent 8cd1db41db
commit 00b72d01bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 160 additions and 146 deletions

View file

@ -0,0 +1,35 @@
import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import React from "react";
import { usePollContext } from "./poll-context";
const ControlledScrollArea: React.VoidFunctionComponent<{
children?: React.ReactNode;
className?: string;
}> = ({ className, children }) => {
const { availableSpace, scrollPosition } = usePollContext();
return (
<div
className={clsx("min-w-0 overflow-hidden", className)}
style={{ width: availableSpace, maxWidth: availableSpace }}
>
<AnimatePresence initial={false}>
<motion.div
className="flex h-full"
transition={{
type: "spring",
mass: 0.4,
}}
initial={{ x: 0 }}
animate={{ x: scrollPosition * -1 }}
>
{children}
</motion.div>
</AnimatePresence>
</div>
);
};
export default ControlledScrollArea;

View file

@ -0,0 +1,182 @@
import { Option } from "@prisma/client";
import clsx from "clsx";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import CheckCircle from "@/components/icons/check-circle.svg";
import { requiredString } from "../../../utils/form-validation";
import Button from "../../button";
import NameInput from "../../name-input";
import Tooltip from "../../tooltip";
import { ParticipantForm } from "../types";
import ControlledScrollArea from "./controlled-scroll-area";
import { usePollContext } from "./poll-context";
export interface ParticipantRowFormProps {
defaultValues?: Partial<ParticipantForm>;
onSubmit: (data: ParticipantForm) => Promise<void>;
className?: string;
options: Option[];
onCancel?: () => void;
}
const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
({ defaultValues, onSubmit, className, options, onCancel }) => {
const {
setActiveOptionId,
activeOptionId,
columnWidth,
scrollPosition,
sidebarWidth,
numberOfColumns,
goToNextPage,
setScrollPosition,
} = usePollContext();
const {
handleSubmit,
register,
control,
formState: { errors, submitCount, isSubmitting },
reset,
} = useForm<ParticipantForm>({
defaultValues: { name: "", votes: [], ...defaultValues },
});
const isColumnVisible = (index: number) => {
return (
scrollPosition + numberOfColumns * columnWidth > columnWidth * index
);
};
const checkboxRefs = React.useRef<HTMLInputElement[]>([]);
const isAnimatingRef = React.useRef(false);
// This hack is necessary because when there is only one checkbox,
// react-hook-form does not know to format the value into an array.
// See: https://github.com/react-hook-form/react-hook-form/issues/7834
const checkboxProps = register("votes", {
onBlur: () => setActiveOptionId(null),
});
const checkboxGroupHack = (
<input type="checkbox" className="hidden" {...checkboxProps} />
);
return (
<form
onSubmit={handleSubmit(async ({ name, votes }) => {
await onSubmit({
name,
// if there is only one checkbox then we get a string rather than array
// See this issue with using dot notation: https://github.com/react-hook-form/react-hook-form/issues/7834
votes: Array.isArray(votes) ? votes : [votes],
});
reset();
})}
className={clsx("flex h-14 shrink-0", className)}
>
{checkboxGroupHack}
<div className="flex items-center px-2" style={{ width: sidebarWidth }}>
<Controller
name="name"
rules={{
validate: requiredString,
}}
render={({ field }) => (
<div className="w-full">
<NameInput
autoFocus={true}
className={clsx("w-full", {
"input-error animate-wiggle":
errors.name && submitCount > 0,
})}
placeholder="Your name"
{...field}
onKeyDown={(e) => {
if (e.code === "Tab" && scrollPosition > 0) {
e.preventDefault();
setScrollPosition(0);
setTimeout(() => {
checkboxRefs.current[0].focus();
}, 100);
}
}}
/>
</div>
)}
control={control}
/>
</div>
<ControlledScrollArea>
{options.map((option, index) => {
return (
<div
key={option.id}
className={clsx(
"flex shrink-0 items-center justify-center transition-colors",
{
"bg-slate-50": activeOptionId === option.id,
},
)}
style={{ width: columnWidth }}
onMouseOver={() => setActiveOptionId(option.id)}
onMouseOut={() => setActiveOptionId(null)}
>
<input
className="checkbox"
type="checkbox"
value={option.id}
onKeyDown={(e) => {
if (isAnimatingRef.current) {
return e.preventDefault();
}
if (
e.code === "Tab" &&
index < options.length - 1 &&
!isColumnVisible(index + 1)
) {
isAnimatingRef.current = true;
e.preventDefault();
goToNextPage();
setTimeout(() => {
checkboxRefs.current[index + 1].focus();
isAnimatingRef.current = false;
}, 100);
}
}}
{...checkboxProps}
ref={(el) => {
if (el) {
checkboxRefs.current[index] = el;
checkboxProps.ref(el);
}
}}
onFocus={() => {
setActiveOptionId(option.id);
}}
/>
</div>
);
})}
</ControlledScrollArea>
<div className="flex items-center space-x-2 px-2 transition-all">
<Tooltip content="Save" placement="top">
<Button
htmlType="submit"
icon={<CheckCircle />}
type="primary"
loading={isSubmitting}
data-testid="submitNewParticipant"
/>
</Tooltip>
<Button onClick={onCancel} type="default">
Cancel
</Button>
</div>
</form>
);
};
export default ParticipantRowForm;

View file

@ -0,0 +1,149 @@
import { Option, Participant, Vote } from "@prisma/client";
import clsx from "clsx";
import * as React from "react";
import Button from "@/components/button";
import Pencil from "@/components/icons/pencil.svg";
import Trash from "@/components/icons/trash.svg";
import { usePoll } from "@/components/poll-context";
import { useUpdateParticipantMutation } from "../mutations";
import { useDeleteParticipantModal } from "../use-delete-participant-modal";
import UserAvater from "../user-avatar";
import VoteIcon from "../vote-icon";
import ControlledScrollArea from "./controlled-scroll-area";
import ParticipantRowForm from "./participant-row-form";
import { usePollContext } from "./poll-context";
export interface ParticipantRowProps {
urlId: string;
participant: Participant & { votes: Vote[] };
options: Array<Option & { votes: Vote[] }>;
editMode: boolean;
canDelete?: boolean;
onChangeEditMode?: (value: boolean) => void;
}
const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
urlId,
participant,
options,
editMode,
canDelete,
onChangeEditMode,
}) => {
const {
setActiveOptionId,
activeOptionId,
columnWidth,
sidebarWidth,
actionColumnWidth,
} = usePollContext();
const { mutate: updateParticipantMutation } =
useUpdateParticipantMutation(urlId);
const confirmDeleteParticipant = useDeleteParticipantModal();
const { poll } = usePoll();
if (editMode) {
return (
<ParticipantRowForm
defaultValues={{
name: participant.name,
votes: participant.votes.map(({ optionId }) => optionId),
}}
onSubmit={async ({ name, votes }) => {
return new Promise((resolve, reject) => {
updateParticipantMutation(
{
pollId: participant.pollId,
participantId: participant.id,
votes,
name,
},
{
onSuccess: () => {
onChangeEditMode?.(false);
resolve();
},
onError: reject,
},
);
});
}}
options={options}
onCancel={() => onChangeEditMode?.(false)}
/>
);
}
return (
<div
key={participant.id}
className="group flex h-14 transition-colors hover:bg-slate-50"
>
<div
className="flex shrink-0 items-center px-4"
style={{ width: sidebarWidth }}
>
<UserAvater className="mr-2" name={participant.name} />
<span className="truncate" title={participant.name}>
{participant.name}
</span>
</div>
<ControlledScrollArea>
{options.map((option) => {
return (
<div
key={option.id}
className={clsx(
"flex shrink-0 items-center justify-center transition-colors",
{
"bg-slate-50": activeOptionId === option.id,
},
)}
style={{ width: columnWidth }}
onMouseOver={() => setActiveOptionId(option.id)}
onMouseOut={() => setActiveOptionId(null)}
>
{option.votes.some(
(vote) => vote.participantId === participant.id,
) ? (
<VoteIcon type="yes" />
) : (
<VoteIcon type="no" />
)}
</div>
);
})}
</ControlledScrollArea>
{!poll.closed ? (
<div
style={{ width: actionColumnWidth }}
className="flex items-center space-x-2 overflow-hidden px-2 opacity-0 transition-all delay-100 group-hover:opacity-100"
>
<Button
icon={<Pencil />}
onClick={() => {
onChangeEditMode?.(true);
}}
>
Edit
</Button>
{canDelete ? (
<Button
icon={<Trash />}
type="danger"
onClick={() => {
confirmDeleteParticipant(participant.id);
}}
/>
) : null}
</div>
) : null}
</div>
);
};
export default ParticipantRow;

View file

@ -0,0 +1,30 @@
import noop from "lodash/noop";
import React from "react";
export const PollContext = React.createContext<{
activeOptionId: string | null;
setActiveOptionId: (optionId: string | null) => void;
scrollPosition: number;
setScrollPosition: (position: number) => void;
columnWidth: number;
sidebarWidth: number;
numberOfColumns: number;
availableSpace: number;
actionColumnWidth: number;
goToNextPage: () => void;
goToPreviousPage: () => void;
}>({
activeOptionId: null,
setActiveOptionId: noop,
scrollPosition: 0,
setScrollPosition: noop,
columnWidth: 100,
sidebarWidth: 200,
numberOfColumns: 0,
availableSpace: 0,
goToNextPage: noop,
goToPreviousPage: noop,
actionColumnWidth: 0,
});
export const usePollContext = () => React.useContext(PollContext);

View file

@ -0,0 +1,80 @@
import clsx from "clsx";
import * as React from "react";
import DateCard from "@/components/date-card";
import { usePoll } from "@/components/poll-context";
import ControlledScrollArea from "./controlled-scroll-area";
import { usePollContext } from "./poll-context";
import Score from "./score";
const TimeRange: React.VoidFunctionComponent<{
startTime: string;
endTime: string;
className?: string;
}> = ({ startTime, endTime, className }) => {
return (
<div
className={clsx(
"relative inline-block pr-2 text-right text-xs font-semibold after:absolute after:top-2 after:right-0 after:h-4 after:w-1 after:border-t after:border-r after:border-b after:border-slate-300 after:content-['']",
className,
)}
>
<div>{startTime}</div>
<div className="text-slate-400">{endTime}</div>
</div>
);
};
const PollHeader: React.VoidFunctionComponent = () => {
const { options, getParticipantsWhoVotedForOption, highScore } = usePoll();
const { activeOptionId, setActiveOptionId, columnWidth } = usePollContext();
return (
<ControlledScrollArea>
{options.map((option) => {
const { optionId } = option;
const numVotes = getParticipantsWhoVotedForOption(optionId).length;
return (
<div
key={optionId}
className={clsx(
"shrink-0 pt-4 pb-3 text-center transition-colors",
{
"bg-slate-50": activeOptionId === optionId,
},
)}
style={{ width: columnWidth }}
onMouseOver={() => setActiveOptionId(optionId)}
onMouseOut={() => setActiveOptionId(null)}
>
<div>
<DateCard
day={option.day}
dow={option.dow}
month={option.month}
annotation={
numVotes > 0 ? (
<Score
count={numVotes}
highlight={numVotes > 1 && highScore === numVotes}
/>
) : null
}
/>
</div>
{option.type === "timeSlot" ? (
<TimeRange
className="mt-3"
startTime={option.startTime}
endTime={option.endTime}
/>
) : null}
</div>
);
})}
</ControlledScrollArea>
);
};
export default PollHeader;

View file

@ -0,0 +1,34 @@
import clsx from "clsx";
import * as React from "react";
export interface ScoreProps {
count: number;
highlight?: boolean;
style?: React.CSSProperties;
className?: string;
}
const Score: React.VoidFunctionComponent<ScoreProps> = ({
count,
highlight,
style,
className,
}) => {
return (
<div
className={clsx(
" z-20 flex h-5 w-5 items-center justify-center rounded-full text-xs shadow-sm shadow-slate-200 transition-colors",
{
"bg-rose-500 text-white": highlight,
"bg-slate-200 text-slate-500": !highlight,
},
className,
)}
style={style}
>
{count}
</div>
);
};
export default Score;