mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-31 23:19:15 +02:00
Desktop poll code clean up and refinements (#120)
This commit is contained in:
parent
8cd1db41db
commit
00b72d01bf
11 changed files with 160 additions and 146 deletions
35
components/poll/desktop-poll/controlled-scroll-area.tsx
Normal file
35
components/poll/desktop-poll/controlled-scroll-area.tsx
Normal 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;
|
182
components/poll/desktop-poll/participant-row-form.tsx
Normal file
182
components/poll/desktop-poll/participant-row-form.tsx
Normal 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;
|
149
components/poll/desktop-poll/participant-row.tsx
Normal file
149
components/poll/desktop-poll/participant-row.tsx
Normal 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;
|
30
components/poll/desktop-poll/poll-context.ts
Normal file
30
components/poll/desktop-poll/poll-context.ts
Normal 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);
|
80
components/poll/desktop-poll/poll-header.tsx
Normal file
80
components/poll/desktop-poll/poll-header.tsx
Normal 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;
|
34
components/poll/desktop-poll/score.tsx
Normal file
34
components/poll/desktop-poll/score.tsx
Normal 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;
|
Loading…
Add table
Add a link
Reference in a new issue