mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-28 17:56:37 +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
|
@ -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
104
components/poll-context.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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",
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -136,6 +136,9 @@ export default async function handler(
|
|||
include: {
|
||||
votes: true,
|
||||
},
|
||||
orderBy: {
|
||||
value: "asc",
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
include: {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
28
tests/mobile-test.spec.ts
Normal 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();
|
||||
});
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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"),
|
||||
|
|
Loading…
Add table
Reference in a new issue