mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-19 20:06:19 +02:00
Improvements to mobile UI (#119)
This commit is contained in:
parent
f206d31083
commit
dde0fe8ea1
26 changed files with 971 additions and 317 deletions
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
26
components/poll/mobile-poll/date-option.tsx
Normal file
26
components/poll/mobile-poll/date-option.tsx
Normal 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;
|
156
components/poll/mobile-poll/poll-option.tsx
Normal file
156
components/poll/mobile-poll/poll-option.tsx
Normal 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;
|
100
components/poll/mobile-poll/poll-options.tsx
Normal file
100
components/poll/mobile-poll/poll-options.tsx
Normal 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;
|
52
components/poll/mobile-poll/popularity-score.tsx
Normal file
52
components/poll/mobile-poll/popularity-score.tsx
Normal 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);
|
32
components/poll/mobile-poll/time-slot-option.tsx
Normal file
32
components/poll/mobile-poll/time-slot-option.tsx
Normal 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;
|
42
components/poll/mobile-poll/time-slot-options.tsx
Normal file
42
components/poll/mobile-poll/time-slot-options.tsx
Normal 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;
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -5,6 +5,4 @@ export interface ParticipantForm {
|
|||
export interface PollProps {
|
||||
pollId: string;
|
||||
highScore: number;
|
||||
onChangeTargetTimeZone: (timeZone: string) => void;
|
||||
targetTimeZone: string;
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue