First public commit

This commit is contained in:
Luke Vella 2022-04-12 07:14:28 +01:00
commit e05cd62e53
228 changed files with 17717 additions and 0 deletions

1
components/poll/index.ts Normal file
View file

@ -0,0 +1 @@
export { default } from "./poll";

View file

@ -0,0 +1,58 @@
import Speakerphone from "@/components/icons/speakerphone.svg";
import Cookies from "js-cookie";
import * as React from "react";
const cookieName = "legacy-poll-notice";
const LegacyPollNotice: React.VoidFunctionComponent<{ show?: boolean }> = ({
show,
}) => {
const [visible, setVisible] = React.useState(show);
if (!visible) {
return null;
}
const didSeeLegacyPollNotice = !!Cookies.get(cookieName);
if (didSeeLegacyPollNotice) {
return null;
}
const setCookie = () => {
setVisible(false);
Cookies.set(cookieName, "1", {
expires: 60,
});
};
return (
<div className="md:flex md:items-center space-y-3 md:space-y-0 text-sm shadow-sm rounded-lg mb-4 border md:space-x-4 p-2 bg-yellow-200 text-yellow-700">
<div className="flex space-x-3 md:grow md:items-center">
<div className="bg-yellow-400 w-9 h-9 p-2 rounded-lg">
<Speakerphone className="w-5" />
</div>
<div className="grow">
Notice anything different? We&apos;ve announced a new version release.
</div>
</div>
<div className="flex space-x-3 ml-12">
<a
onClick={() => setCookie()}
className="btn-default border-0"
href="https://blog.rallly.co/posts/new-version-announcment"
>
Read more
</a>
<button
onClick={() => setCookie()}
className="py-2 px-3 transition-colors bg-yellow-300 rounded-lg active:bg-yellow-400"
>
Hide
</button>
</div>
</div>
);
};
export default LegacyPollNotice;

View file

@ -0,0 +1,210 @@
import * as React from "react";
import Dropdown, { DropdownItem } from "../dropdown";
import { usePoll } from "../use-poll";
import Pencil from "@/components/icons/pencil-alt.svg";
import Table from "@/components/icons/table.svg";
import Save from "@/components/icons/save.svg";
import Cog from "@/components/icons/cog.svg";
import LockOpen from "@/components/icons/lock-open.svg";
import LockClosed from "@/components/icons/lock-closed.svg";
import { useTranslation } from "next-i18next";
import { format } from "date-fns";
import { decodeDateOption, encodeDateOption } from "utils/date-time-utils";
import { useModal } from "../modal";
import { useUpdatePollMutation } from "./mutations";
import { PollDetailsForm } from "../forms";
import Button from "@/components/button";
import { Placement } from "@popperjs/core";
const PollOptionsForm = React.lazy(() => import("../forms/poll-options-form"));
const ManagePoll: React.VoidFunctionComponent<{
targetTimeZone: string;
placement?: Placement;
}> = ({ targetTimeZone, placement }) => {
const { t } = useTranslation("app");
const poll = usePoll();
const { mutate: updatePollMutation, isLoading: isUpdating } =
useUpdatePollMutation();
const [
changeOptionsModalContextHolder,
openChangeOptionsModal,
closeChangeOptionsModal,
] = useModal({
okText: "Save",
okButtonProps: {
form: "pollOptions",
htmlType: "submit",
loading: isUpdating,
},
cancelText: "Cancel",
content: (
<React.Suspense fallback={null}>
<PollOptionsForm
name="pollOptions"
title={poll.title}
defaultValues={{
navigationDate: poll.options[0].value.split("/")[0],
options: poll.options.map((option) => {
const [start, end] = option.value.split("/");
return end
? {
type: "timeSlot",
start,
end,
}
: {
type: "date",
date: start,
};
}),
timeZone: poll.timeZone ?? "",
}}
onSubmit={(data) => {
const encodedOptions = data.options.map(encodeDateOption);
const optionsToDelete = poll.options
.filter((option) => {
return !encodedOptions.includes(option.value);
})
.map((option) => option.id);
const optionsToAdd = encodedOptions.filter(
(encodedOption) =>
!poll.options.find((o) => o.value === encodedOption),
);
updatePollMutation(
{
timeZone: data.timeZone,
optionsToDelete,
optionsToAdd,
},
{
onSuccess: () => closeChangeOptionsModal(),
},
);
}}
/>
</React.Suspense>
),
});
const [
changePollDetailsModalContextHolder,
openChangePollDetailsModa,
closePollDetailsModal,
] = useModal({
okText: "Save changes",
okButtonProps: {
form: "updateDetails",
loading: isUpdating,
htmlType: "submit",
},
cancelText: "Cancel",
content: (
<PollDetailsForm
name="updateDetails"
defaultValues={{
title: poll.title,
location: poll.location ?? "",
description: poll.description ?? "",
}}
className="p-4"
onSubmit={(data) => {
//submit
updatePollMutation(data, { onSuccess: closePollDetailsModal });
}}
/>
),
});
return (
<>
{changeOptionsModalContextHolder}
{changePollDetailsModalContextHolder}
<Dropdown
placement={placement}
trigger={<Button icon={<Cog />}>Manage</Button>}
>
<DropdownItem
icon={Pencil}
label="Edit details"
onClick={openChangePollDetailsModa}
/>
<DropdownItem
icon={Table}
label="Edit options"
onClick={openChangeOptionsModal}
/>
<DropdownItem
icon={Save}
label="Export to CSV"
onClick={() => {
const header = [
t("participantCount", {
count: poll.participants.length,
}),
...poll.options.map((option) => {
const decodedOption = decodeDateOption(
option.value,
poll.timeZone,
targetTimeZone,
);
const day = `${decodedOption.dow} ${decodedOption.day} ${decodedOption.month}`;
return decodedOption.type === "date"
? day
: `${day} ${decodedOption.startTime} - ${decodedOption.endTime}`;
}),
].join(",");
const rows = poll.participants.map((participant) => {
return [
participant.name,
...poll.options.map((option) => {
if (
participant.votes.some((vote) => {
return vote.optionId === option.id;
})
) {
return "Yes";
}
return "No";
}),
].join(",");
});
const csv = `data:text/csv;charset=utf-8,${[header, ...rows].join(
"\r\n",
)}`;
const encodedCsv = encodeURI(csv);
var link = document.createElement("a");
link.setAttribute("href", encodedCsv);
link.setAttribute(
"download",
`${poll.title.replace(/\s/g, "_")}-${format(
Date.now(),
"yyyyMMddhhmm",
)}`,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
/>
{poll.closed ? (
<DropdownItem
icon={LockOpen}
label="Unlock poll"
onClick={() => updatePollMutation({ closed: false })}
/>
) : (
<DropdownItem
icon={LockClosed}
label="Lock poll"
onClick={() => updatePollMutation({ closed: true })}
/>
)}
</Dropdown>
</>
);
};
export default ManagePoll;

View file

@ -0,0 +1 @@
export { default } from "./mobile-poll";

View file

@ -0,0 +1,334 @@
import { usePoll } from "@/components/use-poll";
import { Listbox } from "@headlessui/react";
import { Participant, Vote } from "@prisma/client";
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { decodeDateOption } from "../../../utils/date-time-utils";
import { requiredString } from "../../../utils/form-validation";
import Button from "../../button";
import DateCard from "../../date-card";
import ChevronDown from "../../icons/chevron-down.svg";
import Pencil from "../../icons/pencil.svg";
import PlusCircle from "../../icons/plus-circle.svg";
import Save from "../../icons/save.svg";
import Trash from "../../icons/trash.svg";
import { styleMenuItem } from "../../menu-styles";
import NameInput from "../../name-input";
import TimeZonePicker from "../../time-zone-picker";
import { TransitionPopInOut } from "../../transitions";
import { useUserName } from "../../user-name-context";
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,
timeZone,
options,
participants,
highScore,
targetTimeZone,
onChangeTargetTimeZone,
role,
}) => {
const [, setUserName] = useUserName();
const participantById = participants.reduce<
Record<string, Participant & { votes: Vote[] }>
>((acc, curr) => {
acc[curr.id] = { ...curr };
return acc;
}, {});
const { register, setValue, reset, handleSubmit, control, formState } =
useForm<ParticipantForm>({
defaultValues: {
name: "",
votes: [],
},
});
const [selectedParticipantId, setSelectedParticipantId] =
React.useState<string>();
const selectedParticipant = selectedParticipantId
? 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 { 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 poll = usePoll();
return (
<form
className="border-t border-b shadow-sm bg-white"
onSubmit={handleSubmit((data) => {
return new Promise<ParticipantForm>((resolve, reject) => {
if (selectedParticipant) {
updateParticipantMutation(
{
participantId: selectedParticipant.id,
pollId,
...data,
},
{
onSuccess: () => {
setMode("default");
resolve(data);
},
onError: reject,
},
);
} else {
addParticipantMutation(data, {
onSuccess: (newParticipant) => {
setMode("default");
setSelectedParticipantId(newParticipant.id);
resolve(data);
},
onError: reject,
});
}
});
})}
>
{checkboxGroupHack}
<div className="sticky top-0 px-4 py-2 space-y-2 flex flex-col border-b z-30 bg-gray-50">
{mode === "default" ? (
<div className="flex space-x-3">
<Listbox
value={selectedParticipantId}
onChange={setSelectedParticipantId}
>
<div className="menu grow">
<Listbox.Button className="btn-default w-full text-left">
<div className="grow">
{selectedParticipant ? (
<div className="flex space-x-2 items-center">
<UserAvater name={selectedParticipant.name} />
<span>{selectedParticipant.name}</span>
</div>
) : (
t("participantCount", { count: participants.length })
)}
</div>
<ChevronDown className="h-5" />
</Listbox.Button>
<TransitionPopInOut>
<Listbox.Options className="menu-items w-full">
<Listbox.Option value={undefined} className={styleMenuItem}>
Show all
</Listbox.Option>
{participants.map((participant) => (
<Listbox.Option
key={participant.id}
value={participant.id}
className={styleMenuItem}
>
<div className="flex space-x-2 items-center">
<UserAvater name={participant.name} />
<span>{participant.name}</span>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</TransitionPopInOut>
</div>
</Listbox>
{!poll.closed ? (
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),
);
}}
>
Edit
</Button>
{role === "admin" ? (
<Button
icon={<Trash />}
type="danger"
onClick={confirmDeleteParticipant}
/>
) : null}
{deleteParticipantModal}
</div>
) : (
<Button
type="primary"
icon={<PlusCircle />}
onClick={() => {
reset();
setUserName("");
setMode("edit");
}}
>
New
</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="px-4 py-2 flex items-center space-x-4"
>
<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 space-y-1 items-center">
<div>
<span
className={clsx(
"inline-block px-2 leading-relaxed border rounded-full text-xs",
{
"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 ring-1 ring-white items-center justify-center rounded-full font-medium bg-slate-100 text-xs px-1 h-5">
+{option.votes.length - 5}
</span>
) : null}
</div>
) : null}
</div>
<div className="w-12 items-center justify-center h-14 flex">
{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="p-2 border-t flex space-x-3">
<Button className="grow" onClick={() => setMode("default")}>
Cancel
</Button>
<Button
icon={<Save />}
htmlType="submit"
className="grow"
type="primary"
loading={formState.isSubmitting}
>
Save
</Button>
</div>
) : null}
</form>
);
};
export default MobilePoll;

View file

@ -0,0 +1,115 @@
import { updatePoll, UpdatePollPayload } from "api-client/update-poll";
import { usePlausible } from "next-plausible";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { addParticipant } from "../../api-client/add-participant";
import {
deleteParticipant,
DeleteParticipantPayload,
} from "../../api-client/delete-participant";
import { GetPollResponse } from "../../api-client/get-poll";
import {
updateParticipant,
UpdateParticipantPayload,
} from "../../api-client/update-participant";
import { usePoll } from "../use-poll";
import { useUserName } from "../user-name-context";
import { ParticipantForm } from "./types";
export const useAddParticipantMutation = (pollId: string) => {
const queryClient = useQueryClient();
const [, setUserName] = useUserName();
const plausible = usePlausible();
return useMutation(
(payload: ParticipantForm) =>
addParticipant({
pollId,
name: payload.name.trim(),
votes: payload.votes,
}),
{
onSuccess: (participant, { name }) => {
plausible("Add participant");
setUserName(name);
queryClient.setQueryData<GetPollResponse>(
["getPoll", pollId],
(poll) => {
if (!poll) {
throw new Error(
"Tried to update poll but no result found in query cache",
);
}
poll.participants = [participant, ...poll.participants];
participant.votes.forEach((vote) => {
const votedOption = poll.options.find(
(option) => option.id === vote.optionId,
);
votedOption?.votes.push(vote);
});
poll.options.forEach((option) => {
participant.votes.some(({ optionId }) => optionId === option.id);
});
return poll;
},
);
},
},
);
};
export const useUpdateParticipantMutation = (pollId: string) => {
const queryClient = useQueryClient();
const [, setUserName] = useUserName();
const plausible = usePlausible();
return useMutation(
(payload: UpdateParticipantPayload) =>
updateParticipant({
pollId,
participantId: payload.participantId,
name: payload.name.trim(),
votes: payload.votes,
}),
{
onMutate: ({ name }) => {
setUserName(name);
},
onSuccess: () => {
plausible("Update participant");
},
onSettled: () => {
queryClient.invalidateQueries(["getPoll", pollId]);
},
},
);
};
export const useDeleteParticipantMutation = (pollId: string) => {
const queryClient = useQueryClient();
const plausible = usePlausible();
return useMutation(
(payload: DeleteParticipantPayload) => deleteParticipant(payload),
{
onSuccess: () => {
plausible("Remove participant");
},
onSettled: () => {
queryClient.invalidateQueries(["getPoll", pollId]);
},
},
);
};
export const useUpdatePollMutation = () => {
const poll = usePoll();
const plausible = usePlausible();
const queryClient = useQueryClient();
return useMutation(
(payload: UpdatePollPayload) => updatePoll(poll.urlId, payload),
{
onSuccess: (data) => {
queryClient.setQueryData(["getPoll", poll.urlId], data);
plausible("Updated poll");
},
},
);
};

View file

@ -0,0 +1,82 @@
import * as React from "react";
import Tooltip from "../tooltip";
import { usePoll } from "../use-poll";
import { Trans, useTranslation } from "next-i18next";
import Button from "@/components/button";
import Bell from "@/components/icons/bell.svg";
import BellCrossed from "@/components/icons/bell-crossed.svg";
import { useUpdatePollMutation } from "./mutations";
import { usePlausible } from "next-plausible";
export interface NotificationsToggleProps {}
const NotificationsToggle: React.VoidFunctionComponent<NotificationsToggleProps> =
() => {
const poll = usePoll();
const { t } = useTranslation("app");
const [isUpdatingNotifications, setIsUpdatingNotifications] =
React.useState(false);
const { mutate: updatePollMutation } = useUpdatePollMutation();
const plausible = usePlausible();
return (
<Tooltip
content={
poll.verified ? (
poll.notifications ? (
<div>
<div className="font-medium text-indigo-300">
Notifications are on
</div>
<div className="max-w-sm">
<Trans
t={t}
i18nKey="notificationsOnDescription"
values={{
email: poll.user.email,
}}
components={{
b: <span className="email" />,
}}
/>
</div>
</div>
) : (
"Notifications are off"
)
) : (
"You need to verify your email to turn on notifications"
)
}
>
<Button
loading={isUpdatingNotifications}
icon={
poll.verified && poll.notifications ? <Bell /> : <BellCrossed />
}
disabled={!poll.verified}
onClick={() => {
setIsUpdatingNotifications(true);
updatePollMutation(
{
notifications: !poll.notifications,
},
{
onSuccess: ({ notifications }) => {
plausible(
notifications
? "Turned notifications on"
: "Turned notifications off",
);
setIsUpdatingNotifications(false);
},
},
);
}}
/>
</Tooltip>
);
};
export default NotificationsToggle;

View file

@ -0,0 +1,178 @@
import { Option } from "@prisma/client";
import clsx from "clsx";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { requiredString } from "../../utils/form-validation";
import Button from "../button";
import CheckCircle from "../icons/check-circle.svg";
import NameInput from "../name-input";
import { ControlledScrollDiv } from "./poll";
import { usePollContext } from "./poll-context";
import { ParticipantForm } from "./types";
export interface ParticipantRowFormProps {
defaultValues?: Partial<ParticipantForm>;
onSubmit: (data: ParticipantForm) => Promise<void>;
className?: string;
options: Option[];
onCancel?: () => void;
}
const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
({ defaultValues, onSubmit, className, options, onCancel }) => {
const {
setActiveOptionId,
activeOptionId,
columnWidth,
scrollPosition,
sidebarWidth,
numberOfColumns,
goToNextPage,
setScrollPosition,
} = usePollContext();
const {
handleSubmit,
register,
control,
formState: { errors, submitCount, isSubmitting },
reset,
} = useForm<ParticipantForm>({
defaultValues: { name: "", votes: [], ...defaultValues },
});
const isColumnVisible = (index: number) => {
return (
scrollPosition + numberOfColumns * columnWidth > columnWidth * index
);
};
const checkboxRefs = React.useRef<HTMLInputElement[]>([]);
const isAnimatingRef = React.useRef(false);
// 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 checkboxProps = register("votes", {
onBlur: () => setActiveOptionId(null),
});
const checkboxGroupHack = (
<input type="checkbox" className="hidden" {...checkboxProps} />
);
return (
<form
onSubmit={handleSubmit(async ({ name, votes }) => {
await onSubmit({
name,
// if there is only one checkbox then we get a string rather than array
// See this issue with using dot notation: https://github.com/react-hook-form/react-hook-form/issues/7834
votes: Array.isArray(votes) ? votes : [votes],
});
reset();
})}
className={clsx("flex shrink-0 h-14", className)}
>
{checkboxGroupHack}
<div className="flex items-center px-4" style={{ width: sidebarWidth }}>
<Controller
name="name"
rules={{
validate: requiredString,
}}
render={({ field }) => (
<div className="-ml-2">
<NameInput
autoFocus={true}
className={clsx("w-full", {
"animate-wiggle input-error":
errors.name && submitCount > 0,
})}
placeholder="Your name"
{...field}
onKeyDown={(e) => {
if (e.code === "Tab" && scrollPosition > 0) {
e.preventDefault();
setScrollPosition(0);
setTimeout(() => {
checkboxRefs.current[0].focus();
}, 800);
}
}}
/>
</div>
)}
control={control}
/>
</div>
<ControlledScrollDiv>
{options.map((option, index) => {
return (
<div
key={option.id}
className={clsx(
"flex justify-center items-center shrink-0 transition-colors",
{
"bg-slate-50": activeOptionId === option.id,
},
)}
style={{ width: columnWidth }}
onMouseOver={() => setActiveOptionId(option.id)}
onMouseOut={() => setActiveOptionId(null)}
>
<input
className="checkbox"
type="checkbox"
value={option.id}
onKeyDown={(e) => {
if (isAnimatingRef.current) {
return e.preventDefault();
}
if (
e.code === "Tab" &&
index < options.length - 1 &&
!isColumnVisible(index + 1)
) {
isAnimatingRef.current = true;
e.preventDefault();
goToNextPage();
setTimeout(() => {
checkboxRefs.current[index + 1].focus();
isAnimatingRef.current = false;
}, 500);
}
}}
{...checkboxProps}
ref={(el) => {
if (el) {
checkboxRefs.current[index] = el;
checkboxProps.ref(el);
}
}}
onFocus={() => {
setActiveOptionId(option.id);
}}
/>
</div>
);
})}
</ControlledScrollDiv>
<div className="flex items-center px-2 space-x-2 transition-all">
<Button
htmlType="submit"
icon={<CheckCircle />}
type="primary"
loading={isSubmitting}
data-testid="submitNewParticipant"
/>
<Button onClick={onCancel} type="default">
Cancel
</Button>
</div>
</form>
);
};
export default ParticipantRowForm;

View file

@ -0,0 +1,148 @@
import { Option, Participant, Vote } from "@prisma/client";
import clsx from "clsx";
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 { useUpdateParticipantMutation } from "./mutations";
import ParticipantRowForm from "./participant-row-form";
import { ControlledScrollDiv } from "./poll";
import { usePollContext } from "./poll-context";
import { useDeleteParticipantModal } from "./use-delete-participant-modal";
import UserAvater from "./user-avatar";
import VoteIcon from "./vote-icon";
export interface ParticipantRowProps {
urlId: string;
participant: Participant & { votes: Vote[] };
options: Array<Option & { votes: Vote[] }>;
editMode: boolean;
canDelete?: boolean;
onChangeEditMode?: (value: boolean) => void;
}
const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
urlId,
participant,
options,
editMode,
canDelete,
onChangeEditMode,
}) => {
const {
setActiveOptionId,
activeOptionId,
columnWidth,
sidebarWidth,
actionColumnWidth,
} = usePollContext();
const { mutate: updateParticipantMutation } =
useUpdateParticipantMutation(urlId);
const [deleteParticipantConfirModal, confirmDeleteParticipant] =
useDeleteParticipantModal(urlId, participant.id);
const poll = usePoll();
if (editMode) {
return (
<ParticipantRowForm
defaultValues={{
name: participant.name,
votes: participant.votes.map(({ optionId }) => optionId),
}}
onSubmit={async ({ name, votes }) => {
return new Promise((resolve, reject) => {
updateParticipantMutation(
{
pollId: participant.pollId,
participantId: participant.id,
votes,
name,
},
{
onSuccess: () => {
onChangeEditMode?.(false);
resolve();
},
onError: reject,
},
);
});
}}
options={options}
onCancel={() => onChangeEditMode?.(false)}
/>
);
}
return (
<div
key={participant.id}
className="group flex hover:bg-slate-50 h-14 transition-colors"
>
{deleteParticipantConfirModal}
<div
className="flex items-center px-4 shrink-0"
style={{ width: sidebarWidth }}
>
<UserAvater className="mr-2" name={participant.name} />
<span className="truncate" title={participant.name}>
{participant.name}
</span>
</div>
<ControlledScrollDiv>
{options.map((option) => {
return (
<div
key={option.id}
className={clsx(
"justify-center items-center flex shrink-0 transition-colors",
{
"bg-slate-50": activeOptionId === option.id,
},
)}
style={{ width: columnWidth }}
onMouseOver={() => setActiveOptionId(option.id)}
onMouseOut={() => setActiveOptionId(null)}
>
{option.votes.some(
(vote) => vote.participantId === participant.id,
) ? (
<VoteIcon type="yes" />
) : (
<VoteIcon type="no" />
)}
</div>
);
})}
</ControlledScrollDiv>
{!poll.closed ? (
<div
style={{ width: actionColumnWidth }}
className="flex items-center overflow-hidden px-2 opacity-0 group-hover:opacity-100 delay-100 transition-all space-x-2"
>
<Button
icon={<Pencil />}
onClick={() => {
onChangeEditMode?.(true);
}}
>
Edit
</Button>
{canDelete ? (
<Button
icon={<Trash />}
type="danger"
onClick={confirmDeleteParticipant}
/>
) : null}
</div>
) : null}
</div>
);
};
export default ParticipantRow;

View file

@ -0,0 +1,30 @@
import noop from "lodash/noop";
import React from "react";
export const PollContext = React.createContext<{
activeOptionId: string | null;
setActiveOptionId: (optionId: string | null) => void;
scrollPosition: number;
setScrollPosition: (position: number) => void;
columnWidth: number;
sidebarWidth: number;
numberOfColumns: number;
availableSpace: number;
actionColumnWidth: number;
goToNextPage: () => void;
goToPreviousPage: () => void;
}>({
activeOptionId: null,
setActiveOptionId: noop,
scrollPosition: 0,
setScrollPosition: noop,
columnWidth: 100,
sidebarWidth: 200,
numberOfColumns: 0,
availableSpace: 0,
goToNextPage: noop,
goToPreviousPage: noop,
actionColumnWidth: 0,
});
export const usePollContext = () => React.useContext(PollContext);

View file

@ -0,0 +1,67 @@
import { formatRelative } from "date-fns";
import * as React from "react";
import { Trans, useTranslation } from "next-i18next";
import Tooltip from "../tooltip";
import { usePoll } from "../use-poll";
export interface PollSubheaderProps {}
const PollSubheader: React.VoidFunctionComponent<PollSubheaderProps> = () => {
const poll = usePoll();
const { t } = useTranslation("app");
return (
<div className="text-slate-500">
<div className="md:inline">
<Trans
i18nKey="createdBy"
t={t}
values={{
name: poll.authorName,
date: Date.parse(poll.createdAt),
formatParams: {
date: {
year: "numeric",
month: "numeric",
day: "numeric",
},
},
}}
components={{
b: <span className="font-medium text-indigo-500" />,
}}
/>
&nbsp;
{poll.role === "admin" ? (
poll.verified ? (
<span className="badge border-green-400 bg-green-50 text-green-500">
Verified
</span>
) : (
<Tooltip
content={
<div className="max-w-sm">
<Trans
t={t}
i18nKey="unverifiedMessage"
values={{ email: poll.user.email }}
components={{
b: <span className="email" />,
}}
/>
</div>
}
>
<span className="badge text-slate-400">Unverified</span>
</Tooltip>
)
) : null}
</div>
<span className="hidden md:inline">&nbsp;&bull;&nbsp;</span>
<span className="whitespace-nowrap">
{formatRelative(new Date(poll.createdAt), new Date())}
</span>
</div>
);
};
export default PollSubheader;

339
components/poll/poll.tsx Normal file
View file

@ -0,0 +1,339 @@
import clsx from "clsx";
import debounce from "lodash/debounce";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { useMeasure } from "react-use";
import { decodeDateOption } from "../../utils/date-time-utils";
import Button from "../button";
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 TimeZonePicker from "../time-zone-picker";
import { TransitionPopInOut } from "../transitions";
import { useAddParticipantMutation } from "./mutations";
import ParticipantRow from "./participant-row";
import ParticipantRowForm from "./participant-row-form";
import { PollContext, usePollContext } from "./poll-context";
import Score from "./score";
import TimeRange from "./time-range";
import { PollProps } from "./types";
import smoothscroll from "smoothscroll-polyfill";
import { usePoll } from "../use-poll";
if (typeof window !== "undefined") {
smoothscroll.polyfill();
}
// There's a bug in Safari 15.4 that causes `scroll` to no work as intended
const isSafariV154 =
typeof window !== "undefined"
? /Version\/15.[4-9]\sSafari/.test(navigator.userAgent)
: false;
export const ControlledScrollDiv: React.VoidFunctionComponent<{
children?: React.ReactNode;
className?: string;
}> = ({ className, children }) => {
const { setScrollPosition, availableSpace, scrollPosition } =
usePollContext();
const ref = React.useRef<HTMLDivElement>(null);
const didSetInitialScrollPosition = React.useRef(false);
React.useEffect(() => {
if (ref.current) {
if (!isSafariV154) {
ref.current.scroll({
left: scrollPosition,
behavior: didSetInitialScrollPosition?.current ? "smooth" : "auto",
});
} else {
ref.current.scrollLeft = scrollPosition;
}
didSetInitialScrollPosition.current = true;
}
}, [scrollPosition]);
return (
<div
ref={ref}
className={clsx("flex min-w-0 overflow-hidden", className)}
style={{ width: availableSpace, maxWidth: availableSpace }}
onScroll={(e) => {
const div = e.target as HTMLDivElement;
setScrollPosition(div.scrollLeft);
}}
>
{children}
</div>
);
};
const Poll: React.VoidFunctionComponent<
PollProps & {
width?: number;
sidebarWidth?: number;
columnWidth?: number;
actionColumnWidth?: number;
}
> = ({
pollId,
role,
timeZone,
options,
participants,
highScore,
targetTimeZone,
onChangeTargetTimeZone,
actionColumnWidth = 160,
sidebarWidth = 200,
columnWidth: defaultColumnWidth,
width: defaultWidth,
}) => {
const { t } = useTranslation("app");
const [ref, { width: measuredWidth }] = useMeasure<HTMLDivElement>();
const [editingParticipantId, setEditingParticipantId] =
React.useState<string | null>(null);
const width = defaultWidth ?? measuredWidth;
const columnWidth =
defaultColumnWidth ??
Math.min(
150,
Math.max(95, (width - sidebarWidth - actionColumnWidth) / options.length),
);
const numberOfVisibleColumns = Math.floor(
(width - (sidebarWidth + actionColumnWidth)) / columnWidth,
);
const availableSpace = Math.min(
numberOfVisibleColumns * columnWidth,
options.length * columnWidth,
);
const [activeOptionId, setActiveOptionId] =
React.useState<string | null>(null);
const [scrollPosition, setScrollPosition] = React.useState(0);
const maxScrollPosition =
columnWidth * options.length - columnWidth * numberOfVisibleColumns;
const debouncedSetScrollPosition = React.useMemo(
() => debounce(setScrollPosition, 200),
[],
);
const numberOfInvisibleColumns = options.length - numberOfVisibleColumns;
const [didUsePagination, setDidUsePagination] = React.useState(false);
const [shouldShowNewParticipantForm, setShouldShowNewParticipantForm] =
React.useState(participants.length === 0);
const pollWidth =
sidebarWidth + options.length * columnWidth + actionColumnWidth;
const { mutate: addParticipant } = useAddParticipantMutation(pollId);
const goToNextPage = () => {
debouncedSetScrollPosition(
Math.min(
maxScrollPosition,
scrollPosition + numberOfVisibleColumns * columnWidth,
),
);
};
const goToPreviousPage = () => {
setScrollPosition(
Math.max(0, scrollPosition - numberOfVisibleColumns * columnWidth),
);
};
const poll = usePoll();
return (
<PollContext.Provider
value={{
activeOptionId,
setActiveOptionId,
scrollPosition,
setScrollPosition: debouncedSetScrollPosition,
columnWidth,
sidebarWidth,
goToNextPage,
goToPreviousPage,
numberOfColumns: numberOfVisibleColumns,
availableSpace,
actionColumnWidth,
}}
>
<div
className="relative max-w-full min-w-full" // Don't add styles like border, margin, padding that can mess up the sizing calculations
style={{ width: `min(${pollWidth}px, calc(100vw - 3rem))` }}
ref={ref}
>
<div className="md:rounded-lg shadow-sm bg-white border-t border-b md:border">
<div className="shadow-sm shadow-slate-50 bg-white/80 backdrop-blur-md rounded-t-lg border-gray-200 border-b sticky top-0 z-10">
{role !== "readOnly" ? (
<div className="flex px-4 h-14 items-center justify-end space-x-4 border-b bg-gray-50 rounded-t-lg">
{timeZone ? (
<div className="flex items-center grow">
<div className="text-sm mr-2 font-medium text-slate-500">
{t("timeZone")}
</div>
<TimeZonePicker
value={targetTimeZone}
onChange={onChangeTargetTimeZone}
className="grow"
/>
</div>
) : null}
<div className="shrink-0 flex">
{!shouldShowNewParticipantForm && !poll.closed ? (
<Button
type="primary"
icon={<PlusCircle />}
onClick={() => {
setShouldShowNewParticipantForm(true);
}}
>
New Participant
</Button>
) : null}
</div>
</div>
) : null}
<div className="flex">
<div
className="flex items-center pl-4 pr-2 py-4 shrink-0 font-medium"
style={{ width: sidebarWidth }}
>
<div className="grow h-full flex items-end">
{t("participantCount", { count: participants.length })}
</div>
<TransitionPopInOut show={scrollPosition > 0}>
<Button rounded={true} onClick={goToPreviousPage}>
<ArrowLeft className="h-4 w-4" />
</Button>
</TransitionPopInOut>
</div>
<ControlledScrollDiv>
{options.map((option) => {
const parsedOption = decodeDateOption(
option.value,
timeZone,
targetTimeZone,
);
const numVotes = option.votes.length;
return (
<div
key={option.id}
className={clsx(
"py-4 text-center shrink-0 transition-colors",
{
"bg-slate-50": activeOptionId === option.id,
},
)}
style={{ width: columnWidth }}
onMouseOver={() => setActiveOptionId(option.id)}
onMouseOut={() => setActiveOptionId(null)}
>
<div>
<DateCard
day={parsedOption.day}
dow={parsedOption.dow}
month={parsedOption.month}
annotation={
<Score
count={numVotes}
highlight={numVotes === highScore}
/>
}
/>
</div>
<div>
{parsedOption.type === "timeSlot" ? (
<TimeRange
className="mt-2 -mr-2"
startTime={parsedOption.startTime}
endTime={parsedOption.endTime}
/>
) : null}
</div>
</div>
);
})}
</ControlledScrollDiv>
<div
className="flex items-center py-3 px-2"
style={{ width: actionColumnWidth }}
>
<TransitionPopInOut show={scrollPosition < maxScrollPosition}>
<Button
className="text-xs"
rounded={true}
onClick={() => {
setDidUsePagination(true);
goToNextPage();
}}
>
{didUsePagination ? (
<ArrowRight className="w-4 h-4" />
) : (
`+${numberOfInvisibleColumns} more…`
)}
</Button>
</TransitionPopInOut>
</div>
</div>
{shouldShowNewParticipantForm ? (
<ParticipantRowForm
className="border-t bg-slate-100 bg-opacity-0"
onSubmit={(data) => {
return new Promise((resolve, reject) => {
addParticipant(data, {
onSuccess: () => {
setShouldShowNewParticipantForm(false);
resolve();
},
onError: reject,
});
});
}}
options={options}
onCancel={() => {
setShouldShowNewParticipantForm(false);
}}
/>
) : null}
</div>
<div className="min-h-0 overflow-y-auto">
{participants.map((participant, i) => {
return (
<ParticipantRow
urlId={pollId}
key={i}
participant={participant}
options={options}
canDelete={role === "admin"}
editMode={editingParticipantId === participant.id}
onChangeEditMode={(isEditing) => {
setEditingParticipantId(isEditing ? participant.id : null);
}}
/>
);
})}
</div>
</div>
</div>
</PollContext.Provider>
);
};
export default React.memo(Poll);

34
components/poll/score.tsx Normal file
View file

@ -0,0 +1,34 @@
import clsx from "clsx";
import * as React from "react";
export interface ScoreProps {
count: number;
highlight?: boolean;
style?: React.CSSProperties;
className?: string;
}
const Score: React.VoidFunctionComponent<ScoreProps> = ({
count,
highlight,
style,
className,
}) => {
return (
<div
className={clsx(
" rounded-full text-xs w-5 h-5 flex justify-center items-center shadow-slate-200 shadow-sm transition-colors z-20",
{
"bg-rose-500 text-white": highlight,
"bg-slate-200 text-slate-500": !highlight,
},
className,
)}
style={style}
>
{count}
</div>
);
};
export default Score;

View file

@ -0,0 +1,28 @@
import clsx from "clsx";
import * as React from "react";
export interface TimeRangeProps {
startTime: string;
endTime: string;
className?: string;
}
const TimeRange: React.VoidFunctionComponent<TimeRangeProps> = ({
startTime,
endTime,
className,
}) => {
return (
<div
className={clsx(
"inline-block font-mono text-xs text-right pr-2 relative after:content-[''] after:absolute after:w-1 after:h-4 after:border-t after:border-r after:border-b after:border-slate-300 after:top-2 after:right-0",
className,
)}
>
<div>{startTime}</div>
<div>{endTime}</div>
</div>
);
};
export default TimeRange;

17
components/poll/types.ts Normal file
View file

@ -0,0 +1,17 @@
import { Option, Participant, Role, Vote } from "@prisma/client";
export interface ParticipantForm {
name: string;
votes: string[];
}
export interface PollProps {
pollId: string;
role: Role | "readOnly";
timeZone: string | null;
options: Array<Option & { votes: Vote[] }>;
participants: Array<Participant & { votes: Vote[] }>;
highScore: number;
initialName?: string;
onChangeTargetTimeZone: (timeZone: string) => void;
targetTimeZone: string;
}

View file

@ -0,0 +1,25 @@
import { useModal } from "../modal";
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",
});
};

View file

@ -0,0 +1,109 @@
import clsx from "clsx";
import * as React from "react";
import { stringToValue } from "utils/string-to-value";
export interface UserAvaterProps {
name: string;
className?: string;
size?: "default" | "large";
color?: string;
}
const UserAvatarContext =
React.createContext<((name: string) => string) | null>(null);
const colors = [
"bg-fuchsia-300",
"bg-purple-400",
"bg-indigo-400",
"bg-blue-400",
"bg-sky-400",
"bg-cyan-400",
"bg-teal-300",
"bg-emerald-300",
"bg-emerald-400",
"bg-teal-400",
"bg-cyan-400",
"bg-sky-400",
"bg-blue-400",
"bg-indigo-400",
"bg-purple-400",
"bg-fuchsia-400",
"bg-pink-400",
];
const defaultColor = "bg-slate-400";
export const UserAvatarProvider: React.VoidFunctionComponent<{
children?: React.ReactNode;
names: string[];
seed: string;
}> = ({ seed, children, names }) => {
const seedValue = React.useMemo(() => stringToValue(seed), [seed]);
const colorByName = React.useMemo(() => {
const res = {
"": defaultColor,
};
for (let i = 0; i < names.length; i++) {
const lastIndex = names.length - 1;
// start from the end since the names is "most recent" first.
const name = names[lastIndex - i].trim().toLowerCase();
const color = colors[(seedValue + i) % colors.length];
res[name] = color;
}
return res;
}, [names, seedValue]);
const getColor = React.useCallback(
(name: string) => {
const cachedColor = colorByName[name.toLowerCase()];
if (cachedColor) {
return cachedColor;
}
return defaultColor;
},
[colorByName],
);
return (
<UserAvatarContext.Provider value={getColor}>
{children}
</UserAvatarContext.Provider>
);
};
const UserAvater: React.VoidFunctionComponent<UserAvaterProps> = ({
name,
className,
color: colorOverride,
size = "default",
}) => {
const trimmedName = name.trim();
const getColor = React.useContext(UserAvatarContext);
if (!getColor) {
throw new Error("Forgot to wrap UserAvatarProvider");
}
const color = colorOverride ?? getColor(trimmedName);
return (
<span
className={clsx(
"inline-block w-5 h-5 text-white rounded-full shrink-0 text-center",
color,
{
"w-5 h-5 text-xs leading-5": size === "default",
"w-10 h-10 leading-10": size === "large",
},
className,
)}
title={name}
>
{trimmedName[0]?.toUpperCase()}
</span>
);
};
export default UserAvater;

View file

@ -0,0 +1,14 @@
import * as React from "react";
import CheckCircle from "@/components/icons/check-circle.svg";
const VoteIcon: React.VoidFunctionComponent<{
type: "yes" | "no";
}> = ({ type }) => {
if (type === "yes") {
return <CheckCircle className="h-5 w-5 text-green-400" />;
}
return <span className="inline-block bg-slate-300 w-2 h-2 rounded-full" />;
};
export default VoteIcon;