mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-15 09:56:47 +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 { useTimeoutFn } from "react-use";
|
||||||
|
|
||||||
import DateCard from "../date-card";
|
import DateCard from "../date-card";
|
||||||
import Score from "../poll/score";
|
import Score from "../poll/desktop-poll/score";
|
||||||
import UserAvater from "../poll/user-avatar";
|
import UserAvater from "../poll/user-avatar";
|
||||||
import VoteIcon from "../poll/vote-icon";
|
import VoteIcon from "../poll/vote-icon";
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ type PollContextValue = {
|
||||||
targetTimeZone: string;
|
targetTimeZone: string;
|
||||||
setTargetTimeZone: (timeZone: string) => void;
|
setTargetTimeZone: (timeZone: string) => void;
|
||||||
pollType: "date" | "timeSlot";
|
pollType: "date" | "timeSlot";
|
||||||
|
highScore: number;
|
||||||
getParticipantsWhoVotedForOption: (optionId: string) => Participant[]; // maybe just attach votes to parsed options
|
getParticipantsWhoVotedForOption: (optionId: string) => Participant[]; // maybe just attach votes to parsed options
|
||||||
getParticipantById: (
|
getParticipantById: (
|
||||||
participantId: string,
|
participantId: string,
|
||||||
|
@ -60,6 +61,13 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
||||||
}, [participantById, poll.options]);
|
}, [participantById, poll.options]);
|
||||||
|
|
||||||
const contextValue = React.useMemo<PollContextValue>(() => {
|
const contextValue = React.useMemo<PollContextValue>(() => {
|
||||||
|
let highScore = 1;
|
||||||
|
poll.options.forEach((option) => {
|
||||||
|
if (option.votes.length > highScore) {
|
||||||
|
highScore = option.votes.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const parsedOptions = decodeOptions(
|
const parsedOptions = decodeOptions(
|
||||||
poll.options,
|
poll.options,
|
||||||
poll.timeZone,
|
poll.timeZone,
|
||||||
|
@ -84,6 +92,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
||||||
getParticipantById: (participantId) => {
|
getParticipantById: (participantId) => {
|
||||||
return participantById[participantId];
|
return participantById[participantId];
|
||||||
},
|
},
|
||||||
|
highScore,
|
||||||
getParticipantsWhoVotedForOption: (optionId: string) =>
|
getParticipantsWhoVotedForOption: (optionId: string) =>
|
||||||
participantsByOptionId[optionId],
|
participantsByOptionId[optionId],
|
||||||
getVote: (participantId, optionId) => {
|
getVote: (participantId, optionId) => {
|
||||||
|
|
|
@ -1,24 +1,20 @@
|
||||||
import clsx from "clsx";
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useMeasure } from "react-use";
|
import { useMeasure } from "react-use";
|
||||||
import smoothscroll from "smoothscroll-polyfill";
|
import smoothscroll from "smoothscroll-polyfill";
|
||||||
|
|
||||||
import { decodeDateOption } from "../../utils/date-time-utils";
|
|
||||||
import Button from "../button";
|
import Button from "../button";
|
||||||
import DateCard from "../date-card";
|
|
||||||
import ArrowLeft from "../icons/arrow-left.svg";
|
import ArrowLeft from "../icons/arrow-left.svg";
|
||||||
import ArrowRight from "../icons/arrow-right.svg";
|
import ArrowRight from "../icons/arrow-right.svg";
|
||||||
import PlusCircle from "../icons/plus-circle.svg";
|
import PlusCircle from "../icons/plus-circle.svg";
|
||||||
import { usePoll } from "../poll-context";
|
import { usePoll } from "../poll-context";
|
||||||
import TimeZonePicker from "../time-zone-picker";
|
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 { 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";
|
import { PollProps } from "./types";
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
|
@ -27,48 +23,14 @@ if (typeof window !== "undefined") {
|
||||||
|
|
||||||
const MotionButton = motion(Button);
|
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 minSidebarWidth = 180;
|
||||||
|
|
||||||
const Poll: React.VoidFunctionComponent<PollProps> = ({
|
const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
pollId,
|
|
||||||
highScore,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation("app");
|
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 [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||||
const [editingParticipantId, setEditingParticipantId] =
|
const [editingParticipantId, setEditingParticipantId] =
|
||||||
|
@ -204,53 +166,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
<ControlledScrollDiv>
|
<PollHeader />
|
||||||
{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>
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center py-3 px-2"
|
className="flex items-center py-3 px-2"
|
||||||
style={{ width: actionColumnWidth }}
|
style={{ width: actionColumnWidth }}
|
||||||
|
@ -295,7 +211,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
options={options}
|
options={poll.options}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShouldShowNewParticipantForm(false);
|
setShouldShowNewParticipantForm(false);
|
||||||
}}
|
}}
|
||||||
|
@ -309,7 +225,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({
|
||||||
urlId={pollId}
|
urlId={pollId}
|
||||||
key={i}
|
key={i}
|
||||||
participant={participant}
|
participant={participant}
|
||||||
options={options}
|
options={poll.options}
|
||||||
canDelete={role === "admin"}
|
canDelete={role === "admin"}
|
||||||
editMode={editingParticipantId === participant.id}
|
editMode={editingParticipantId === participant.id}
|
||||||
onChangeEditMode={(isEditing) => {
|
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 * as React from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { requiredString } from "../../utils/form-validation";
|
import CheckCircle from "@/components/icons/check-circle.svg";
|
||||||
import Button from "../button";
|
|
||||||
import CheckCircle from "../icons/check-circle.svg";
|
import { requiredString } from "../../../utils/form-validation";
|
||||||
import NameInput from "../name-input";
|
import Button from "../../button";
|
||||||
import Tooltip from "../tooltip";
|
import NameInput from "../../name-input";
|
||||||
import { ControlledScrollDiv } from "./poll";
|
import Tooltip from "../../tooltip";
|
||||||
|
import { ParticipantForm } from "../types";
|
||||||
|
import ControlledScrollArea from "./controlled-scroll-area";
|
||||||
import { usePollContext } from "./poll-context";
|
import { usePollContext } from "./poll-context";
|
||||||
import { ParticipantForm } from "./types";
|
|
||||||
|
|
||||||
export interface ParticipantRowFormProps {
|
export interface ParticipantRowFormProps {
|
||||||
defaultValues?: Partial<ParticipantForm>;
|
defaultValues?: Partial<ParticipantForm>;
|
||||||
|
@ -99,7 +100,7 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
||||||
setScrollPosition(0);
|
setScrollPosition(0);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
checkboxRefs.current[0].focus();
|
checkboxRefs.current[0].focus();
|
||||||
}, 800);
|
}, 100);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -108,7 +109,7 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
||||||
control={control}
|
control={control}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ControlledScrollDiv>
|
<ControlledScrollArea>
|
||||||
{options.map((option, index) => {
|
{options.map((option, index) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -142,7 +143,7 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
checkboxRefs.current[index + 1].focus();
|
checkboxRefs.current[index + 1].focus();
|
||||||
isAnimatingRef.current = false;
|
isAnimatingRef.current = false;
|
||||||
}, 500);
|
}, 100);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
{...checkboxProps}
|
{...checkboxProps}
|
||||||
|
@ -159,7 +160,7 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ControlledScrollDiv>
|
</ControlledScrollArea>
|
||||||
<div className="flex items-center space-x-2 px-2 transition-all">
|
<div className="flex items-center space-x-2 px-2 transition-all">
|
||||||
<Tooltip content="Save" placement="top">
|
<Tooltip content="Save" placement="top">
|
||||||
<Button
|
<Button
|
|
@ -2,17 +2,18 @@ import { Option, Participant, Vote } from "@prisma/client";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import Button from "../button";
|
import Button from "@/components/button";
|
||||||
import Pencil from "../icons/pencil.svg";
|
import Pencil from "@/components/icons/pencil.svg";
|
||||||
import Trash from "../icons/trash.svg";
|
import Trash from "@/components/icons/trash.svg";
|
||||||
import { usePoll } from "../poll-context";
|
import { usePoll } from "@/components/poll-context";
|
||||||
import { useUpdateParticipantMutation } from "./mutations";
|
|
||||||
|
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 ParticipantRowForm from "./participant-row-form";
|
||||||
import { ControlledScrollDiv } from "./poll";
|
|
||||||
import { usePollContext } from "./poll-context";
|
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 {
|
export interface ParticipantRowProps {
|
||||||
urlId: string;
|
urlId: string;
|
||||||
|
@ -91,7 +92,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
||||||
{participant.name}
|
{participant.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ControlledScrollDiv>
|
<ControlledScrollArea>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -116,7 +117,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ControlledScrollDiv>
|
</ControlledScrollArea>
|
||||||
{!poll.closed ? (
|
{!poll.closed ? (
|
||||||
<div
|
<div
|
||||||
style={{ width: actionColumnWidth }}
|
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