mirror of
https://github.com/lukevella/rallly.git
synced 2025-08-03 00:19:03 +02:00
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
import { Listbox } from "@headlessui/react";
|
|
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 Check from "@/components/icons/check.svg";
|
|
import ChevronDown from "@/components/icons/chevron-down.svg";
|
|
import Pencil from "@/components/icons/pencil-alt.svg";
|
|
import PlusCircle from "@/components/icons/plus-circle.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 { useParticipants } from "../participants-provider";
|
|
import TimeZonePicker from "../time-zone-picker";
|
|
import { isUnclaimed, useUser } from "../user-provider";
|
|
import GroupedOptions from "./mobile-poll/grouped-options";
|
|
import {
|
|
normalizeVotes,
|
|
useAddParticipantMutation,
|
|
useUpdateParticipantMutation,
|
|
} from "./mutations";
|
|
import { ParticipantForm } from "./types";
|
|
import { useDeleteParticipantModal } from "./use-delete-participant-modal";
|
|
import UserAvatar from "./user-avatar";
|
|
|
|
if (typeof window !== "undefined") {
|
|
smoothscroll.polyfill();
|
|
}
|
|
|
|
const MobilePoll: React.VoidFunctionComponent = () => {
|
|
const pollContext = usePoll();
|
|
|
|
const {
|
|
poll,
|
|
admin,
|
|
targetTimeZone,
|
|
setTargetTimeZone,
|
|
getParticipantById,
|
|
optionIds,
|
|
getVote,
|
|
userAlreadyVoted,
|
|
} = pollContext;
|
|
|
|
const { participants } = useParticipants();
|
|
const { timeZone } = poll;
|
|
|
|
const session = useUser();
|
|
|
|
const form = useForm<ParticipantForm>({
|
|
defaultValues: {
|
|
name: "",
|
|
votes: [],
|
|
},
|
|
});
|
|
|
|
const { reset, handleSubmit, control, formState } = form;
|
|
const [selectedParticipantId, setSelectedParticipantId] =
|
|
React.useState<string | undefined>();
|
|
|
|
const selectedParticipant = selectedParticipantId
|
|
? getParticipantById(selectedParticipantId)
|
|
: undefined;
|
|
|
|
const [isEditing, setIsEditing] = React.useState(
|
|
!userAlreadyVoted && !poll.closed && !admin,
|
|
);
|
|
|
|
const formRef = React.useRef<HTMLFormElement>(null);
|
|
|
|
const { t } = useTranslation("app");
|
|
|
|
const updateParticipant = useUpdateParticipantMutation();
|
|
|
|
const addParticipant = useAddParticipantMutation();
|
|
const confirmDeleteParticipant = useDeleteParticipantModal();
|
|
|
|
return (
|
|
<FormProvider {...form}>
|
|
<form
|
|
ref={formRef}
|
|
onSubmit={handleSubmit(async ({ name, votes }) => {
|
|
if (selectedParticipant) {
|
|
await updateParticipant.mutateAsync({
|
|
pollId: poll.id,
|
|
participantId: selectedParticipant.id,
|
|
name,
|
|
votes: normalizeVotes(optionIds, votes),
|
|
});
|
|
setIsEditing(false);
|
|
} else {
|
|
const newParticipant = await addParticipant.mutateAsync({
|
|
pollId: poll.id,
|
|
name,
|
|
votes: normalizeVotes(optionIds, votes),
|
|
});
|
|
setSelectedParticipantId(newParticipant.id);
|
|
setIsEditing(false);
|
|
}
|
|
})}
|
|
>
|
|
<div className="flex flex-col space-y-2 border-b bg-gray-50 p-2">
|
|
<div className="flex space-x-2">
|
|
{!isEditing ? (
|
|
<Listbox
|
|
value={selectedParticipantId}
|
|
onChange={(participantId) => {
|
|
setSelectedParticipantId(participantId);
|
|
}}
|
|
disabled={isEditing}
|
|
>
|
|
<div className="menu min-w-0 grow">
|
|
<Listbox.Button
|
|
as={Button}
|
|
className="w-full"
|
|
disabled={!isEditing}
|
|
data-testid="participant-selector"
|
|
>
|
|
<div className="min-w-0 grow text-left">
|
|
{selectedParticipant ? (
|
|
<div className="flex items-center space-x-2">
|
|
<UserAvatar
|
|
name={selectedParticipant.name}
|
|
showName={true}
|
|
isYou={session.ownsObject(selectedParticipant)}
|
|
/>
|
|
</div>
|
|
) : (
|
|
t("participantCount", { count: participants.length })
|
|
)}
|
|
</div>
|
|
<ChevronDown className="h-5 shrink-0" />
|
|
</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">
|
|
<UserAvatar
|
|
name={participant.name}
|
|
showName={true}
|
|
isYou={session.ownsObject(participant)}
|
|
/>
|
|
</div>
|
|
</Listbox.Option>
|
|
))}
|
|
</Listbox.Options>
|
|
</div>
|
|
</Listbox>
|
|
) : (
|
|
<div className="grow">
|
|
<Controller
|
|
name="name"
|
|
control={control}
|
|
rules={{ validate: requiredString }}
|
|
render={({ field }) => (
|
|
<NameInput
|
|
autoFocus={true}
|
|
disabled={formState.isSubmitting}
|
|
className={clsx("input w-full", {
|
|
"input-error": formState.errors.name,
|
|
})}
|
|
{...field}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
)}
|
|
{isEditing ? (
|
|
<Button
|
|
onClick={() => {
|
|
setIsEditing(false);
|
|
reset();
|
|
}}
|
|
>
|
|
{t("cancel")}
|
|
</Button>
|
|
) : selectedParticipant ? (
|
|
<div className="flex space-x-2">
|
|
<Button
|
|
icon={<Pencil />}
|
|
disabled={
|
|
poll.closed ||
|
|
// if user is participant (not admin)
|
|
(!admin &&
|
|
// and does not own this participant
|
|
!session.ownsObject(selectedParticipant) &&
|
|
// and the participant has been claimed by a different user
|
|
!isUnclaimed(selectedParticipant))
|
|
// not allowed to edit
|
|
}
|
|
onClick={() => {
|
|
setIsEditing(true);
|
|
reset({
|
|
name: selectedParticipant.name,
|
|
votes: optionIds.map((optionId) => ({
|
|
optionId,
|
|
type: getVote(selectedParticipant.id, optionId),
|
|
})),
|
|
});
|
|
}}
|
|
>
|
|
{t("edit")}
|
|
</Button>
|
|
<Button
|
|
icon={<Trash />}
|
|
disabled={
|
|
poll.closed ||
|
|
// if user is participant (not admin)
|
|
(!admin &&
|
|
// and does not own this participant
|
|
!session.ownsObject(selectedParticipant) &&
|
|
// or the participant has been claimed by a different user
|
|
!isUnclaimed(selectedParticipant))
|
|
// not allowed to edit
|
|
}
|
|
data-testid="delete-participant-button"
|
|
type="danger"
|
|
onClick={() => {
|
|
if (selectedParticipant) {
|
|
confirmDeleteParticipant(selectedParticipant.id);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
type="primary"
|
|
icon={<PlusCircle />}
|
|
disabled={poll.closed}
|
|
onClick={() => {
|
|
reset({
|
|
name: "",
|
|
votes: [],
|
|
});
|
|
setIsEditing(true);
|
|
}}
|
|
>
|
|
{t("new")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{timeZone ? (
|
|
<TimeZonePicker
|
|
value={targetTimeZone}
|
|
onChange={setTargetTimeZone}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<GroupedOptions
|
|
selectedParticipantId={selectedParticipantId}
|
|
options={pollContext.options}
|
|
editable={isEditing}
|
|
group={(option) => {
|
|
if (option.type === "timeSlot") {
|
|
return `${option.dow} ${option.day} ${option.month}`;
|
|
}
|
|
return `${option.month} ${option.year}`;
|
|
}}
|
|
/>
|
|
<AnimatePresence>
|
|
{isEditing ? (
|
|
<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 className="space-y-3 border-t bg-gray-50 p-3">
|
|
<Button
|
|
icon={<Check />}
|
|
className="w-full"
|
|
htmlType="submit"
|
|
type="primary"
|
|
loading={formState.isSubmitting}
|
|
>
|
|
{t("save")}
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
</form>
|
|
</FormProvider>
|
|
);
|
|
};
|
|
|
|
export default MobilePoll;
|