mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-28 00:06:27 +02:00
First public commit
This commit is contained in:
commit
e05cd62e53
228 changed files with 17717 additions and 0 deletions
1
components/poll/index.ts
Normal file
1
components/poll/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default } from "./poll";
|
58
components/poll/legacy-poll-notice.tsx
Normal file
58
components/poll/legacy-poll-notice.tsx
Normal 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'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;
|
210
components/poll/manage-poll.tsx
Normal file
210
components/poll/manage-poll.tsx
Normal 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;
|
1
components/poll/mobile-poll/index.ts
Normal file
1
components/poll/mobile-poll/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default } from "./mobile-poll";
|
334
components/poll/mobile-poll/mobile-poll.tsx
Normal file
334
components/poll/mobile-poll/mobile-poll.tsx
Normal 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;
|
115
components/poll/mutations.ts
Normal file
115
components/poll/mutations.ts
Normal 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");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
82
components/poll/notifications-toggle.tsx
Normal file
82
components/poll/notifications-toggle.tsx
Normal 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;
|
178
components/poll/participant-row-form.tsx
Normal file
178
components/poll/participant-row-form.tsx
Normal 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;
|
148
components/poll/participant-row.tsx
Normal file
148
components/poll/participant-row.tsx
Normal 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;
|
30
components/poll/poll-context.ts
Normal file
30
components/poll/poll-context.ts
Normal 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);
|
67
components/poll/poll-subheader.tsx
Normal file
67
components/poll/poll-subheader.tsx
Normal 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" />,
|
||||
}}
|
||||
/>
|
||||
|
||||
{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"> • </span>
|
||||
<span className="whitespace-nowrap">
|
||||
{formatRelative(new Date(poll.createdAt), new Date())}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollSubheader;
|
339
components/poll/poll.tsx
Normal file
339
components/poll/poll.tsx
Normal 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
34
components/poll/score.tsx
Normal 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;
|
28
components/poll/time-range.tsx
Normal file
28
components/poll/time-range.tsx
Normal 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
17
components/poll/types.ts
Normal 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;
|
||||
}
|
25
components/poll/use-delete-participant-modal.ts
Normal file
25
components/poll/use-delete-participant-modal.ts
Normal 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",
|
||||
});
|
||||
};
|
109
components/poll/user-avatar.tsx
Normal file
109
components/poll/user-avatar.tsx
Normal 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;
|
14
components/poll/vote-icon.tsx
Normal file
14
components/poll/vote-icon.tsx
Normal 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;
|
Loading…
Add table
Add a link
Reference in a new issue