Improvements to mobile UI (#119)

This commit is contained in:
Luke Vella 2022-04-20 16:09:38 +01:00 committed by GitHub
parent f206d31083
commit dde0fe8ea1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 971 additions and 317 deletions

View file

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
</svg>

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 362 B

104
components/poll-context.tsx Normal file
View file

@ -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<PollContextValue | null>(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<string, Participant[]> = {};
poll.options.forEach((option) => {
res[option.id] = option.votes.map(
({ participantId }) => participantById[participantId],
);
});
return res;
}, [participantById, poll.options]);
const contextValue = React.useMemo<PollContextValue>(() => {
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 (
<PollContext.Provider value={contextValue}>{children}</PollContext.Provider>
);
};

View file

@ -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,
);

View file

@ -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<PollProps> = ({
pollId,
highScore,
targetTimeZone,
onChangeTargetTimeZone,
}) => {
const poll = usePoll();
if (typeof window !== "undefined") {
smoothscroll.polyfill();
}
const { timeZone, options, participants, role } = poll;
const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ 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<PollProps> = ({
return acc;
}, {});
const { register, setValue, reset, handleSubmit, control, formState } =
useForm<ParticipantForm>({
defaultValues: {
name: "",
votes: [],
},
});
const form = useForm<ParticipantForm>({
defaultValues: {
name: "",
votes: [],
},
});
const { reset, handleSubmit, control, formState } = form;
const [selectedParticipantId, setSelectedParticipantId] =
React.useState<string>();
@ -64,73 +65,102 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({
? 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<HTMLFormElement>(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 = (
<input type="checkbox" className="hidden" {...register("votes")} />
);
const confirmDeleteParticipant = useDeleteParticipantModal();
const submitContainerRef = React.useRef<HTMLDivElement>(null);
const scrollToSave = () => {
if (submitContainerRef.current) {
window.scrollTo({
top:
document.documentElement.scrollTop +
submitContainerRef.current.getBoundingClientRect().bottom -
window.innerHeight +
100,
behavior: "smooth",
});
}
};
return (
<form
className="border-t border-b bg-white shadow-sm"
onSubmit={handleSubmit((data) => {
return new Promise<ParticipantForm>((resolve, reject) => {
if (selectedParticipant) {
updateParticipantMutation(
{
participantId: selectedParticipant.id,
pollId,
...data,
},
{
onSuccess: () => {
setMode("default");
<FormProvider {...form}>
<form
ref={formRef}
className="border-t border-b bg-white shadow-sm"
onSubmit={handleSubmit((data) => {
return new Promise<ParticipantForm>((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}
<div className="sticky top-0 z-30 flex flex-col space-y-2 border-b bg-gray-50 px-4 py-2">
{mode === "default" ? (
});
}
});
})}
>
<div className="sticky top-0 z-30 flex flex-col space-y-2 border-b bg-gray-50 p-3">
<div className="flex space-x-3">
<Listbox
value={selectedParticipantId}
onChange={setSelectedParticipantId}
disabled={editable}
>
<div className="menu grow">
<Listbox.Button className="btn-default w-full text-left">
<Listbox.Button
className={clsx("btn-default w-full px-2 text-left", {
"btn-disabled": editable,
})}
>
<div className="grow">
{selectedParticipant ? (
<div className="flex items-center space-x-2">
@ -153,7 +183,7 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({
className="menu-items max-h-72 w-full overflow-auto"
>
<Listbox.Option value={undefined} className={styleMenuItem}>
Show all
{t("participantCount", { count: participants.length })}
</Listbox.Option>
{participants.map((participant) => (
<Listbox.Option
@ -170,18 +200,19 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({
</Listbox.Options>
</div>
</Listbox>
{!poll.closed ? (
{!poll.closed && !editable ? (
selectedParticipant ? (
<div className="flex space-x-3">
<Button
icon={<Pencil />}
onClick={() => {
setMode("edit");
setValue("name", selectedParticipant.name);
setValue(
"votes",
selectedParticipant.votes.map((vote) => vote.optionId),
);
setEditable(true);
reset({
name: selectedParticipant.name,
votes: selectedParticipant.votes.map(
(vote) => vote.optionId,
),
});
}}
>
Edit
@ -189,151 +220,141 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({
{role === "admin" ? (
<Button
icon={<Trash />}
data-testid="delete-participant-button"
type="danger"
onClick={confirmDeleteParticipant}
onClick={() => {
if (selectedParticipant) {
confirmDeleteParticipant(selectedParticipant.id);
}
}}
/>
) : null}
{deleteParticipantModal}
</div>
) : (
<Button
type="primary"
icon={<PlusCircle />}
onClick={() => {
reset();
reset({ name: "", votes: [] });
setUserName("");
setMode("edit");
setEditable(true);
}}
>
New
</Button>
)
) : null}
{editable ? (
<Button
onClick={() => {
setEditable(false);
reset();
}}
>
Cancel
</Button>
) : null}
</div>
) : null}
{mode === "edit" ? (
<Controller
name="name"
control={control}
rules={{ validate: requiredString }}
render={({ field }) => (
<NameInput
disabled={formState.isSubmitting}
autoFocus={!selectedParticipant}
className="w-full"
{...field}
/>
)}
/>
) : null}
{timeZone ? (
<TimeZonePicker
value={targetTimeZone}
onChange={onChangeTargetTimeZone}
/>
) : null}
</div>
<div className="divide-y">
{options.map((option) => {
const parsedOption = decodeDateOption(
option.value,
timeZone,
targetTimeZone,
);
const numVotes = option.votes.length;
return (
<div
key={option.id}
className="flex items-center space-x-4 px-4 py-2"
>
<div>
<DateCard
day={parsedOption.day}
dow={parsedOption.dow}
month={parsedOption.month}
/>
</div>
{parsedOption.type === "timeSlot" ? (
<TimeRange
startTime={parsedOption.startTime}
endTime={parsedOption.endTime}
className="shrink-0"
/>
) : null}
<div className="grow items-center space-y-1">
<div>
<span
className={clsx(
"inline-block rounded-full border px-2 text-xs leading-relaxed",
{
"border-slate-200": numVotes !== highScore,
"border-rose-500 text-rose-500": numVotes === highScore,
},
)}
>
{t("voteCount", { count: numVotes })}
</span>
</div>
{option.votes.length ? (
<div className="-space-x-1">
{option.votes
.slice(0, option.votes.length <= 6 ? 6 : 5)
.map((vote) => {
const participant = participantById[vote.participantId];
return (
<UserAvater
key={vote.id}
className="ring-1 ring-white"
name={participant.name}
/>
);
})}
{option.votes.length > 6 ? (
<span className="inline-flex h-5 items-center justify-center rounded-full bg-slate-100 px-1 text-xs font-medium ring-1 ring-white">
+{option.votes.length - 5}
</span>
) : null}
</div>
) : null}
</div>
<div className="flex h-14 w-12 items-center justify-center">
{mode === "edit" ? (
<input
type="checkbox"
className="checkbox"
value={option.id}
{...register("votes")}
/>
) : selectedParticipantVotedOption ? (
selectedParticipantVotedOption.includes(option.id) ? (
<VoteIcon type="yes" />
) : (
<VoteIcon type="no" />
)
) : null}
</div>
</div>
);
})}
</div>
{mode === "edit" ? (
<div className="flex space-x-3 border-t p-2">
<Button className="grow" onClick={() => setMode("default")}>
Cancel
</Button>
<Button
icon={<CheckCircle />}
htmlType="submit"
className="grow"
type="primary"
loading={formState.isSubmitting}
>
Save
</Button>
{timeZone ? (
<TimeZonePicker
value={targetTimeZone}
onChange={setTargetTimeZone}
/>
) : null}
</div>
) : null}
</form>
{(() => {
switch (pollContext.pollType) {
case "date":
return (
<PollOptions
selectedParticipantId={selectedParticipantId}
options={pollContext.options}
editable={editable}
/>
);
case "timeSlot":
return (
<TimeSlotOptions
selectedParticipantId={selectedParticipantId}
options={pollContext.options}
editable={editable}
/>
);
}
})()}
<AnimatePresence>
{shouldShowSaveButton && editable ? (
<motion.button
type="button"
variants={{
exit: {
opacity: 0,
y: -50,
transition: { duration: 0.2 },
},
hidden: { opacity: 0, y: 50 },
visible: { opacity: 1, y: 0, transition: { delay: 0.2 } },
}}
initial="hidden"
animate="visible"
exit="exit"
className="fixed bottom-8 left-1/2 z-10 -ml-6 inline-flex h-12 w-12 appearance-none items-center justify-center rounded-full bg-white text-slate-700 shadow-lg active:bg-gray-100"
>
<Save className="w-5" onClick={scrollToSave} />
</motion.button>
) : null}
</AnimatePresence>
<AnimatePresence>
{editable ? (
<motion.div
variants={{
hidden: { opacity: 0, y: -100, height: 0 },
visible: { opacity: 1, y: 0, height: "auto" },
}}
initial="hidden"
animate="visible"
exit={{
opacity: 0,
y: -10,
height: 0,
transition: { duration: 0.2 },
}}
>
<div
ref={submitContainerRef}
className="space-y-3 border-t bg-gray-50 p-3"
>
<div className="flex space-x-3">
<Controller
name="name"
control={control}
rules={{ validate: requiredString }}
render={({ field }) => (
<NameInput
disabled={formState.isSubmitting}
className={clsx("input w-full", {
"input-error": formState.errors.name,
})}
{...field}
/>
)}
/>
<Button
className="grow"
icon={<Save />}
htmlType="submit"
type="primary"
loading={formState.isSubmitting}
>
Save
</Button>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
</form>
</FormProvider>
);
};

View file

@ -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<DateOptionProps> = ({
dow,
day,
month,
...rest
}) => {
return (
<PollOption {...rest}>
<DateCard dow={dow} day={day} month={month} />
</PollOption>
);
};
export default DateOption;

View file

@ -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 (
<motion.div
initial={{ width: 0 }}
variants={{
collapsed: {
width: 0,
opacity: 0,
},
expanded: {
opacity: 1,
width: 58,
},
}}
animate={expanded ? "expanded" : "collapsed"}
className={className}
>
{children}
</motion.div>
);
};
const PopInOut: React.VoidFunctionComponent<{
children?: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
return (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
className={clsx(className)}
>
{children}
</motion.div>
);
};
const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
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 (
<div
data-testid="poll-option"
onClick={() => {
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",
},
)}
>
<div className="pointer-events-none flex grow items-center">
<div className="grow">{children}</div>
<div className="flex flex-col items-end">
<PopularityScore score={numberOfVotes + difference} />
{participants.length > 0 ? (
<div className="mt-1 -mr-1">
<div className="-space-x-1">
{participants
.slice(0, participants.length <= 6 ? 6 : 5)
.map((participant, i) => {
return (
<UserAvater
key={i}
className="ring-1 ring-white"
name={participant.name}
/>
);
})}
{participants.length > 6 ? (
<span className="inline-flex h-5 items-center justify-center rounded-full bg-slate-100 px-1 text-xs font-medium ring-1 ring-white">
+{participants.length - 5}
</span>
) : null}
</div>
</div>
) : null}
</div>
</div>
<CollapsibleContainer
expanded={showVotes}
className="relative flex h-12 items-center justify-center rounded-lg"
>
<AnimatePresence>
{editable ? (
<PopInOut className="h-full">
<div className="flex h-full w-14 items-center justify-center">
<input
readOnly={true}
type="checkbox"
className="checkbox"
checked={vote === "yes"}
/>
</div>
</PopInOut>
) : vote ? (
<PopInOut
key={vote}
className="absolute inset-0 flex h-full w-full items-center justify-center"
>
<VoteIcon type={vote} />
</PopInOut>
) : null}
</AnimatePresence>
</CollapsibleContainer>
</div>
);
};
export default PollOption;

View file

@ -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<PollOptions> = ({
options,
editable,
selectedParticipantId,
}) => {
const { control } = useFormContext<ParticipantForm>();
const { getParticipantsWhoVotedForOption, getVote, getParticipantById } =
usePoll();
const selectedParticipant = selectedParticipantId
? getParticipantById(selectedParticipantId)
: undefined;
return (
<div className="divide-y">
{options.map((option) => {
const participants = getParticipantsWhoVotedForOption(option.optionId);
return (
<Controller
key={option.optionId}
control={control}
name="votes"
render={({ field }) => {
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 (
<TimeSlotOption
onChange={handleChange}
numberOfVotes={participants.length}
participants={participants}
vote={vote}
startTime={option.startTime}
endTime={option.endTime}
duration={option.duration}
editable={editable}
selectedParticipantId={selectedParticipant?.id}
/>
);
case "date":
return (
<DateOption
onChange={handleChange}
numberOfVotes={participants.length}
participants={participants}
vote={vote}
dow={option.dow}
day={option.day}
month={option.month}
editable={editable}
selectedParticipantId={selectedParticipant?.id}
/>
);
}
}}
/>
);
})}
</div>
);
};
export default PollOptions;

View file

@ -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<PopularityScoreProps> = ({
score,
}) => {
const prevScore = usePrevious(score);
const multiplier = prevScore !== undefined ? score - prevScore : 0;
return (
<div
data-testid="popularity-score"
className="inline-flex items-center font-mono text-sm font-semibold text-slate-500"
>
<Check className="mr-1 inline-block h-5 text-slate-400/80" />
<span className="relative inline-block">
<AnimatePresence initial={false}>
<motion.span
transition={{
duration: 0.2,
}}
initial={{
opacity: 0,
y: 10 * multiplier,
}}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: 10 * multiplier,
}}
key={score}
className="absolute inset-0"
>
{score}
</motion.span>
</AnimatePresence>
{/* Invisible text just to give us the right width */}
<span className="text-transparent">{score}</span>
</span>
</div>
);
};
export default React.memo(PopularityScore);

View file

@ -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<TimeSlotOptionProps> = ({
startTime,
endTime,
duration,
...rest
}) => {
return (
<PollOption {...rest}>
<div className="grow">
<div className="h-7">{`${startTime} - ${endTime}`}</div>
<div className="flex grow items-center text-sm text-slate-400">
<Clock className="leading- mr-1 inline w-4" />
{duration}
</div>
</div>
</PollOption>
);
};
export default TimeSlotOption;

View file

@ -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<TimeSlotOptionsProps> = ({
options,
editable,
selectedParticipantId,
}) => {
const grouped = groupBy(options, (option) => {
return `${option.dow} ${option.day} ${option.month}`;
});
return (
<div className="select-none divide-y">
{Object.entries(grouped).map(([day, options]) => {
return (
<div key={day}>
<div className="sticky top-[105px] z-10 flex border-b bg-gray-50/80 py-2 px-4 text-sm font-semibold shadow-sm backdrop-blur-md">
{day}
</div>
<PollOptions
options={options}
editable={editable}
selectedParticipantId={selectedParticipantId}
/>
</div>
);
})}
</div>
);
};
export default TimeSlotOptions;

View file

@ -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();

View file

@ -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<NotificationsToggleProps> =
() => {
const poll = usePoll();
const { poll } = usePoll();
const { t } = useTranslation("app");
const [isUpdatingNotifications, setIsUpdatingNotifications] =
React.useState(false);

View file

@ -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<ParticipantRowProps> = ({
const { mutate: updateParticipantMutation } =
useUpdateParticipantMutation(urlId);
const [deleteParticipantConfirModal, confirmDeleteParticipant] =
useDeleteParticipantModal(urlId, participant.id);
const confirmDeleteParticipant = useDeleteParticipantModal();
const poll = usePoll();
const { poll } = usePoll();
if (editMode) {
return (
<ParticipantRowForm
@ -83,7 +82,6 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
key={participant.id}
className="group flex h-14 transition-colors hover:bg-slate-50"
>
{deleteParticipantConfirModal}
<div
className="flex shrink-0 items-center px-4"
style={{ width: sidebarWidth }}
@ -136,7 +134,9 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
<Button
icon={<Trash />}
type="danger"
onClick={confirmDeleteParticipant}
onClick={() => {
confirmDeleteParticipant(participant.id);
}}
/>
) : null}
</div>

View file

@ -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<PollSubheaderProps> = () => {
const poll = usePoll();
const { poll } = usePoll();
const { t } = useTranslation("app");
const {

View file

@ -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<PollProps> = ({
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<PollProps> = ({
</div>
<TimeZonePicker
value={targetTimeZone}
onChange={onChangeTargetTimeZone}
onChange={setTargetTimeZone}
className="grow"
/>
</div>
@ -209,7 +207,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({
<ControlledScrollDiv>
{options.map((option) => {
const parsedOption = decodeDateOption(
option.value,
option,
timeZone,
targetTimeZone,
);

View file

@ -5,6 +5,4 @@ export interface ParticipantForm {
export interface PollProps {
pollId: string;
highScore: number;
onChangeTargetTimeZone: (timeZone: string) => void;
targetTimeZone: string;
}

View file

@ -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",
});
};
};

View file

@ -1,12 +0,0 @@
import { GetPollResponse } from "api-client/get-poll";
import React from "react";
export const PollContext = React.createContext<GetPollResponse | null>(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;
};

View file

@ -136,6 +136,9 @@ export default async function handler(
include: {
votes: true,
},
orderBy: {
value: "asc",
},
},
participants: {
include: {

View file

@ -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: {

View file

@ -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);
}
});

View file

@ -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 ? (
<FullPageLoader>{t("loading")}</FullPageLoader>
) : (
<PollContext.Provider value={poll}>
<PollContextProvider value={poll}>
<PollPage />
</PollContext.Provider>
</PollContextProvider>
);
};
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}
/>
<div>
<Popover
@ -290,12 +277,7 @@ const PollPage: NextPage = () => {
) : null}
<React.Suspense fallback={<div>Loading</div>}>
<div className="mb-4 lg:mb-8">
<PollComponent
pollId={poll.urlId}
highScore={highScore}
targetTimeZone={targetTimeZone}
onChangeTargetTimeZone={setTargetTimeZone}
/>
<PollComponent pollId={poll.urlId} highScore={highScore} />
</div>
<Discussion
pollId={poll.urlId}

View file

@ -62,7 +62,7 @@
@apply border-rose-500 bg-rose-50 placeholder:text-rose-500 focus:border-rose-400 focus:ring-rose-500;
}
.checkbox {
@apply h-4 w-4 cursor-pointer rounded border-slate-300 text-indigo-500 focus:ring-indigo-500;
@apply h-4 w-4 cursor-pointer rounded border-slate-300 text-indigo-500 shadow-sm focus:ring-indigo-500;
}
.btn {
@apply inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all focus:outline-none focus:ring-2 focus:ring-offset-1;

28
tests/mobile-test.spec.ts Normal file
View file

@ -0,0 +1,28 @@
import { expect, test } from "@playwright/test";
test("should be able to vote and comment on a poll", async ({ page }) => {
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();
});

View file

@ -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";

View file

@ -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"),