mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-01 11:16:32 +02:00
361 lines
12 KiB
TypeScript
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;
|