diff --git a/components/icons/user.svg b/components/icons/user.svg index af56aa4c0..db9c8bc84 100644 --- a/components/icons/user.svg +++ b/components/icons/user.svg @@ -1,3 +1,3 @@ - - + + \ No newline at end of file diff --git a/components/poll-context.tsx b/components/poll-context.tsx new file mode 100644 index 000000000..261a0f9af --- /dev/null +++ b/components/poll-context.tsx @@ -0,0 +1,104 @@ +import { Participant, Vote } from "@prisma/client"; +import { GetPollResponse } from "api-client/get-poll"; +import { keyBy } from "lodash"; +import React from "react"; +import { + decodeOptions, + getBrowserTimeZone, + ParsedDateOption, + ParsedTimeSlotOption, +} from "utils/date-time-utils"; + +import { useRequiredContext } from "./use-required-context"; + +type VoteType = "yes" | "no"; + +type PollContextValue = { + poll: GetPollResponse; + targetTimeZone: string; + setTargetTimeZone: (timeZone: string) => void; + pollType: "date" | "timeSlot"; + getParticipantsWhoVotedForOption: (optionId: string) => Participant[]; // maybe just attach votes to parsed options + getParticipantById: ( + participantId: string, + ) => (Participant & { votes: Vote[] }) | undefined; + getVote: (participantId: string, optionId: string) => VoteType; +} & ( + | { pollType: "date"; options: ParsedDateOption[] } + | { pollType: "timeSlot"; options: ParsedTimeSlotOption[] } +); + +export const PollContext = React.createContext(null); + +PollContext.displayName = "PollContext.Provider"; + +export const usePoll = () => { + const context = useRequiredContext(PollContext); + return context; +}; + +export const PollContextProvider: React.VoidFunctionComponent<{ + value: GetPollResponse; + children?: React.ReactNode; +}> = ({ value: poll, children }) => { + const [targetTimeZone, setTargetTimeZone] = + React.useState(getBrowserTimeZone); + + const participantById = React.useMemo( + () => keyBy(poll.participants, (participant) => participant.id), + [poll.participants], + ); + + const participantsByOptionId = React.useMemo(() => { + const res: Record = {}; + poll.options.forEach((option) => { + res[option.id] = option.votes.map( + ({ participantId }) => participantById[participantId], + ); + }); + return res; + }, [participantById, poll.options]); + + const contextValue = React.useMemo(() => { + const parsedOptions = decodeOptions( + poll.options, + poll.timeZone, + targetTimeZone, + ); + const getParticipantById = (participantId: string) => { + // TODO (Luke Vella) [2022-04-16]: Build an index instead + const participant = poll.participants.find( + ({ id }) => id === participantId, + ); + + return participant; + }; + + return { + poll, + getVotesForOption: (optionId: string) => { + // TODO (Luke Vella) [2022-04-16]: Build an index instead + const option = poll.options.find(({ id }) => id === optionId); + return option?.votes ?? []; + }, + getParticipantById: (participantId) => { + return participantById[participantId]; + }, + getParticipantsWhoVotedForOption: (optionId: string) => + participantsByOptionId[optionId], + getVote: (participantId, optionId) => { + return getParticipantById(participantId)?.votes.some( + (vote) => vote.optionId === optionId, + ) + ? "yes" + : "no"; + }, + ...parsedOptions, + targetTimeZone, + setTargetTimeZone, + }; + }, [participantById, participantsByOptionId, poll, targetTimeZone]); + return ( + {children} + ); +}; diff --git a/components/poll/manage-poll.tsx b/components/poll/manage-poll.tsx index 583bd815c..f9bf4d443 100644 --- a/components/poll/manage-poll.tsx +++ b/components/poll/manage-poll.tsx @@ -16,17 +16,16 @@ import Dropdown, { DropdownItem } from "../dropdown"; import { PollDetailsForm } from "../forms"; import { useModal } from "../modal"; import { useModalContext } from "../modal/modal-provider"; -import { usePoll } from "../use-poll"; +import { usePoll } from "../poll-context"; import { useUpdatePollMutation } from "./mutations"; const PollOptionsForm = React.lazy(() => import("../forms/poll-options-form")); const ManagePoll: React.VoidFunctionComponent<{ - targetTimeZone: string; placement?: Placement; -}> = ({ targetTimeZone, placement }) => { +}> = ({ placement }) => { const { t } = useTranslation("app"); - const poll = usePoll(); + const { poll, targetTimeZone } = usePoll(); const modalContext = useModalContext(); @@ -184,7 +183,7 @@ const ManagePoll: React.VoidFunctionComponent<{ }), ...poll.options.map((option) => { const decodedOption = decodeDateOption( - option.value, + option, poll.timeZone, targetTimeZone, ); diff --git a/components/poll/mobile-poll.tsx b/components/poll/mobile-poll.tsx index 079a407b4..5e6be76ca 100644 --- a/components/poll/mobile-poll.tsx +++ b/components/poll/mobile-poll.tsx @@ -1,45 +1,45 @@ import { Listbox } from "@headlessui/react"; import { Participant, Vote } from "@prisma/client"; import clsx from "clsx"; -import { motion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import { useTranslation } from "next-i18next"; import * as React from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import smoothscroll from "smoothscroll-polyfill"; import ChevronDown from "@/components/icons/chevron-down.svg"; import Pencil from "@/components/icons/pencil.svg"; import PlusCircle from "@/components/icons/plus-circle.svg"; +import Save from "@/components/icons/save.svg"; import Trash from "@/components/icons/trash.svg"; -import { usePoll } from "@/components/use-poll"; +import { usePoll } from "@/components/poll-context"; -import { decodeDateOption } from "../../utils/date-time-utils"; import { requiredString } from "../../utils/form-validation"; import Button from "../button"; -import DateCard from "../date-card"; -import CheckCircle from "../icons/check-circle.svg"; import { styleMenuItem } from "../menu-styles"; import NameInput from "../name-input"; import TimeZonePicker from "../time-zone-picker"; import { useUserName } from "../user-name-context"; +import PollOptions from "./mobile-poll/poll-options"; +import TimeSlotOptions from "./mobile-poll/time-slot-options"; import { useAddParticipantMutation, useUpdateParticipantMutation, } from "./mutations"; -import TimeRange from "./time-range"; import { ParticipantForm, PollProps } from "./types"; import { useDeleteParticipantModal } from "./use-delete-participant-modal"; import UserAvater from "./user-avatar"; -import VoteIcon from "./vote-icon"; -const MobilePoll: React.VoidFunctionComponent = ({ - pollId, - highScore, - targetTimeZone, - onChangeTargetTimeZone, -}) => { - const poll = usePoll(); +if (typeof window !== "undefined") { + smoothscroll.polyfill(); +} - const { timeZone, options, participants, role } = poll; +const MobilePoll: React.VoidFunctionComponent = ({ pollId }) => { + const pollContext = usePoll(); + + const { poll, targetTimeZone, setTargetTimeZone } = pollContext; + + const { timeZone, participants, role } = poll; const [, setUserName] = useUserName(); @@ -50,13 +50,14 @@ const MobilePoll: React.VoidFunctionComponent = ({ return acc; }, {}); - const { register, setValue, reset, handleSubmit, control, formState } = - useForm({ - defaultValues: { - name: "", - votes: [], - }, - }); + const form = useForm({ + defaultValues: { + name: "", + votes: [], + }, + }); + + const { reset, handleSubmit, control, formState } = form; const [selectedParticipantId, setSelectedParticipantId] = React.useState(); @@ -64,73 +65,102 @@ const MobilePoll: React.VoidFunctionComponent = ({ ? participantById[selectedParticipantId] : undefined; - const selectedParticipantVotedOption = selectedParticipant - ? selectedParticipant.votes.map((vote) => vote.optionId) - : undefined; - - const [mode, setMode] = React.useState<"edit" | "default">(() => - participants.length > 0 ? "default" : "edit", + const [editable, setEditable] = React.useState(() => + participants.length > 0 ? false : true, ); + const [shouldShowSaveButton, setShouldShowSaveButton] = React.useState(false); + const formRef = React.useRef(null); + + React.useEffect(() => { + const setState = () => { + if (formRef.current) { + const rect = formRef.current.getBoundingClientRect(); + const saveButtonIsVisible = rect.bottom <= window.innerHeight; + + setShouldShowSaveButton( + !saveButtonIsVisible && + formRef.current.getBoundingClientRect().top < + window.innerHeight / 2, + ); + } + }; + setState(); + window.addEventListener("scroll", setState, true); + return () => { + window.removeEventListener("scroll", setState, true); + }; + }, []); + const { t } = useTranslation("app"); const { mutate: updateParticipantMutation } = useUpdateParticipantMutation(pollId); const { mutate: addParticipantMutation } = useAddParticipantMutation(pollId); - const [deleteParticipantModal, confirmDeleteParticipant] = - useDeleteParticipantModal(pollId, selectedParticipantId ?? ""); // TODO (Luke Vella) [2022-03-14]: Figure out a better way to deal with these modals - - // 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 checkboxGroupHack = ( - - ); + const confirmDeleteParticipant = useDeleteParticipantModal(); + const submitContainerRef = React.useRef(null); + const scrollToSave = () => { + if (submitContainerRef.current) { + window.scrollTo({ + top: + document.documentElement.scrollTop + + submitContainerRef.current.getBoundingClientRect().bottom - + window.innerHeight + + 100, + behavior: "smooth", + }); + } + }; return ( -
{ - return new Promise((resolve, reject) => { - if (selectedParticipant) { - updateParticipantMutation( - { - participantId: selectedParticipant.id, - pollId, - ...data, - }, - { - onSuccess: () => { - setMode("default"); + +
{ + return new Promise((resolve, reject) => { + if (selectedParticipant) { + updateParticipantMutation( + { + participantId: selectedParticipant.id, + pollId, + ...data, + }, + { + onSuccess: () => { + resolve(data); + setEditable(false); + }, + onError: reject, + }, + ); + } else { + addParticipantMutation(data, { + onSuccess: (newParticipant) => { + setSelectedParticipantId(newParticipant.id); resolve(data); + setEditable(false); }, onError: reject, - }, - ); - } else { - addParticipantMutation(data, { - onSuccess: (newParticipant) => { - setMode("default"); - setSelectedParticipantId(newParticipant.id); - resolve(data); - }, - onError: reject, - }); - } - }); - })} - > - {checkboxGroupHack} -
- {mode === "default" ? ( + }); + } + }); + })} + > +
- +
{selectedParticipant ? (
@@ -153,7 +183,7 @@ const MobilePoll: React.VoidFunctionComponent = ({ className="menu-items max-h-72 w-full overflow-auto" > - Show all + {t("participantCount", { count: participants.length })} {participants.map((participant) => ( = ({
- {!poll.closed ? ( + {!poll.closed && !editable ? ( selectedParticipant ? (
) : ( ) ) : null} + {editable ? ( + + ) : null}
- ) : null} - {mode === "edit" ? ( - ( - - )} - /> - ) : null} - {timeZone ? ( - - ) : null} -
-
- {options.map((option) => { - const parsedOption = decodeDateOption( - option.value, - timeZone, - targetTimeZone, - ); - const numVotes = option.votes.length; - return ( -
-
- -
- {parsedOption.type === "timeSlot" ? ( - - ) : null} - -
-
- - {t("voteCount", { count: numVotes })} - -
- {option.votes.length ? ( -
- {option.votes - .slice(0, option.votes.length <= 6 ? 6 : 5) - .map((vote) => { - const participant = participantById[vote.participantId]; - return ( - - ); - })} - {option.votes.length > 6 ? ( - - +{option.votes.length - 5} - - ) : null} -
- ) : null} -
-
- {mode === "edit" ? ( - - ) : selectedParticipantVotedOption ? ( - selectedParticipantVotedOption.includes(option.id) ? ( - - ) : ( - - ) - ) : null} -
-
- ); - })} -
- {mode === "edit" ? ( -
- - + {timeZone ? ( + + ) : null}
- ) : null} - + {(() => { + switch (pollContext.pollType) { + case "date": + return ( + + ); + case "timeSlot": + return ( + + ); + } + })()} + + {shouldShowSaveButton && editable ? ( + + + + ) : null} + + + {editable ? ( + +
+
+ ( + + )} + /> + +
+
+
+ ) : null} +
+ + ); }; diff --git a/components/poll/mobile-poll/date-option.tsx b/components/poll/mobile-poll/date-option.tsx new file mode 100644 index 000000000..0e080f257 --- /dev/null +++ b/components/poll/mobile-poll/date-option.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +import DateCard from "@/components/date-card"; + +import PollOption, { PollOptionProps } from "./poll-option"; + +export interface DateOptionProps extends PollOptionProps { + dow: string; + day: string; + month: string; +} + +const DateOption: React.VoidFunctionComponent = ({ + dow, + day, + month, + ...rest +}) => { + return ( + + + + ); +}; + +export default DateOption; diff --git a/components/poll/mobile-poll/poll-option.tsx b/components/poll/mobile-poll/poll-option.tsx new file mode 100644 index 000000000..992d68b0d --- /dev/null +++ b/components/poll/mobile-poll/poll-option.tsx @@ -0,0 +1,156 @@ +import { Participant } from "@prisma/client"; +import clsx from "clsx"; +import { AnimatePresence, motion } from "framer-motion"; +import * as React from "react"; + +import UserAvater from "../user-avatar"; +import VoteIcon from "../vote-icon"; +import PopularityScore from "./popularity-score"; + +export interface PollOptionProps { + children?: React.ReactNode; + numberOfVotes: number; + editable?: boolean; + vote?: "yes" | "no"; + onChange: (vote: "yes" | "no") => void; + participants: Participant[]; + selectedParticipantId?: string; +} + +const CollapsibleContainer: React.VoidFunctionComponent<{ + expanded?: boolean; + children?: React.ReactNode; + className?: string; +}> = ({ className, children, expanded }) => { + return ( + + {children} + + ); +}; + +const PopInOut: React.VoidFunctionComponent<{ + children?: React.ReactNode; + className?: string; +}> = ({ children, className }) => { + return ( + + {children} + + ); +}; + +const PollOption: React.VoidFunctionComponent = ({ + children, + selectedParticipantId, + vote, + onChange, + participants, + editable, + numberOfVotes, +}) => { + const difference = selectedParticipantId + ? participants.some(({ id }) => id === selectedParticipantId) + ? vote === "yes" + ? 0 + : -1 + : vote === "yes" + ? 1 + : 0 + : vote === "yes" + ? 1 + : 0; + + const showVotes = !!(selectedParticipantId || editable); + return ( +
{ + onChange(vote === "yes" ? "no" : "yes"); + }} + className={clsx( + "flex items-center space-x-3 px-4 py-3 transition duration-75", + { + "active:bg-indigo-50": editable, + "bg-indigo-50/50": editable && vote === "yes", + }, + )} + > +
+
{children}
+
+ + {participants.length > 0 ? ( +
+
+ {participants + .slice(0, participants.length <= 6 ? 6 : 5) + .map((participant, i) => { + return ( + + ); + })} + {participants.length > 6 ? ( + + +{participants.length - 5} + + ) : null} +
+
+ ) : null} +
+
+ + + {editable ? ( + +
+ +
+
+ ) : vote ? ( + + + + ) : null} +
+
+
+ ); +}; + +export default PollOption; diff --git a/components/poll/mobile-poll/poll-options.tsx b/components/poll/mobile-poll/poll-options.tsx new file mode 100644 index 000000000..940b334c3 --- /dev/null +++ b/components/poll/mobile-poll/poll-options.tsx @@ -0,0 +1,100 @@ +import * as React from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { ParsedDateTimeOpton } from "utils/date-time-utils"; + +import { usePoll } from "@/components/poll-context"; + +import { ParticipantForm } from "../types"; +import DateOption from "./date-option"; +import TimeSlotOption from "./time-slot-option"; + +export interface PollOptions { + options: ParsedDateTimeOpton[]; + editable?: boolean; + selectedParticipantId?: string; +} + +const PollOptions: React.VoidFunctionComponent = ({ + options, + editable, + selectedParticipantId, +}) => { + const { control } = useFormContext(); + const { getParticipantsWhoVotedForOption, getVote, getParticipantById } = + usePoll(); + const selectedParticipant = selectedParticipantId + ? getParticipantById(selectedParticipantId) + : undefined; + + return ( +
+ {options.map((option) => { + const participants = getParticipantsWhoVotedForOption(option.optionId); + return ( + { + const vote = editable + ? field.value.includes(option.optionId) + ? "yes" + : "no" + : selectedParticipant + ? getVote(selectedParticipant.id, option.optionId) + : undefined; + + const handleChange = (newVote: "yes" | "no") => { + if (!editable) { + return; + } + if (newVote === "no") { + field.onChange( + field.value.filter( + (optionId) => optionId !== option.optionId, + ), + ); + } else { + field.onChange([...field.value, option.optionId]); + } + }; + + switch (option.type) { + case "timeSlot": + return ( + + ); + case "date": + return ( + + ); + } + }} + /> + ); + })} +
+ ); +}; + +export default PollOptions; diff --git a/components/poll/mobile-poll/popularity-score.tsx b/components/poll/mobile-poll/popularity-score.tsx new file mode 100644 index 000000000..40bd443a5 --- /dev/null +++ b/components/poll/mobile-poll/popularity-score.tsx @@ -0,0 +1,52 @@ +import { AnimatePresence, motion } from "framer-motion"; +import * as React from "react"; +import { usePrevious } from "react-use"; + +import Check from "@/components/icons/check.svg"; + +export interface PopularityScoreProps { + score: number; +} + +const PopularityScore: React.VoidFunctionComponent = ({ + score, +}) => { + const prevScore = usePrevious(score); + + const multiplier = prevScore !== undefined ? score - prevScore : 0; + + return ( +
+ + + + + {score} + + + {/* Invisible text just to give us the right width */} + {score} + +
+ ); +}; + +export default React.memo(PopularityScore); diff --git a/components/poll/mobile-poll/time-slot-option.tsx b/components/poll/mobile-poll/time-slot-option.tsx new file mode 100644 index 000000000..995bee7bc --- /dev/null +++ b/components/poll/mobile-poll/time-slot-option.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; + +import Clock from "@/components/icons/clock.svg"; + +import PollOption, { PollOptionProps } from "./poll-option"; + +export interface TimeSlotOptionProps extends PollOptionProps { + startTime: string; + endTime: string; + duration: string; +} + +const TimeSlotOption: React.VoidFunctionComponent = ({ + startTime, + endTime, + duration, + ...rest +}) => { + return ( + +
+
{`${startTime} - ${endTime}`}
+
+ + {duration} +
+
+
+ ); +}; + +export default TimeSlotOption; diff --git a/components/poll/mobile-poll/time-slot-options.tsx b/components/poll/mobile-poll/time-slot-options.tsx new file mode 100644 index 000000000..55b4d2215 --- /dev/null +++ b/components/poll/mobile-poll/time-slot-options.tsx @@ -0,0 +1,42 @@ +import { groupBy } from "lodash"; +import * as React from "react"; +import { ParsedTimeSlotOption } from "utils/date-time-utils"; + +import PollOptions from "./poll-options"; + +export interface TimeSlotOptionsProps { + options: ParsedTimeSlotOption[]; + editable?: boolean; + selectedParticipantId?: string; +} + +const TimeSlotOptions: React.VoidFunctionComponent = ({ + options, + editable, + selectedParticipantId, +}) => { + const grouped = groupBy(options, (option) => { + return `${option.dow} ${option.day} ${option.month}`; + }); + + return ( +
+ {Object.entries(grouped).map(([day, options]) => { + return ( +
+
+ {day} +
+ +
+ ); + })} +
+ ); +}; + +export default TimeSlotOptions; diff --git a/components/poll/mutations.ts b/components/poll/mutations.ts index 6702687d9..445a41b64 100644 --- a/components/poll/mutations.ts +++ b/components/poll/mutations.ts @@ -1,6 +1,6 @@ import { updatePoll, UpdatePollPayload } from "api-client/update-poll"; import { usePlausible } from "next-plausible"; -import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useMutation, useQueryClient } from "react-query"; import { addParticipant } from "../../api-client/add-participant"; import { @@ -12,7 +12,7 @@ import { updateParticipant, UpdateParticipantPayload, } from "../../api-client/update-participant"; -import { usePoll } from "../use-poll"; +import { usePoll } from "../poll-context"; import { useUserName } from "../user-name-context"; import { ParticipantForm } from "./types"; @@ -83,7 +83,7 @@ export const useUpdateParticipantMutation = (pollId: string) => { ); }; -export const useDeleteParticipantMutation = (pollId: string) => { +export const useDeleteParticipantMutation = () => { const queryClient = useQueryClient(); const plausible = usePlausible(); return useMutation( @@ -92,7 +92,7 @@ export const useDeleteParticipantMutation = (pollId: string) => { onSuccess: () => { plausible("Remove participant"); }, - onSettled: () => { + onSettled: (_data, _error, { pollId }) => { queryClient.invalidateQueries(["getPoll", pollId]); }, }, @@ -100,7 +100,7 @@ export const useDeleteParticipantMutation = (pollId: string) => { }; export const useUpdatePollMutation = () => { - const poll = usePoll(); + const { poll } = usePoll(); const plausible = usePlausible(); const queryClient = useQueryClient(); diff --git a/components/poll/notifications-toggle.tsx b/components/poll/notifications-toggle.tsx index 09d6b017d..8b0cb9375 100644 --- a/components/poll/notifications-toggle.tsx +++ b/components/poll/notifications-toggle.tsx @@ -6,15 +6,15 @@ import Button from "@/components/button"; import Bell from "@/components/icons/bell.svg"; import BellCrossed from "@/components/icons/bell-crossed.svg"; +import { usePoll } from "../poll-context"; import Tooltip from "../tooltip"; -import { usePoll } from "../use-poll"; import { useUpdatePollMutation } from "./mutations"; export interface NotificationsToggleProps {} const NotificationsToggle: React.VoidFunctionComponent = () => { - const poll = usePoll(); + const { poll } = usePoll(); const { t } = useTranslation("app"); const [isUpdatingNotifications, setIsUpdatingNotifications] = React.useState(false); diff --git a/components/poll/participant-row.tsx b/components/poll/participant-row.tsx index 26d03f19c..0d2ab355c 100644 --- a/components/poll/participant-row.tsx +++ b/components/poll/participant-row.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import Button from "../button"; import Pencil from "../icons/pencil.svg"; import Trash from "../icons/trash.svg"; -import { usePoll } from "../use-poll"; +import { usePoll } from "../poll-context"; import { useUpdateParticipantMutation } from "./mutations"; import ParticipantRowForm from "./participant-row-form"; import { ControlledScrollDiv } from "./poll"; @@ -42,10 +42,9 @@ const ParticipantRow: React.VoidFunctionComponent = ({ const { mutate: updateParticipantMutation } = useUpdateParticipantMutation(urlId); - const [deleteParticipantConfirModal, confirmDeleteParticipant] = - useDeleteParticipantModal(urlId, participant.id); + const confirmDeleteParticipant = useDeleteParticipantModal(); - const poll = usePoll(); + const { poll } = usePoll(); if (editMode) { return ( = ({ key={participant.id} className="group flex h-14 transition-colors hover:bg-slate-50" > - {deleteParticipantConfirModal}
= ({
diff --git a/components/poll/poll-subheader.tsx b/components/poll/poll-subheader.tsx index 2f60238c0..04fb76d7a 100644 --- a/components/poll/poll-subheader.tsx +++ b/components/poll/poll-subheader.tsx @@ -5,13 +5,13 @@ import * as React from "react"; import { useMutation } from "react-query"; import Button from "../button"; +import { usePoll } from "../poll-context"; import Popover from "../popover"; -import { usePoll } from "../use-poll"; export interface PollSubheaderProps {} const PollSubheader: React.VoidFunctionComponent = () => { - const poll = usePoll(); + const { poll } = usePoll(); const { t } = useTranslation("app"); const { diff --git a/components/poll/poll.tsx b/components/poll/poll.tsx index d3e531947..a68b855bd 100644 --- a/components/poll/poll.tsx +++ b/components/poll/poll.tsx @@ -11,8 +11,8 @@ 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 { usePoll } from "../use-poll"; import { useAddParticipantMutation } from "./mutations"; import ParticipantRow from "./participant-row"; import ParticipantRowForm from "./participant-row-form"; @@ -63,12 +63,10 @@ const minSidebarWidth = 180; const Poll: React.VoidFunctionComponent = ({ pollId, highScore, - targetTimeZone, - onChangeTargetTimeZone, }) => { const { t } = useTranslation("app"); - const poll = usePoll(); + const { poll, targetTimeZone, setTargetTimeZone } = usePoll(); const { timeZone, options, participants, role } = poll; @@ -164,7 +162,7 @@ const Poll: React.VoidFunctionComponent = ({
@@ -209,7 +207,7 @@ const Poll: React.VoidFunctionComponent = ({ {options.map((option) => { const parsedOption = decodeDateOption( - option.value, + option, timeZone, targetTimeZone, ); diff --git a/components/poll/types.ts b/components/poll/types.ts index dac4b6f35..f469a0b5b 100644 --- a/components/poll/types.ts +++ b/components/poll/types.ts @@ -5,6 +5,4 @@ export interface ParticipantForm { export interface PollProps { pollId: string; highScore: number; - onChangeTargetTimeZone: (timeZone: string) => void; - targetTimeZone: string; } diff --git a/components/poll/use-delete-participant-modal.ts b/components/poll/use-delete-participant-modal.ts index 9f92c4817..719272be3 100644 --- a/components/poll/use-delete-participant-modal.ts +++ b/components/poll/use-delete-participant-modal.ts @@ -1,25 +1,31 @@ -import { useModal } from "../modal"; +import { useModalContext } from "../modal/modal-provider"; +import { usePoll } from "../poll-context"; import { useDeleteParticipantMutation } from "./mutations"; -export const useDeleteParticipantModal = ( - pollId: string, - participantId: string, -) => { - const { mutate: deleteParticipant } = useDeleteParticipantMutation(pollId); - return useModal({ - title: "Delete participant?", - description: - "Are you sure you want to remove this participant from the poll?", - okButtonProps: { - type: "danger", - }, - okText: "Remove", - onOk: () => { - deleteParticipant({ - pollId: pollId, - participantId, - }); - }, - cancelText: "Cancel", - }); +export const useDeleteParticipantModal = () => { + const { render } = useModalContext(); + + const { mutate: deleteParticipant } = useDeleteParticipantMutation(); + const { + poll: { urlId }, + } = usePoll(); + + return (participantId: string) => { + return render({ + title: "Delete participant?", + description: + "Are you sure you want to remove this participant from the poll?", + okButtonProps: { + type: "danger", + }, + okText: "Delete", + onOk: () => { + deleteParticipant({ + pollId: urlId, + participantId, + }); + }, + cancelText: "Cancel", + }); + }; }; diff --git a/components/use-poll.ts b/components/use-poll.ts deleted file mode 100644 index 0e0ed03a1..000000000 --- a/components/use-poll.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { GetPollResponse } from "api-client/get-poll"; -import React from "react"; - -export const PollContext = React.createContext(null); - -export const usePoll = () => { - const context = React.useContext(PollContext); - if (!context) { - throw new Error("Tried to get poll from context but got undefined"); - } - return context; -}; diff --git a/pages/api/legacy/[urlId].ts b/pages/api/legacy/[urlId].ts index 5399a47f6..77b6e2f59 100644 --- a/pages/api/legacy/[urlId].ts +++ b/pages/api/legacy/[urlId].ts @@ -136,6 +136,9 @@ export default async function handler( include: { votes: true, }, + orderBy: { + value: "asc", + }, }, participants: { include: { diff --git a/pages/api/poll/[urlId]/index.ts b/pages/api/poll/[urlId]/index.ts index a72a28d84..174049660 100644 --- a/pages/api/poll/[urlId]/index.ts +++ b/pages/api/poll/[urlId]/index.ts @@ -27,6 +27,9 @@ export default withLink( include: { votes: true, }, + orderBy: { + value: "asc", + }, }, participants: { include: { @@ -119,6 +122,9 @@ export default withLink( include: { votes: true, }, + orderBy: { + value: "asc", + }, }, participants: { include: { diff --git a/pages/api/poll/[urlId]/participant/[participantId].ts b/pages/api/poll/[urlId]/participant/[participantId].ts index eed0e7b89..e25fc0dde 100644 --- a/pages/api/poll/[urlId]/participant/[participantId].ts +++ b/pages/api/poll/[urlId]/participant/[participantId].ts @@ -1,5 +1,5 @@ import { prisma } from "../../../../../db"; -import { getQueryParam,withLink } from "../../../../../utils/api-utils"; +import { getQueryParam, withLink } from "../../../../../utils/api-utils"; export default withLink(async (req, res, link) => { const participantId = getQueryParam(req, "participantId"); @@ -44,5 +44,6 @@ export default withLink(async (req, res, link) => { return res.end(); default: + return res.status(405); } }); diff --git a/pages/poll.tsx b/pages/poll.tsx index 3b3826879..bd2ad7e7c 100644 --- a/pages/poll.tsx +++ b/pages/poll.tsx @@ -24,14 +24,13 @@ import NotificationsToggle from "@/components/poll/notifications-toggle"; import PollSubheader from "@/components/poll/poll-subheader"; import TruncatedLinkify from "@/components/poll/truncated-linkify"; import { UserAvatarProvider } from "@/components/poll/user-avatar"; +import { PollContextProvider, usePoll } from "@/components/poll-context"; import Popover from "@/components/popover"; import Sharing from "@/components/sharing"; import StandardLayout from "@/components/standard-layout"; -import { PollContext, usePoll } from "@/components/use-poll"; import { useUserName } from "@/components/user-name-context"; import { GetPollResponse } from "../api-client/get-poll"; -import { getBrowserTimeZone } from "../utils/date-time-utils"; import Custom404 from "./404"; const Discussion = React.lazy(() => import("@/components/discussion")); @@ -100,14 +99,14 @@ const PollPageLoader: NextPage = () => { return !poll ? ( {t("loading")} ) : ( - + - + ); }; const PollPage: NextPage = () => { - const poll = usePoll(); + const { poll } = usePoll(); const router = useRouter(); @@ -174,17 +173,6 @@ const PollPage: NextPage = () => { } }, [plausible, router, updatePollMutation]); - const [targetTimeZone, setTargetTimeZone] = - React.useState(getBrowserTimeZone); - - const sortedOptions = React.useMemo( - () => - poll.options.sort((a, b) => - a.value < b.value ? -1 : a.value > b.value ? 1 : 0, - ), - [poll.options], - ); - const checkIfWideScreen = () => window.innerWidth > 640; const [isWideScreen, setIsWideScreen] = React.useState(checkIfWideScreen); @@ -202,7 +190,7 @@ const PollPage: NextPage = () => { const PollComponent = isWideScreen ? Poll : MobilePoll; let highScore = 1; // set to one because we don't want to highlight - sortedOptions.forEach((option) => { + poll.options.forEach((option) => { if (option.votes.length > highScore) { highScore = option.votes.length; } @@ -241,7 +229,6 @@ const PollPage: NextPage = () => { placement={ isWideScreen ? "bottom-end" : "bottom-start" } - targetTimeZone={targetTimeZone} />
{ ) : null} Loading…
}>
- +
{ + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto("/demo"); + + await expect(page.locator('text="Lunch Meeting Demo"')).toBeVisible(); + + await page.click("text='New'"); + await page.click("data-testid=poll-option >> nth=0"); + await page.click("data-testid=poll-option >> nth=1"); + await page.click("data-testid=poll-option >> nth=3"); + await page.type('[placeholder="Your name…"]', "Test user"); + + await page.click("text=Save"); + await expect(page.locator("text='Test user'")).toBeVisible(); + + await page.click("text=Edit"); + await page.click("data-testid=poll-option >> nth=1"); + await page.click("text=Save"); + await expect(page.locator("data-testid=poll-option >> nth=1 ")).toContainText( + "2", + ); + + await page.click("data-testid=delete-participant-button"); + await page.locator("button", { hasText: "Delete" }).click(); + await expect(page.locator("text='Test user'")).not.toBeVisible(); +}); diff --git a/utils/api-utils.ts b/utils/api-utils.ts index 54844a461..e79744274 100644 --- a/utils/api-utils.ts +++ b/utils/api-utils.ts @@ -3,6 +3,7 @@ import * as Eta from "eta"; import { readFileSync } from "fs"; import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; import path from "path"; + import { prisma } from "../db"; import { sendEmail } from "./send-email"; diff --git a/utils/date-time-utils.ts b/utils/date-time-utils.ts index 9206ae45e..e3774e9bb 100644 --- a/utils/date-time-utils.ts +++ b/utils/date-time-utils.ts @@ -1,4 +1,11 @@ -import { format, isSameDay } from "date-fns"; +import { Option } from "@prisma/client"; +import { + differenceInHours, + differenceInMinutes, + format, + formatDuration, + isSameDay, +} from "date-fns"; import { formatInTimeZone } from "date-fns-tz"; import spacetime from "spacetime"; @@ -16,57 +23,161 @@ export const encodeDateOption = (option: DateTimeOption) => { : option.date; }; -type ParsedDateTimeOpton = { day: string; dow: string; month: string } & ( - | { - type: "timeSlot"; - startTime: string; - endTime: string; - } - | { - type: "date"; - } -); +export interface ParsedDateOption { + type: "date"; + optionId: string; + day: string; + dow: string; + month: string; +} + +export interface ParsedTimeSlotOption { + type: "timeSlot"; + optionId: string; + day: string; + dow: string; + month: string; + startTime: string; + endTime: string; + duration: string; +} + +export type ParsedDateTimeOpton = ParsedDateOption | ParsedTimeSlotOption; + +const isTimeSlot = (value: string) => value.indexOf("/") !== -1; + +const getDuration = (startTime: Date, endTime: Date) => { + const hours = Math.floor(differenceInHours(endTime, startTime)); + const minutes = Math.floor( + differenceInMinutes(endTime, startTime) - hours * 60, + ); + return formatDuration({ hours, minutes }); +}; + +export const decodeOptions = ( + options: Option[], + timeZone: string | null, + targetTimeZone: string, +): + | { pollType: "date"; options: ParsedDateOption[] } + | { pollType: "timeSlot"; options: ParsedTimeSlotOption[] } => { + const pollType = isTimeSlot(options[0].value) ? "timeSlot" : "date"; + + if (pollType === "timeSlot") { + return { + pollType, + options: options.map((option) => + parseTimeSlotOption(option, timeZone, targetTimeZone), + ), + }; + } else { + return { + pollType, + options: options.map((option) => parseDateOption(option)), + }; + } +}; + +const parseDateOption = (option: Option): ParsedDateOption => { + const dateString = + option.value.indexOf("T") === -1 + ? // we add the time because otherwise Date will assume UTC time which might change the day for some time zones + option.value + "T00:00:00" + : option.value; + const date = new Date(dateString); + return { + type: "date", + optionId: option.id, + day: format(date, "d"), + dow: format(date, "E"), + month: format(date, "MMM"), + }; +}; + +const parseTimeSlotOption = ( + option: Option, + timeZone: string | null, + targetTimeZone: string, +): ParsedTimeSlotOption => { + const [start, end] = option.value.split("/"); + if (timeZone && targetTimeZone) { + const startDate = spacetime(start, timeZone).toNativeDate(); + const endDate = spacetime(end, timeZone).toNativeDate(); + return { + type: "timeSlot", + optionId: option.id, + startTime: formatInTimeZone(startDate, targetTimeZone, "hh:mm a"), + endTime: formatInTimeZone(endDate, targetTimeZone, "hh:mm a"), + day: formatInTimeZone(startDate, targetTimeZone, "d"), + dow: formatInTimeZone(startDate, targetTimeZone, "E"), + month: formatInTimeZone(startDate, targetTimeZone, "MMM"), + duration: getDuration(startDate, endDate), + }; + } else { + const startDate = new Date(start); + const endDate = new Date(end); + return { + type: "timeSlot", + optionId: option.id, + startTime: format(startDate, "hh:mm a"), + endTime: format(endDate, "hh:mm a"), + day: format(startDate, "d"), + dow: format(startDate, "E"), + month: format(startDate, "MMM"), + duration: getDuration(startDate, endDate), + }; + } +}; export const decodeDateOption = ( - option: string, + option: Option, timeZone: string | null, targetTimeZone: string, ): ParsedDateTimeOpton => { - const isTimeRange = option.indexOf("/") !== -1; + const isTimeRange = option.value.indexOf("/") !== -1; // option can either be an ISO date (ex. 2000-01-01) // or a time range (ex. 2000-01-01T08:00:00/2000-01-01T09:00:00) if (isTimeRange) { - const [start, end] = option.split("/"); + const [start, end] = option.value.split("/"); if (timeZone && targetTimeZone) { const startDate = spacetime(start, timeZone).toNativeDate(); const endDate = spacetime(end, timeZone).toNativeDate(); return { type: "timeSlot", + optionId: option.id, startTime: formatInTimeZone(startDate, targetTimeZone, "hh:mm a"), endTime: formatInTimeZone(endDate, targetTimeZone, "hh:mm a"), day: formatInTimeZone(startDate, targetTimeZone, "d"), dow: formatInTimeZone(startDate, targetTimeZone, "E"), month: formatInTimeZone(startDate, targetTimeZone, "MMM"), + duration: getDuration(startDate, endDate), }; } else { - const date = new Date(start); + const startDate = new Date(start); + const endDate = new Date(end); return { type: "timeSlot", - startTime: format(date, "hh:mm a"), - endTime: format(new Date(end), "hh:mm a"), - day: format(date, "d"), - dow: format(date, "E"), - month: format(date, "MMM"), + optionId: option.id, + startTime: format(startDate, "hh:mm a"), + endTime: format(endDate, "hh:mm a"), + day: format(startDate, "d"), + dow: format(startDate, "E"), + month: format(startDate, "MMM"), + duration: getDuration(startDate, endDate), }; } } // we add the time because otherwise Date will assume UTC time which might change the day for some time zones - const dateString = option.indexOf("T") === -1 ? option + "T00:00:00" : option; + const dateString = + option.value.indexOf("T") === -1 + ? option.value + "T00:00:00" + : option.value; const date = new Date(dateString); return { type: "date", + optionId: option.id, day: format(date, "d"), dow: format(date, "E"), month: format(date, "MMM"),