rallly/components/poll/mobile-poll.tsx
2022-04-20 16:09:38 +01:00

361 lines
12 KiB
TypeScript

import { Listbox } from "@headlessui/react";
import { Participant, Vote } from "@prisma/client";
import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslation } from "next-i18next";
import * as React from "react";
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/poll-context";
import { requiredString } from "../../utils/form-validation";
import Button from "../button";
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 { ParticipantForm, PollProps } from "./types";
import { useDeleteParticipantModal } from "./use-delete-participant-modal";
import UserAvater from "./user-avatar";
if (typeof window !== "undefined") {
smoothscroll.polyfill();
}
const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
const pollContext = usePoll();
const { poll, targetTimeZone, setTargetTimeZone } = pollContext;
const { timeZone, participants, role } = poll;
const [, setUserName] = useUserName();
const participantById = participants.reduce<
Record<string, Participant & { votes: Vote[] }>
>((acc, curr) => {
acc[curr.id] = { ...curr };
return acc;
}, {});
const form = useForm<ParticipantForm>({
defaultValues: {
name: "",
votes: [],
},
});
const { reset, handleSubmit, control, formState } = form;
const [selectedParticipantId, setSelectedParticipantId] =
React.useState<string>();
const selectedParticipant = selectedParticipantId
? participantById[selectedParticipantId]
: undefined;
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 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 (
<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,
});
}
});
})}
>
<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={clsx("btn-default w-full px-2 text-left", {
"btn-disabled": editable,
})}
>
<div className="grow">
{selectedParticipant ? (
<div className="flex items-center space-x-2">
<UserAvater name={selectedParticipant.name} />
<span>{selectedParticipant.name}</span>
</div>
) : (
t("participantCount", { count: participants.length })
)}
</div>
<ChevronDown className="h-5" />
</Listbox.Button>
<Listbox.Options
as={motion.div}
transition={{
duration: 0.1,
}}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="menu-items max-h-72 w-full overflow-auto"
>
<Listbox.Option value={undefined} className={styleMenuItem}>
{t("participantCount", { count: participants.length })}
</Listbox.Option>
{participants.map((participant) => (
<Listbox.Option
key={participant.id}
value={participant.id}
className={styleMenuItem}
>
<div className="flex items-center space-x-2">
<UserAvater name={participant.name} />
<span>{participant.name}</span>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</div>
</Listbox>
{!poll.closed && !editable ? (
selectedParticipant ? (
<div className="flex space-x-3">
<Button
icon={<Pencil />}
onClick={() => {
setEditable(true);
reset({
name: selectedParticipant.name,
votes: selectedParticipant.votes.map(
(vote) => vote.optionId,
),
});
}}
>
Edit
</Button>
{role === "admin" ? (
<Button
icon={<Trash />}
data-testid="delete-participant-button"
type="danger"
onClick={() => {
if (selectedParticipant) {
confirmDeleteParticipant(selectedParticipant.id);
}
}}
/>
) : null}
</div>
) : (
<Button
type="primary"
icon={<PlusCircle />}
onClick={() => {
reset({ name: "", votes: [] });
setUserName("");
setEditable(true);
}}
>
New
</Button>
)
) : null}
{editable ? (
<Button
onClick={() => {
setEditable(false);
reset();
}}
>
Cancel
</Button>
) : null}
</div>
{timeZone ? (
<TimeZonePicker
value={targetTimeZone}
onChange={setTargetTimeZone}
/>
) : null}
</div>
{(() => {
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>
);
};
export default MobilePoll;