mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-11 16:06:50 +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
|
@ -4,7 +4,7 @@ import * as React from "react";
|
|||
import { useTimeoutFn } from "react-use";
|
||||
|
||||
import DateCard from "../date-card";
|
||||
import Score from "../poll/score";
|
||||
import Score from "../poll/desktop-poll/score";
|
||||
import UserAvater from "../poll/user-avatar";
|
||||
import VoteIcon from "../poll/vote-icon";
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ type PollContextValue = {
|
|||
targetTimeZone: string;
|
||||
setTargetTimeZone: (timeZone: string) => void;
|
||||
pollType: "date" | "timeSlot";
|
||||
highScore: number;
|
||||
getParticipantsWhoVotedForOption: (optionId: string) => Participant[]; // maybe just attach votes to parsed options
|
||||
getParticipantById: (
|
||||
participantId: string,
|
||||
|
@ -60,6 +61,13 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
|||
}, [participantById, poll.options]);
|
||||
|
||||
const contextValue = React.useMemo<PollContextValue>(() => {
|
||||
let highScore = 1;
|
||||
poll.options.forEach((option) => {
|
||||
if (option.votes.length > highScore) {
|
||||
highScore = option.votes.length;
|
||||
}
|
||||
});
|
||||
|
||||
const parsedOptions = decodeOptions(
|
||||
poll.options,
|
||||
poll.timeZone,
|
||||
|
@ -84,6 +92,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
|||
getParticipantById: (participantId) => {
|
||||
return participantById[participantId];
|
||||
},
|
||||
highScore,
|
||||
getParticipantsWhoVotedForOption: (optionId: string) =>
|
||||
participantsByOptionId[optionId],
|
||||
getVote: (participantId, optionId) => {
|
||||
|
|
|
@ -1,24 +1,20 @@
|
|||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
import { useMeasure } from "react-use";
|
||||
import smoothscroll from "smoothscroll-polyfill";
|
||||
|
||||
import { decodeDateOption } from "../../utils/date-time-utils";
|
||||
import Button from "../button";
|
||||
import DateCard from "../date-card";
|
||||
import ArrowLeft from "../icons/arrow-left.svg";
|
||||
import ArrowRight from "../icons/arrow-right.svg";
|
||||
import PlusCircle from "../icons/plus-circle.svg";
|
||||
import { usePoll } from "../poll-context";
|
||||
import TimeZonePicker from "../time-zone-picker";
|
||||
import ParticipantRow from "./desktop-poll/participant-row";
|
||||
import ParticipantRowForm from "./desktop-poll/participant-row-form";
|
||||
import { PollContext } from "./desktop-poll/poll-context";
|
||||
import PollHeader from "./desktop-poll/poll-header";
|
||||
import { useAddParticipantMutation } from "./mutations";
|
||||
import ParticipantRow from "./participant-row";
|
||||
import ParticipantRowForm from "./participant-row-form";
|
||||
import { PollContext, usePollContext } from "./poll-context";
|
||||
import Score from "./score";
|
||||
import TimeRange from "./time-range";
|
||||
import { PollProps } from "./types";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
|
@ -27,48 +23,14 @@ if (typeof window !== "undefined") {
|
|||
|
||||
const MotionButton = motion(Button);
|
||||
|
||||
export const ControlledScrollDiv: React.VoidFunctionComponent<{
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ className, children }) => {
|
||||
const { availableSpace, scrollPosition } = usePollContext();
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const minSidebarWidth = 180;
|
||||
|
||||
const Poll: React.VoidFunctionComponent<PollProps> = ({
|
||||
pollId,
|
||||
highScore,
|
||||
}) => {
|
||||
const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const { poll, targetTimeZone, setTargetTimeZone } = usePoll();
|
||||
const { poll, targetTimeZone, setTargetTimeZone, options } = usePoll();
|
||||
|
||||
const { timeZone, options, participants, role } = poll;
|
||||
const { timeZone, participants, role } = poll;
|
||||
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
const [editingParticipantId, setEditingParticipantId] =
|
||||
|
@ -204,53 +166,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({
|
|||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<ControlledScrollDiv>
|
||||
{options.map((option) => {
|
||||
const parsedOption = decodeDateOption(
|
||||
option,
|
||||
timeZone,
|
||||
targetTimeZone,
|
||||
);
|
||||
const numVotes = option.votes.length;
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className={clsx(
|
||||
"shrink-0 py-4 text-center transition-colors",
|
||||
{
|
||||
"bg-slate-50": activeOptionId === option.id,
|
||||
},
|
||||
)}
|
||||
style={{ width: columnWidth }}
|
||||
onMouseOver={() => setActiveOptionId(option.id)}
|
||||
onMouseOut={() => setActiveOptionId(null)}
|
||||
>
|
||||
<div>
|
||||
<DateCard
|
||||
day={parsedOption.day}
|
||||
dow={parsedOption.dow}
|
||||
month={parsedOption.month}
|
||||
annotation={
|
||||
<Score
|
||||
count={numVotes}
|
||||
highlight={numVotes === highScore}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{parsedOption.type === "timeSlot" ? (
|
||||
<TimeRange
|
||||
className="mt-2 -mr-2"
|
||||
startTime={parsedOption.startTime}
|
||||
endTime={parsedOption.endTime}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ControlledScrollDiv>
|
||||
<PollHeader />
|
||||
<div
|
||||
className="flex items-center py-3 px-2"
|
||||
style={{ width: actionColumnWidth }}
|
||||
|
@ -295,7 +211,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({
|
|||
});
|
||||
});
|
||||
}}
|
||||
options={options}
|
||||
options={poll.options}
|
||||
onCancel={() => {
|
||||
setShouldShowNewParticipantForm(false);
|
||||
}}
|
||||
|
@ -309,7 +225,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({
|
|||
urlId={pollId}
|
||||
key={i}
|
||||
participant={participant}
|
||||
options={options}
|
||||
options={poll.options}
|
||||
canDelete={role === "admin"}
|
||||
editMode={editingParticipantId === participant.id}
|
||||
onChangeEditMode={(isEditing) => {
|
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;
|
|
@ -3,14 +3,15 @@ import clsx from "clsx";
|
|||
import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { requiredString } from "../../utils/form-validation";
|
||||
import Button from "../button";
|
||||
import CheckCircle from "../icons/check-circle.svg";
|
||||
import NameInput from "../name-input";
|
||||
import Tooltip from "../tooltip";
|
||||
import { ControlledScrollDiv } from "./poll";
|
||||
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";
|
||||
import { ParticipantForm } from "./types";
|
||||
|
||||
export interface ParticipantRowFormProps {
|
||||
defaultValues?: Partial<ParticipantForm>;
|
||||
|
@ -99,7 +100,7 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
|||
setScrollPosition(0);
|
||||
setTimeout(() => {
|
||||
checkboxRefs.current[0].focus();
|
||||
}, 800);
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -108,7 +109,7 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
|||
control={control}
|
||||
/>
|
||||
</div>
|
||||
<ControlledScrollDiv>
|
||||
<ControlledScrollArea>
|
||||
{options.map((option, index) => {
|
||||
return (
|
||||
<div
|
||||
|
@ -142,7 +143,7 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
|||
setTimeout(() => {
|
||||
checkboxRefs.current[index + 1].focus();
|
||||
isAnimatingRef.current = false;
|
||||
}, 500);
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
{...checkboxProps}
|
||||
|
@ -159,7 +160,7 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
</ControlledScrollDiv>
|
||||
</ControlledScrollArea>
|
||||
<div className="flex items-center space-x-2 px-2 transition-all">
|
||||
<Tooltip content="Save" placement="top">
|
||||
<Button
|
|
@ -2,17 +2,18 @@ import { Option, Participant, Vote } from "@prisma/client";
|
|||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
import Button from "../button";
|
||||
import Pencil from "../icons/pencil.svg";
|
||||
import Trash from "../icons/trash.svg";
|
||||
import { usePoll } from "../poll-context";
|
||||
import { useUpdateParticipantMutation } from "./mutations";
|
||||
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 { ControlledScrollDiv } from "./poll";
|
||||
import { usePollContext } from "./poll-context";
|
||||
import { useDeleteParticipantModal } from "./use-delete-participant-modal";
|
||||
import UserAvater from "./user-avatar";
|
||||
import VoteIcon from "./vote-icon";
|
||||
|
||||
export interface ParticipantRowProps {
|
||||
urlId: string;
|
||||
|
@ -91,7 +92,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
{participant.name}
|
||||
</span>
|
||||
</div>
|
||||
<ControlledScrollDiv>
|
||||
<ControlledScrollArea>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<div
|
||||
|
@ -116,7 +117,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
</ControlledScrollDiv>
|
||||
</ControlledScrollArea>
|
||||
{!poll.closed ? (
|
||||
<div
|
||||
style={{ width: actionColumnWidth }}
|
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;
|
|
@ -1 +1 @@
|
|||
export { default } from "./poll";
|
||||
export { default } from "./desktop-poll";
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
export interface TimeRangeProps {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TimeRange: React.VoidFunctionComponent<TimeRangeProps> = ({
|
||||
startTime,
|
||||
endTime,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"relative inline-block pr-2 text-right font-mono text-xs 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>{endTime}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeRange;
|
Loading…
Add table
Add a link
Reference in a new issue