mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-05 12:11:51 +02:00
If need be (#168)
This commit is contained in:
parent
6375e80641
commit
17dc9519d2
48 changed files with 1033 additions and 642 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -40,3 +40,6 @@ yarn-error.log*
|
|||
# playwright
|
||||
/playwright-report
|
||||
/test-results
|
||||
|
||||
# ts
|
||||
tsconfig.tsbuildinfo
|
|
@ -1,10 +1,10 @@
|
|||
import { Participant, Vote } from "@prisma/client";
|
||||
import { Participant, Vote, VoteType } from "@prisma/client";
|
||||
import axios from "axios";
|
||||
|
||||
export interface AddParticipantPayload {
|
||||
pollId: string;
|
||||
name: string;
|
||||
votes: string[];
|
||||
votes: Array<{ optionId: string; type: VoteType }>;
|
||||
}
|
||||
|
||||
export type AddParticipantResponse = Participant & {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Link, Option, Participant, Poll, User, Vote } from "@prisma/client";
|
||||
|
||||
export interface GetPollApiResponse extends Omit<Poll, "verificationCode"> {
|
||||
options: Array<Option & { votes: Vote[] }>;
|
||||
options: Option[];
|
||||
participants: Array<Participant & { votes: Vote[] }>;
|
||||
user: User;
|
||||
role: "admin" | "participant";
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
import { Participant, Vote, VoteType } from "@prisma/client";
|
||||
import axios from "axios";
|
||||
|
||||
export interface UpdateParticipantPayload {
|
||||
pollId: string;
|
||||
participantId: string;
|
||||
name: string;
|
||||
votes: string[];
|
||||
votes: Array<{ optionId: string; type: VoteType }>;
|
||||
}
|
||||
|
||||
export const updateParticipant = async (
|
||||
payload: UpdateParticipantPayload,
|
||||
): Promise<void> => {
|
||||
): Promise<Participant & { votes: Vote[] }> => {
|
||||
const { pollId, participantId, ...body } = payload;
|
||||
await axios.patch(`/api/poll/${pollId}/participant/${participantId}`, body);
|
||||
const res = await axios.patch<Participant & { votes: Vote[] }>(
|
||||
`/api/poll/${pollId}/participant/${participantId}`,
|
||||
body,
|
||||
);
|
||||
return res.data;
|
||||
};
|
||||
|
|
|
@ -19,7 +19,7 @@ const DateCard: React.VoidFunctionComponent<DateCardProps> = ({
|
|||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"relative inline-block h-14 w-14 rounded-md border bg-white text-center shadow-md shadow-slate-100",
|
||||
"relative mt-1 inline-block h-14 w-14 rounded-md border bg-white text-center shadow-md shadow-slate-100",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
@ -31,7 +31,7 @@ const DateCard: React.VoidFunctionComponent<DateCardProps> = ({
|
|||
{dow}
|
||||
</span>
|
||||
</div>
|
||||
<div className="-mb-1 text-center text-lg text-red-500">{day}</div>
|
||||
<div className="-mb-1 text-center text-lg text-rose-500">{day}</div>
|
||||
<div className="text-center text-xs font-semibold uppercase text-gray-800">
|
||||
{month}
|
||||
</div>
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { format } from "date-fns";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
import { useTimeoutFn } from "react-use";
|
||||
|
||||
import DateCard from "../date-card";
|
||||
import Score from "../poll/desktop-poll/score";
|
||||
import { ScoreSummary } from "../poll/score-summary";
|
||||
import UserAvatar from "../poll/user-avatar";
|
||||
import VoteIcon from "../poll/vote-icon";
|
||||
|
||||
|
@ -36,10 +35,6 @@ const options = ["2022-12-14", "2022-12-15", "2022-12-16", "2022-12-17"];
|
|||
|
||||
const PollDemo: React.VoidFunctionComponent = () => {
|
||||
const { t } = useTranslation("app");
|
||||
const [bestOption, setBestOption] = React.useState<number>();
|
||||
useTimeoutFn(() => {
|
||||
setBestOption(2);
|
||||
}, 1500);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -48,7 +43,7 @@ const PollDemo: React.VoidFunctionComponent = () => {
|
|||
>
|
||||
<div className="flex border-b shadow-sm">
|
||||
<div
|
||||
className="flex shrink-0 items-center py-4 pl-4 pr-2 font-medium"
|
||||
className="flex shrink-0 items-center py-2 pl-4 pr-2 font-medium"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<div className="flex h-full grow items-end">
|
||||
|
@ -66,17 +61,17 @@ const PollDemo: React.VoidFunctionComponent = () => {
|
|||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="shrink-0 py-4 text-center transition-colors"
|
||||
className="shrink-0 space-y-3 py-2 pt-3 text-center transition-colors"
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<DateCard
|
||||
day={format(d, "dd")}
|
||||
dow={format(d, "E")}
|
||||
month={format(d, "MMM")}
|
||||
annotation={
|
||||
<Score count={score} highlight={i === bestOption} />
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<ScoreSummary yesScore={score} compact={true} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
3
components/icons/fire.svg
Normal file
3
components/icons/fire.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z" clip-rule="evenodd" />
|
||||
</svg>
|
After Width: | Height: | Size: 614 B |
3
components/icons/if-need-be.svg
Normal file
3
components/icons/if-need-be.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M3.433 2.099a1 1 0 0 1 .468 1.334C2.986 5.34 2.57 6.704 2.561 8.007c-.008 1.298.387 2.655 1.333 4.546a1 1 0 0 1-1.788.895C1.095 11.428.55 9.742.56 7.994c.012-1.74.575-3.422 1.538-5.427A1 1 0 0 1 3.433 2.1Zm8.274 3.194a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L7 8.586l3.293-3.293a1 1 0 0 1 1.414 0Zm.392-1.86a1 1 0 1 1 1.803-.866c.963 2.005 1.526 3.686 1.537 5.427.011 1.748-.533 3.434-1.545 5.454a1 1 0 0 1-1.788-.896c.947-1.89 1.341-3.247 1.333-4.545-.008-1.303-.424-2.667-1.34-4.574Z" clip-rule="evenodd" />
|
||||
</svg>
|
After Width: | Height: | Size: 655 B |
3
components/icons/star.svg
Normal file
3
components/icons/star.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
After Width: | Height: | Size: 463 B |
3
components/icons/user-solid.svg
Normal file
3
components/icons/user-solid.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM6 9a3 3 0 0 0-3 3v1a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1a3 3 0 0 0-3-3H6Z" clip-rule="evenodd" />
|
||||
</svg>
|
After Width: | Height: | Size: 246 B |
|
@ -1,3 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
Before Width: | Height: | Size: 265 B After Width: | Height: | Size: 374 B |
|
@ -1,4 +1,4 @@
|
|||
import { Participant, Vote } from "@prisma/client";
|
||||
import { Participant, Vote, VoteType } from "@prisma/client";
|
||||
import { GetPollResponse } from "api-client/get-poll";
|
||||
import { keyBy } from "lodash";
|
||||
import React from "react";
|
||||
|
@ -13,8 +13,6 @@ import { usePreferences } from "./preferences/use-preferences";
|
|||
import { useSession } from "./session";
|
||||
import { useRequiredContext } from "./use-required-context";
|
||||
|
||||
type VoteType = "yes" | "no";
|
||||
|
||||
type PollContextValue = {
|
||||
userAlreadyVoted: boolean;
|
||||
poll: GetPollResponse;
|
||||
|
@ -23,10 +21,11 @@ type PollContextValue = {
|
|||
pollType: "date" | "timeSlot";
|
||||
highScore: number;
|
||||
getParticipantsWhoVotedForOption: (optionId: string) => Participant[]; // maybe just attach votes to parsed options
|
||||
getScore: (optionId: string) => { yes: number; ifNeedBe: number };
|
||||
getParticipantById: (
|
||||
participantId: string,
|
||||
) => (Participant & { votes: Vote[] }) | undefined;
|
||||
getVote: (participantId: string, optionId: string) => VoteType;
|
||||
getVote: (participantId: string, optionId: string) => VoteType | undefined;
|
||||
} & (
|
||||
| { pollType: "date"; options: ParsedDateOption[] }
|
||||
| { pollType: "timeSlot"; options: ParsedTimeSlotOption[] }
|
||||
|
@ -57,21 +56,46 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
|||
const participantsByOptionId = React.useMemo(() => {
|
||||
const res: Record<string, Participant[]> = {};
|
||||
poll.options.forEach((option) => {
|
||||
res[option.id] = option.votes.map(
|
||||
({ participantId }) => participantById[participantId],
|
||||
res[option.id] = poll.participants.filter((participant) =>
|
||||
participant.votes.some(
|
||||
({ type, optionId }) => optionId === option.id && type !== "no",
|
||||
),
|
||||
);
|
||||
});
|
||||
return res;
|
||||
}, [participantById, poll.options]);
|
||||
}, [poll.options, poll.participants]);
|
||||
|
||||
const { locale } = usePreferences();
|
||||
|
||||
const contextValue = React.useMemo<PollContextValue>(() => {
|
||||
let highScore = 1;
|
||||
poll.options.forEach((option) => {
|
||||
if (option.votes.length > highScore) {
|
||||
highScore = option.votes.length;
|
||||
const getScore = React.useCallback(
|
||||
(optionId: string) => {
|
||||
return poll.participants.reduce(
|
||||
(acc, curr) => {
|
||||
curr.votes.forEach((vote) => {
|
||||
if (vote.optionId !== optionId) {
|
||||
return;
|
||||
}
|
||||
if (vote.type === "yes") {
|
||||
acc.yes += 1;
|
||||
}
|
||||
if (vote.type === "ifNeedBe") {
|
||||
acc.ifNeedBe += 1;
|
||||
}
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{ yes: 0, ifNeedBe: 0 },
|
||||
);
|
||||
},
|
||||
[poll.participants],
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo<PollContextValue>(() => {
|
||||
const highScore = poll.options.reduce((acc, curr) => {
|
||||
const score = getScore(curr.id).yes;
|
||||
|
||||
return score > acc ? score : acc;
|
||||
}, 1);
|
||||
|
||||
const parsedOptions = decodeOptions(
|
||||
poll.options,
|
||||
|
@ -99,11 +123,6 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
|||
return {
|
||||
userAlreadyVoted,
|
||||
poll,
|
||||
getVotesForOption: (optionId: string) => {
|
||||
// TODO (Luke Vella) [2022-04-16]: Build an index instead
|
||||
const option = poll.options.find(({ id }) => id === optionId);
|
||||
return option?.votes ?? [];
|
||||
},
|
||||
getParticipantById: (participantId) => {
|
||||
return participantById[participantId];
|
||||
},
|
||||
|
@ -111,17 +130,18 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
|||
getParticipantsWhoVotedForOption: (optionId: string) =>
|
||||
participantsByOptionId[optionId],
|
||||
getVote: (participantId, optionId) => {
|
||||
return getParticipantById(participantId)?.votes.some(
|
||||
const vote = getParticipantById(participantId)?.votes.find(
|
||||
(vote) => vote.optionId === optionId,
|
||||
)
|
||||
? "yes"
|
||||
: "no";
|
||||
);
|
||||
return vote?.type;
|
||||
},
|
||||
getScore,
|
||||
...parsedOptions,
|
||||
targetTimeZone,
|
||||
setTargetTimeZone,
|
||||
};
|
||||
}, [
|
||||
getScore,
|
||||
locale,
|
||||
participantById,
|
||||
participantsByOptionId,
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { GetPollResponse } from "api-client/get-poll";
|
||||
import axios from "axios";
|
||||
import { NextPage } from "next";
|
||||
import Head from "next/head";
|
||||
|
@ -21,7 +20,8 @@ import NotificationsToggle from "./poll/notifications-toggle";
|
|||
import PollSubheader from "./poll/poll-subheader";
|
||||
import TruncatedLinkify from "./poll/truncated-linkify";
|
||||
import { UserAvatarProvider } from "./poll/user-avatar";
|
||||
import { PollContextProvider, usePoll } from "./poll-context";
|
||||
import VoteIcon from "./poll/vote-icon";
|
||||
import { usePoll } from "./poll-context";
|
||||
import Popover from "./popover";
|
||||
import { useSession } from "./session";
|
||||
import Sharing from "./sharing";
|
||||
|
@ -32,7 +32,7 @@ const Discussion = React.lazy(() => import("@/components/discussion"));
|
|||
const DesktopPoll = React.lazy(() => import("@/components/poll/desktop-poll"));
|
||||
const MobilePoll = React.lazy(() => import("@/components/poll/mobile-poll"));
|
||||
|
||||
const PollInner: NextPage = () => {
|
||||
const PollPage: NextPage = () => {
|
||||
const { poll } = usePoll();
|
||||
|
||||
const router = useRouter();
|
||||
|
@ -121,13 +121,6 @@ const PollInner: NextPage = () => {
|
|||
|
||||
const PollComponent = isWideScreen ? DesktopPoll : MobilePoll;
|
||||
|
||||
let highScore = 1; // set to one because we don't want to highlight
|
||||
poll.options.forEach((option) => {
|
||||
if (option.votes.length > highScore) {
|
||||
highScore = option.votes.length;
|
||||
}
|
||||
});
|
||||
|
||||
const names = React.useMemo(
|
||||
() => poll.participants.map(({ name }) => name),
|
||||
[poll.participants],
|
||||
|
@ -210,9 +203,28 @@ const PollInner: NextPage = () => {
|
|||
This poll has been locked (voting is disabled)
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center space-x-3 px-4 py-2 sm:justify-end">
|
||||
<span className="text-xs font-semibold text-slate-500">
|
||||
Legend:
|
||||
</span>
|
||||
<span className="inline-flex items-center space-x-2">
|
||||
<VoteIcon type="yes" />
|
||||
<span className="text-xs text-slate-500">Yes</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center space-x-2">
|
||||
<VoteIcon type="ifNeedBe" />
|
||||
<span className="text-xs text-slate-500">If need be</span>
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center space-x-2">
|
||||
<VoteIcon type="no" />
|
||||
<span className="text-xs text-slate-500">No</span>
|
||||
</span>
|
||||
</div>
|
||||
<React.Suspense fallback={<div>Loading…</div>}>
|
||||
<div className="mb-4 lg:mb-8">
|
||||
<PollComponent pollId={poll.urlId} highScore={highScore} />
|
||||
<PollComponent pollId={poll.urlId} />
|
||||
</div>
|
||||
<Discussion pollId={poll.urlId} />
|
||||
</React.Suspense>
|
||||
|
@ -223,12 +235,4 @@ const PollInner: NextPage = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const PollPage = ({ poll }: { poll: GetPollResponse }) => {
|
||||
return (
|
||||
<PollContextProvider value={poll}>
|
||||
<PollInner />
|
||||
</PollContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollPage;
|
||||
|
|
|
@ -23,6 +23,8 @@ if (typeof window !== "undefined") {
|
|||
|
||||
const MotionButton = motion(Button);
|
||||
|
||||
const MotionParticipantFormRow = motion(ParticipantRowForm);
|
||||
|
||||
const minSidebarWidth = 180;
|
||||
|
||||
const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||
|
@ -111,7 +113,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
}}
|
||||
>
|
||||
<div
|
||||
className="relative min-w-full max-w-full" // Don't add styles like border, margin, padding – that can mess up the sizing calculations
|
||||
className="relative min-w-full max-w-full select-none" // 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}
|
||||
>
|
||||
|
@ -131,9 +133,9 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
</div>
|
||||
) : null}
|
||||
<div className="flex shrink-0">
|
||||
{!shouldShowNewParticipantForm && !poll.closed ? (
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={shouldShowNewParticipantForm || poll.closed}
|
||||
icon={<PlusCircle />}
|
||||
onClick={() => {
|
||||
setShouldShowNewParticipantForm(true);
|
||||
|
@ -141,12 +143,11 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
>
|
||||
New Participant
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div
|
||||
className="flex shrink-0 items-center py-4 pl-4 pr-2 font-medium"
|
||||
className="flex shrink-0 items-center py-2 pl-4 pr-2 font-medium"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<div className="flex h-full grow items-end">
|
||||
|
@ -198,8 +199,12 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{shouldShowNewParticipantForm && !poll.closed ? (
|
||||
<ParticipantRowForm
|
||||
<MotionParticipantFormRow
|
||||
transition={{ duration: 0.2 }}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 55, y: 0 }}
|
||||
className="border-t bg-slate-100 bg-opacity-0"
|
||||
onSubmit={(data) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
@ -218,6 +223,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto">
|
||||
{participants.map((participant, i) => {
|
||||
|
|
|
@ -4,13 +4,14 @@ import * as React from "react";
|
|||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import CompactButton from "@/components/compact-button";
|
||||
import Check from "@/components/icons/check.svg";
|
||||
import X from "@/components/icons/x.svg";
|
||||
import { useSession } from "@/components/session";
|
||||
|
||||
import { requiredString } from "../../../utils/form-validation";
|
||||
import Button from "../../button";
|
||||
import NameInput from "../../name-input";
|
||||
import { ParticipantForm } from "../types";
|
||||
import { VoteSelector } from "../vote-selector";
|
||||
import ControlledScrollArea from "./controlled-scroll-area";
|
||||
import { usePollContext } from "./poll-context";
|
||||
|
||||
|
@ -22,8 +23,10 @@ export interface ParticipantRowFormProps {
|
|||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
||||
({ defaultValues, onSubmit, className, options, onCancel }) => {
|
||||
const ParticipantRowForm: React.ForwardRefRenderFunction<
|
||||
HTMLFormElement,
|
||||
ParticipantRowFormProps
|
||||
> = ({ defaultValues, onSubmit, className, options, onCancel }, ref) => {
|
||||
const {
|
||||
setActiveOptionId,
|
||||
activeOptionId,
|
||||
|
@ -35,11 +38,8 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
|||
setScrollPosition,
|
||||
} = usePollContext();
|
||||
|
||||
const session = useSession();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
control,
|
||||
formState: { errors, submitCount, isSubmitting },
|
||||
reset,
|
||||
|
@ -60,39 +60,24 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
|||
}, [onCancel]);
|
||||
|
||||
const isColumnVisible = (index: number) => {
|
||||
return (
|
||||
scrollPosition + numberOfColumns * columnWidth > columnWidth * index
|
||||
);
|
||||
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} />
|
||||
);
|
||||
const checkboxRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={ref}
|
||||
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],
|
||||
// no need to create votes for "no"
|
||||
votes,
|
||||
});
|
||||
reset();
|
||||
})}
|
||||
className={clsx("flex h-14 shrink-0", className)}
|
||||
>
|
||||
{checkboxGroupHack}
|
||||
<div className="flex items-center px-2" style={{ width: sidebarWidth }}>
|
||||
<Controller
|
||||
name="name"
|
||||
|
@ -102,10 +87,9 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
|||
render={({ field }) => (
|
||||
<div className="w-full">
|
||||
<NameInput
|
||||
autoFocus={!session.user}
|
||||
autoFocus={true}
|
||||
className={clsx("w-full", {
|
||||
"input-error animate-wiggle":
|
||||
errors.name && submitCount > 0,
|
||||
"input-error animate-wiggle": errors.name && submitCount > 0,
|
||||
})}
|
||||
placeholder="Your name"
|
||||
{...field}
|
||||
|
@ -114,7 +98,7 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
|||
e.preventDefault();
|
||||
setScrollPosition(0);
|
||||
setTimeout(() => {
|
||||
checkboxRefs.current[0].focus();
|
||||
checkboxRefs.current[0]?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
|
@ -124,49 +108,50 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
|||
control={control}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="votes"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<ControlledScrollArea>
|
||||
{options.map((option, index) => {
|
||||
const value = field.value[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className={clsx(
|
||||
"flex shrink-0 items-center justify-center transition-colors",
|
||||
{
|
||||
"bg-slate-50": activeOptionId === option.id,
|
||||
"bg-gray-50": activeOptionId === option.id,
|
||||
},
|
||||
)}
|
||||
style={{ width: columnWidth }}
|
||||
onMouseOver={() => setActiveOptionId(option.id)}
|
||||
onMouseOut={() => setActiveOptionId(null)}
|
||||
>
|
||||
<input
|
||||
className="checkbox"
|
||||
type="checkbox"
|
||||
value={option.id}
|
||||
<VoteSelector
|
||||
value={value?.type}
|
||||
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;
|
||||
checkboxRefs.current[index + 1]?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
{...checkboxProps}
|
||||
onChange={(vote) => {
|
||||
const newValue = [...field.value];
|
||||
newValue[index] = { optionId: option.id, type: vote };
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
checkboxRefs.current[index] = el;
|
||||
checkboxProps.ref(el);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
setActiveOptionId(option.id);
|
||||
|
@ -176,9 +161,14 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
|||
);
|
||||
})}
|
||||
</ControlledScrollArea>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-2 px-2 transition-all">
|
||||
<Button
|
||||
htmlType="submit"
|
||||
icon={<Check />}
|
||||
type="primary"
|
||||
loading={isSubmitting}
|
||||
data-testid="submitNewParticipant"
|
||||
|
@ -191,4 +181,4 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
|||
);
|
||||
};
|
||||
|
||||
export default ParticipantRowForm;
|
||||
export default React.forwardRef(ParticipantRowForm);
|
||||
|
|
|
@ -2,7 +2,6 @@ import { Option, Participant, Vote } from "@prisma/client";
|
|||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
import Badge from "@/components/badge";
|
||||
import CompactButton from "@/components/compact-button";
|
||||
import Pencil from "@/components/icons/pencil-alt.svg";
|
||||
import Trash from "@/components/icons/trash.svg";
|
||||
|
@ -20,7 +19,7 @@ import { usePollContext } from "./poll-context";
|
|||
export interface ParticipantRowProps {
|
||||
urlId: string;
|
||||
participant: Participant & { votes: Vote[] };
|
||||
options: Array<Option & { votes: Vote[] }>;
|
||||
options: Option[];
|
||||
editMode: boolean;
|
||||
onChangeEditMode?: (value: boolean) => void;
|
||||
}
|
||||
|
@ -41,7 +40,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
const confirmDeleteParticipant = useDeleteParticipantModal();
|
||||
|
||||
const session = useSession();
|
||||
const { poll } = usePoll();
|
||||
const { poll, getVote } = usePoll();
|
||||
|
||||
const isYou = session.user && session.ownsObject(participant) ? true : false;
|
||||
|
||||
|
@ -55,13 +54,15 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
<ParticipantRowForm
|
||||
defaultValues={{
|
||||
name: participant.name,
|
||||
votes: participant.votes.map(({ optionId }) => optionId),
|
||||
votes: options.map(({ id }) => {
|
||||
const type = getVote(participant.id, id);
|
||||
return type ? { optionId: id, type } : undefined;
|
||||
}),
|
||||
}}
|
||||
onSubmit={async ({ name, votes }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
updateParticipantMutation(
|
||||
{
|
||||
pollId: participant.pollId,
|
||||
participantId: participant.id,
|
||||
votes,
|
||||
name,
|
||||
|
@ -86,7 +87,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
<div
|
||||
key={participant.id}
|
||||
data-testid="participant-row"
|
||||
className="group flex h-14 transition-colors hover:bg-slate-50"
|
||||
className="group flex h-14 transition-colors hover:bg-slate-300/10"
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center px-4"
|
||||
|
@ -99,7 +100,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
isYou={isYou}
|
||||
/>
|
||||
{canEdit ? (
|
||||
<div className="hidden shrink-0 items-center space-x-2 overflow-hidden px-2 group-hover:flex">
|
||||
<div className="hidden shrink-0 items-center space-x-2 overflow-hidden group-hover:flex">
|
||||
<CompactButton
|
||||
icon={Pencil}
|
||||
onClick={() => {
|
||||
|
@ -117,26 +118,21 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
|||
</div>
|
||||
<ControlledScrollArea>
|
||||
{options.map((option) => {
|
||||
const vote = getVote(participant.id, option.id);
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className={clsx(
|
||||
"flex shrink-0 items-center justify-center transition-colors",
|
||||
{
|
||||
"bg-slate-50": activeOptionId === option.id,
|
||||
"bg-gray-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" />
|
||||
)}
|
||||
<VoteIcon type={vote} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -4,9 +4,9 @@ import * as React from "react";
|
|||
import DateCard from "@/components/date-card";
|
||||
import { usePoll } from "@/components/poll-context";
|
||||
|
||||
import { ScoreSummary } from "../score-summary";
|
||||
import ControlledScrollArea from "./controlled-scroll-area";
|
||||
import { usePollContext } from "./poll-context";
|
||||
import Score from "./score";
|
||||
|
||||
const TimeRange: React.VoidFunctionComponent<{
|
||||
startTime: string;
|
||||
|
@ -16,7 +16,7 @@ const TimeRange: React.VoidFunctionComponent<{
|
|||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"relative inline-block pr-2 text-right text-xs font-semibold after:absolute after:top-2 after:right-0 after:h-4 after:w-1 after:border-t after:border-r after:border-b after:border-slate-300 after:content-['']",
|
||||
"relative -mr-2 inline-block pr-2 text-right text-xs font-semibold after:absolute after:top-2 after:right-0 after:h-4 after:w-1 after:border-t after:border-r after:border-b after:border-slate-300 after:content-['']",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
@ -27,21 +27,21 @@ const TimeRange: React.VoidFunctionComponent<{
|
|||
};
|
||||
|
||||
const PollHeader: React.VoidFunctionComponent = () => {
|
||||
const { options, getParticipantsWhoVotedForOption, highScore } = usePoll();
|
||||
const { options, getScore } = usePoll();
|
||||
const { activeOptionId, setActiveOptionId, columnWidth } = usePollContext();
|
||||
|
||||
return (
|
||||
<ControlledScrollArea>
|
||||
{options.map((option) => {
|
||||
const { optionId } = option;
|
||||
const numVotes = getParticipantsWhoVotedForOption(optionId).length;
|
||||
const numVotes = getScore(optionId);
|
||||
return (
|
||||
<div
|
||||
key={optionId}
|
||||
className={clsx(
|
||||
"shrink-0 pt-4 pb-3 text-center transition-colors",
|
||||
"shrink-0 space-y-3 py-3 text-center transition-colors",
|
||||
{
|
||||
"bg-slate-50": activeOptionId === optionId,
|
||||
"bg-gray-50": activeOptionId === optionId,
|
||||
},
|
||||
)}
|
||||
style={{ width: columnWidth }}
|
||||
|
@ -53,14 +53,6 @@ const PollHeader: React.VoidFunctionComponent = () => {
|
|||
day={option.day}
|
||||
dow={option.dow}
|
||||
month={option.month}
|
||||
annotation={
|
||||
numVotes > 0 ? (
|
||||
<Score
|
||||
count={numVotes}
|
||||
highlight={numVotes > 1 && highScore === numVotes}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{option.type === "timeSlot" ? (
|
||||
|
@ -70,6 +62,13 @@ const PollHeader: React.VoidFunctionComponent = () => {
|
|||
endTime={option.endTime}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex justify-center">
|
||||
<ScoreSummary
|
||||
yesScore={numVotes.yes}
|
||||
ifNeedBeScore={numVotes.ifNeedBe}
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
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(
|
||||
" z-20 flex h-5 w-5 items-center justify-center rounded-full text-xs shadow-sm shadow-slate-200 transition-colors",
|
||||
{
|
||||
"bg-rose-500 text-white": highlight,
|
||||
"bg-gray-200 text-gray-500": !highlight,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Score;
|
|
@ -1,5 +1,4 @@
|
|||
import { Placement } from "@floating-ui/react-dom-interactions";
|
||||
import { format } from "date-fns";
|
||||
import { Trans, useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
import { encodeDateOption } from "utils/date-time-utils";
|
||||
|
@ -17,6 +16,7 @@ import { PollDetailsForm } from "../forms";
|
|||
import { useModal } from "../modal";
|
||||
import { useModalContext } from "../modal/modal-provider";
|
||||
import { usePoll } from "../poll-context";
|
||||
import { useCsvExporter } from "./manage-poll/use-csv-exporter";
|
||||
import { useUpdatePollMutation } from "./mutations";
|
||||
|
||||
const PollOptionsForm = React.lazy(() => import("../forms/poll-options-form"));
|
||||
|
@ -25,7 +25,9 @@ const ManagePoll: React.VoidFunctionComponent<{
|
|||
placement?: Placement;
|
||||
}> = ({ placement }) => {
|
||||
const { t } = useTranslation("app");
|
||||
const { poll, options } = usePoll();
|
||||
const { poll, getParticipantsWhoVotedForOption } = usePoll();
|
||||
|
||||
const { exportToCsv } = useCsvExporter();
|
||||
|
||||
const modalContext = useModalContext();
|
||||
|
||||
|
@ -105,7 +107,8 @@ const ManagePoll: React.VoidFunctionComponent<{
|
|||
};
|
||||
|
||||
const optionsToDeleteThatHaveVotes = optionsToDelete.filter(
|
||||
(option) => option.votes.length > 0,
|
||||
(option) =>
|
||||
getParticipantsWhoVotedForOption(option.id).length > 0,
|
||||
);
|
||||
|
||||
if (optionsToDeleteThatHaveVotes.length > 0) {
|
||||
|
@ -181,55 +184,7 @@ const ManagePoll: React.VoidFunctionComponent<{
|
|||
label="Edit options"
|
||||
onClick={handleChangeOptions}
|
||||
/>
|
||||
<DropdownItem
|
||||
icon={Save}
|
||||
label="Export to CSV"
|
||||
onClick={() => {
|
||||
const header = [
|
||||
t("participantCount", {
|
||||
count: poll.participants.length,
|
||||
}),
|
||||
...options.map((decodedOption) => {
|
||||
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);
|
||||
const 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);
|
||||
}}
|
||||
/>
|
||||
<DropdownItem icon={Save} label="Export to CSV" onClick={exportToCsv} />
|
||||
{poll.closed ? (
|
||||
<DropdownItem
|
||||
icon={LockOpen}
|
||||
|
|
61
components/poll/manage-poll/use-csv-exporter.ts
Normal file
61
components/poll/manage-poll/use-csv-exporter.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { format } from "date-fns";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
import { usePoll } from "@/components/poll-context";
|
||||
|
||||
export const useCsvExporter = () => {
|
||||
const { poll, options } = usePoll();
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
return {
|
||||
exportToCsv: () => {
|
||||
const header = [
|
||||
t("participantCount", {
|
||||
count: poll.participants.length,
|
||||
}),
|
||||
...options.map((decodedOption) => {
|
||||
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) => {
|
||||
const vote = participant.votes.find((vote) => {
|
||||
return vote.optionId === option.id;
|
||||
});
|
||||
|
||||
switch (vote?.type) {
|
||||
case "yes":
|
||||
return t("yes");
|
||||
case "ifNeedBe":
|
||||
return t("ifNeedBe");
|
||||
default:
|
||||
return t("no");
|
||||
}
|
||||
}),
|
||||
].join(",");
|
||||
});
|
||||
const csv = `data:text/csv;charset=utf-8,${[header, ...rows].join(
|
||||
"\r\n",
|
||||
)}`;
|
||||
|
||||
const encodedCsv = encodeURI(csv);
|
||||
const 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);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -1,5 +1,4 @@
|
|||
import { Listbox } from "@headlessui/react";
|
||||
import { Participant, Vote } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
@ -7,6 +6,7 @@ import * as React from "react";
|
|||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import smoothscroll from "smoothscroll-polyfill";
|
||||
|
||||
import Check from "@/components/icons/check.svg";
|
||||
import ChevronDown from "@/components/icons/chevron-down.svg";
|
||||
import Pencil from "@/components/icons/pencil-alt.svg";
|
||||
import PlusCircle from "@/components/icons/plus-circle.svg";
|
||||
|
@ -37,17 +37,11 @@ if (typeof window !== "undefined") {
|
|||
const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||
const pollContext = usePoll();
|
||||
|
||||
const { poll, targetTimeZone, setTargetTimeZone } = pollContext;
|
||||
const { poll, targetTimeZone, setTargetTimeZone, getParticipantById } =
|
||||
pollContext;
|
||||
|
||||
const { timeZone, participants, role } = poll;
|
||||
|
||||
const participantById = participants.reduce<
|
||||
Record<string, Participant & { votes: Vote[] }>
|
||||
>((acc, curr) => {
|
||||
acc[curr.id] = { ...curr };
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const session = useSession();
|
||||
|
||||
const form = useForm<ParticipantForm>({
|
||||
|
@ -62,7 +56,7 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
React.useState<string>();
|
||||
|
||||
const selectedParticipant = selectedParticipantId
|
||||
? participantById[selectedParticipantId]
|
||||
? getParticipantById(selectedParticipantId)
|
||||
: undefined;
|
||||
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
|
@ -111,6 +105,7 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
|
@ -122,7 +117,6 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
updateParticipantMutation(
|
||||
{
|
||||
participantId: selectedParticipant.id,
|
||||
pollId,
|
||||
...data,
|
||||
},
|
||||
{
|
||||
|
@ -150,7 +144,9 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
<div className="flex space-x-3">
|
||||
<Listbox
|
||||
value={selectedParticipantId}
|
||||
onChange={setSelectedParticipantId}
|
||||
onChange={(participantId) => {
|
||||
setSelectedParticipantId(participantId);
|
||||
}}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<div className="menu min-w-0 grow">
|
||||
|
@ -217,9 +213,10 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
setIsEditing(true);
|
||||
reset({
|
||||
name: selectedParticipant.name,
|
||||
votes: selectedParticipant.votes.map(
|
||||
(vote) => vote.optionId,
|
||||
),
|
||||
votes: selectedParticipant.votes.map((vote) => ({
|
||||
optionId: vote.optionId,
|
||||
type: vote.type,
|
||||
})),
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@ -241,7 +238,10 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
type="primary"
|
||||
icon={<PlusCircle />}
|
||||
onClick={() => {
|
||||
reset({ name: "", votes: [] });
|
||||
reset({
|
||||
name: "",
|
||||
votes: [],
|
||||
});
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
|
@ -269,6 +269,8 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
</div>
|
||||
{(() => {
|
||||
switch (pollContext.pollType) {
|
||||
// we pass poll options as props since we are
|
||||
// discriminating on poll type here
|
||||
case "date":
|
||||
return (
|
||||
<PollOptions
|
||||
|
@ -347,7 +349,7 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
|||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<Save />}
|
||||
icon={<Check />}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
loading={formState.isSubmitting}
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
import { Participant } from "@prisma/client";
|
||||
import { Participant, VoteType } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import * as React from "react";
|
||||
|
||||
import { ScoreSummary } from "../score-summary";
|
||||
import UserAvatar from "../user-avatar";
|
||||
import VoteIcon from "../vote-icon";
|
||||
import PopularityScore from "./popularity-score";
|
||||
import { VoteSelector } from "../vote-selector";
|
||||
|
||||
export interface PollOptionProps {
|
||||
children?: React.ReactNode;
|
||||
numberOfVotes: number;
|
||||
yesScore: number;
|
||||
ifNeedBeScore: number;
|
||||
editable?: boolean;
|
||||
vote?: "yes" | "no";
|
||||
onChange: (vote: "yes" | "no") => void;
|
||||
vote?: VoteType;
|
||||
onChange: (vote: VoteType) => void;
|
||||
participants: Participant[];
|
||||
selectedParticipantId?: string;
|
||||
}
|
||||
|
@ -66,39 +68,31 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
|
|||
onChange,
|
||||
participants,
|
||||
editable,
|
||||
numberOfVotes,
|
||||
yesScore,
|
||||
ifNeedBeScore,
|
||||
}) => {
|
||||
const difference = selectedParticipantId
|
||||
? participants.some(({ id }) => id === selectedParticipantId)
|
||||
? vote === "yes"
|
||||
? 0
|
||||
: -1
|
||||
: vote === "yes"
|
||||
? 1
|
||||
: 0
|
||||
: vote === "yes"
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
const showVotes = !!(selectedParticipantId || editable);
|
||||
|
||||
const selectorRef = React.useRef<HTMLButtonElement>(null);
|
||||
return (
|
||||
<div
|
||||
data-testid="poll-option"
|
||||
onClick={() => {
|
||||
onChange(vote === "yes" ? "no" : "yes");
|
||||
if (selectorRef.current) {
|
||||
selectorRef.current.click();
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"flex items-center space-x-3 px-4 py-3 transition duration-75",
|
||||
"flex select-none items-center space-x-3 px-4 py-3 transition duration-75",
|
||||
{
|
||||
"active:bg-indigo-50": editable,
|
||||
"bg-indigo-50/50": editable && vote === "yes",
|
||||
"active:bg-slate-400/5": editable,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none flex grow items-center">
|
||||
<div className="flex grow items-center">
|
||||
<div className="grow">{children}</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<PopularityScore score={numberOfVotes + difference} />
|
||||
<ScoreSummary yesScore={yesScore} ifNeedBeScore={ifNeedBeScore} />
|
||||
{participants.length > 0 ? (
|
||||
<div className="mt-1 -mr-1">
|
||||
<div className="-space-x-1">
|
||||
|
@ -127,27 +121,20 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
|
|||
expanded={showVotes}
|
||||
className="relative flex h-12 items-center justify-center rounded-lg"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{editable ? (
|
||||
<PopInOut className="h-full">
|
||||
<div className="flex h-full w-14 items-center justify-center">
|
||||
<input
|
||||
readOnly={true}
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={vote === "yes"}
|
||||
/>
|
||||
<VoteSelector ref={selectorRef} value={vote} onChange={onChange} />
|
||||
</div>
|
||||
</PopInOut>
|
||||
) : vote ? (
|
||||
<AnimatePresence initial={false}>
|
||||
<PopInOut
|
||||
key={vote}
|
||||
className="absolute inset-0 flex h-full w-full items-center justify-center"
|
||||
>
|
||||
<VoteIcon type={vote} />
|
||||
</PopInOut>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
) : null}
|
||||
</CollapsibleContainer>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { VoteType } from "@prisma/client";
|
||||
import * as React from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { ParsedDateTimeOpton } from "utils/date-time-utils";
|
||||
|
@ -20,43 +21,39 @@ const PollOptions: React.VoidFunctionComponent<PollOptions> = ({
|
|||
selectedParticipantId,
|
||||
}) => {
|
||||
const { control } = useFormContext<ParticipantForm>();
|
||||
const { getParticipantsWhoVotedForOption, getVote, getParticipantById } =
|
||||
usePoll();
|
||||
const {
|
||||
getParticipantsWhoVotedForOption,
|
||||
getParticipantById,
|
||||
getScore,
|
||||
getVote,
|
||||
} = usePoll();
|
||||
const selectedParticipant = selectedParticipantId
|
||||
? getParticipantById(selectedParticipantId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="divide-y">
|
||||
{options.map((option) => {
|
||||
{options.map((option, index) => {
|
||||
const participants = getParticipantsWhoVotedForOption(option.optionId);
|
||||
const score = getScore(option.optionId);
|
||||
return (
|
||||
<Controller
|
||||
key={option.optionId}
|
||||
control={control}
|
||||
name="votes"
|
||||
render={({ field }) => {
|
||||
const vote = editable
|
||||
? field.value.includes(option.optionId)
|
||||
? "yes"
|
||||
: "no"
|
||||
: selectedParticipant
|
||||
const vote =
|
||||
!editable && selectedParticipant
|
||||
? getVote(selectedParticipant.id, option.optionId)
|
||||
: undefined;
|
||||
: field.value[index]?.type;
|
||||
|
||||
const handleChange = (newVote: "yes" | "no") => {
|
||||
const handleChange = (newVote: VoteType) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
if (newVote === "no") {
|
||||
field.onChange(
|
||||
field.value.filter(
|
||||
(optionId) => optionId !== option.optionId,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
field.onChange([...field.value, option.optionId]);
|
||||
}
|
||||
const newValue = [...field.value];
|
||||
newValue[index] = { optionId: option.optionId, type: newVote };
|
||||
field.onChange(newValue);
|
||||
};
|
||||
|
||||
switch (option.type) {
|
||||
|
@ -64,7 +61,8 @@ const PollOptions: React.VoidFunctionComponent<PollOptions> = ({
|
|||
return (
|
||||
<TimeSlotOption
|
||||
onChange={handleChange}
|
||||
numberOfVotes={participants.length}
|
||||
yesScore={score.yes}
|
||||
ifNeedBeScore={score.ifNeedBe}
|
||||
participants={participants}
|
||||
vote={vote}
|
||||
startTime={option.startTime}
|
||||
|
@ -78,7 +76,8 @@ const PollOptions: React.VoidFunctionComponent<PollOptions> = ({
|
|||
return (
|
||||
<DateOption
|
||||
onChange={handleChange}
|
||||
numberOfVotes={participants.length}
|
||||
yesScore={score.yes}
|
||||
ifNeedBeScore={score.ifNeedBe}
|
||||
participants={participants}
|
||||
vote={vote}
|
||||
dow={option.dow}
|
||||
|
|
|
@ -1,52 +0,0 @@
|
|||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { usePrevious } from "react-use";
|
||||
|
||||
import Check from "@/components/icons/check.svg";
|
||||
|
||||
export interface PopularityScoreProps {
|
||||
score: number;
|
||||
}
|
||||
|
||||
const PopularityScore: React.VoidFunctionComponent<PopularityScoreProps> = ({
|
||||
score,
|
||||
}) => {
|
||||
const prevScore = usePrevious(score);
|
||||
|
||||
const multiplier = prevScore !== undefined ? score - prevScore : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="popularity-score"
|
||||
className="inline-flex items-center font-mono text-sm font-semibold text-slate-500"
|
||||
>
|
||||
<Check className="mr-1 inline-block h-5 text-slate-400/80" />
|
||||
<span className="relative inline-block">
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.span
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: 10 * multiplier,
|
||||
}}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: 10 * multiplier,
|
||||
}}
|
||||
key={score}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
{score}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
{/* Invisible text just to give us the right width */}
|
||||
<span className="text-transparent">{score}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PopularityScore);
|
|
@ -8,10 +8,7 @@ import {
|
|||
DeleteParticipantPayload,
|
||||
} from "../../api-client/delete-participant";
|
||||
import { GetPollResponse } from "../../api-client/get-poll";
|
||||
import {
|
||||
updateParticipant,
|
||||
UpdateParticipantPayload,
|
||||
} from "../../api-client/update-participant";
|
||||
import { updateParticipant } from "../../api-client/update-participant";
|
||||
import { usePoll } from "../poll-context";
|
||||
import { useSession } from "../session";
|
||||
import { ParticipantForm } from "./types";
|
||||
|
@ -20,12 +17,17 @@ export const useAddParticipantMutation = (pollId: string) => {
|
|||
const queryClient = useQueryClient();
|
||||
const session = useSession();
|
||||
const plausible = usePlausible();
|
||||
const { options } = usePoll();
|
||||
|
||||
return useMutation(
|
||||
(payload: ParticipantForm) =>
|
||||
addParticipant({
|
||||
pollId,
|
||||
name: payload.name.trim(),
|
||||
votes: payload.votes,
|
||||
votes: options.map(
|
||||
(option, i) =>
|
||||
payload.votes[i] ?? { optionId: option.optionId, type: "no" },
|
||||
),
|
||||
}),
|
||||
{
|
||||
onSuccess: (participant) => {
|
||||
|
@ -39,15 +41,6 @@ export const useAddParticipantMutation = (pollId: string) => {
|
|||
);
|
||||
}
|
||||
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;
|
||||
},
|
||||
);
|
||||
|
@ -60,18 +53,38 @@ export const useAddParticipantMutation = (pollId: string) => {
|
|||
export const useUpdateParticipantMutation = (pollId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const plausible = usePlausible();
|
||||
const { options } = usePoll();
|
||||
|
||||
return useMutation(
|
||||
(payload: UpdateParticipantPayload) =>
|
||||
(payload: ParticipantForm & { participantId: string }) =>
|
||||
updateParticipant({
|
||||
pollId,
|
||||
participantId: payload.participantId,
|
||||
name: payload.name.trim(),
|
||||
votes: payload.votes,
|
||||
votes: options.map(
|
||||
(option, i) =>
|
||||
payload.votes[i] ?? { optionId: option.optionId, type: "no" },
|
||||
),
|
||||
}),
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: (participant) => {
|
||||
plausible("Update participant");
|
||||
queryClient.setQueryData<GetPollResponse>(
|
||||
["getPoll", pollId],
|
||||
(poll) => {
|
||||
if (!poll) {
|
||||
throw new Error(
|
||||
"Tried to update poll but no result found in query cache",
|
||||
);
|
||||
}
|
||||
|
||||
poll.participants = poll.participants.map((p) =>
|
||||
p.id === participant.id ? participant : p,
|
||||
);
|
||||
|
||||
return poll;
|
||||
},
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(["getPoll", pollId]);
|
||||
|
@ -83,9 +96,28 @@ export const useUpdateParticipantMutation = (pollId: string) => {
|
|||
export const useDeleteParticipantMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const plausible = usePlausible();
|
||||
const { poll } = usePoll();
|
||||
return useMutation(
|
||||
(payload: DeleteParticipantPayload) => deleteParticipant(payload),
|
||||
{
|
||||
onMutate: ({ participantId }) => {
|
||||
queryClient.setQueryData<GetPollResponse>(
|
||||
["getPoll", poll.urlId],
|
||||
(poll) => {
|
||||
if (!poll) {
|
||||
throw new Error(
|
||||
"Tried to update poll but no result found in query cache",
|
||||
);
|
||||
}
|
||||
|
||||
poll.participants = poll.participants.filter(
|
||||
({ id }) => id !== participantId,
|
||||
);
|
||||
|
||||
return poll;
|
||||
},
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
plausible("Remove participant");
|
||||
},
|
||||
|
|
102
components/poll/score-summary.tsx
Normal file
102
components/poll/score-summary.tsx
Normal file
|
@ -0,0 +1,102 @@
|
|||
import clsx from "clsx";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { usePrevious } from "react-use";
|
||||
|
||||
import CheckCircle from "@/components/icons/check-circle.svg";
|
||||
import IfNeedBe from "@/components/icons/if-need-be.svg";
|
||||
|
||||
export interface PopularityScoreProps {
|
||||
yesScore: number;
|
||||
compact?: boolean;
|
||||
ifNeedBeScore?: number;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
const Score = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
score: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
>(function Score({ icon: Icon, score, compact }, ref) {
|
||||
const prevScore = usePrevious(score);
|
||||
|
||||
const multiplier = prevScore !== undefined ? score - prevScore : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
"relative inline-flex items-center font-mono font-semibold text-slate-500",
|
||||
{ "text-sm": !compact, "text-xs": compact },
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={clsx(
|
||||
"mr-1 inline-block text-slate-400/80 transition-opacity",
|
||||
{
|
||||
"h-4": !compact,
|
||||
"h-3": compact,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<span className="relative inline-block">
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.span
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: 10 * multiplier,
|
||||
}}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: 10 * multiplier,
|
||||
}}
|
||||
key={score}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
{score}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
{/* Invisible text just to give us the right width */}
|
||||
<span className="text-transparent">{score}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const MotionScore = motion(Score);
|
||||
|
||||
export const ScoreSummary: React.VoidFunctionComponent<PopularityScoreProps> =
|
||||
React.memo(function PopularityScore({ yesScore, ifNeedBeScore, compact }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="popularity-score"
|
||||
className="inline-flex items-center space-x-2"
|
||||
>
|
||||
<Score icon={CheckCircle} compact={compact} score={yesScore} />
|
||||
<AnimatePresence initial={false}>
|
||||
{ifNeedBeScore ? (
|
||||
<MotionScore
|
||||
initial={{ opacity: 0, width: 0, x: -10 }}
|
||||
animate={{ opacity: 1, width: "auto", x: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
width: 0,
|
||||
x: -10,
|
||||
transition: { duration: 0.1 },
|
||||
}}
|
||||
icon={IfNeedBe}
|
||||
compact={compact}
|
||||
score={ifNeedBeScore}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -1,8 +1,20 @@
|
|||
import { VoteType } from "@prisma/client";
|
||||
|
||||
export interface ParticipantForm {
|
||||
name: string;
|
||||
votes: string[];
|
||||
votes: Array<
|
||||
| {
|
||||
optionId: string;
|
||||
type: VoteType;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ParticipantFormSubmitted {
|
||||
name: string;
|
||||
votes: Array<{ optionId: string; type: VoteType }>;
|
||||
}
|
||||
export interface PollProps {
|
||||
pollId: string;
|
||||
highScore: number;
|
||||
}
|
||||
|
|
|
@ -1,14 +1,27 @@
|
|||
import { VoteType } from "@prisma/client";
|
||||
import * as React from "react";
|
||||
|
||||
import CheckCircle from "@/components/icons/check-circle.svg";
|
||||
import IfNeedBe from "@/components/icons/if-need-be.svg";
|
||||
|
||||
const VoteIcon: React.VoidFunctionComponent<{
|
||||
type: "yes" | "no";
|
||||
type?: VoteType;
|
||||
}> = ({ type }) => {
|
||||
if (type === "yes") {
|
||||
switch (type) {
|
||||
case "yes":
|
||||
return <CheckCircle className="h-5 w-5 text-green-400" />;
|
||||
|
||||
case "ifNeedBe":
|
||||
return <IfNeedBe className="h-5 w-5 text-yellow-400" />;
|
||||
|
||||
case "no":
|
||||
return (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-slate-300" />
|
||||
);
|
||||
|
||||
default:
|
||||
return <span className="inline-block font-bold text-slate-300">?</span>;
|
||||
}
|
||||
return <span className="inline-block h-2 w-2 rounded-full bg-slate-300" />;
|
||||
};
|
||||
|
||||
export default VoteIcon;
|
||||
|
|
54
components/poll/vote-selector.tsx
Normal file
54
components/poll/vote-selector.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { VoteType } from "@prisma/client";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import * as React from "react";
|
||||
|
||||
import VoteIcon from "./vote-icon";
|
||||
|
||||
export interface VoteSelectorProps {
|
||||
value?: VoteType;
|
||||
onChange?: (value: VoteType) => void;
|
||||
onFocus?: React.FocusEventHandler<HTMLButtonElement>;
|
||||
onBlur?: React.FocusEventHandler<HTMLButtonElement>;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const orderedVoteTypes: VoteType[] = ["yes", "ifNeedBe", "no"];
|
||||
|
||||
const getNext = (value: VoteType) => {
|
||||
return orderedVoteTypes[
|
||||
(orderedVoteTypes.indexOf(value) + 1) % orderedVoteTypes.length
|
||||
];
|
||||
};
|
||||
|
||||
export const VoteSelector = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
VoteSelectorProps
|
||||
>(function VoteSelector({ value, onChange, onFocus, onBlur, onKeyDown }, ref) {
|
||||
return (
|
||||
<button
|
||||
data-testid="vote-selector"
|
||||
type="button"
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
className="relative inline-flex h-9 w-9 items-center justify-center rounded-lg border bg-white shadow-sm transition focus-visible:border-0 focus-visible:ring-2 focus-visible:ring-indigo-500 active:scale-95"
|
||||
onClick={() => {
|
||||
onChange?.(value ? getNext(value) : orderedVoteTypes[0]);
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.span
|
||||
className="absolute flex items-center justify-center"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0 }}
|
||||
key={value}
|
||||
>
|
||||
<VoteIcon type={value} />
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
);
|
||||
});
|
|
@ -29,7 +29,7 @@ const HomeLink = () => {
|
|||
return (
|
||||
<Link href="/">
|
||||
<a>
|
||||
<Logo className="w-28 text-indigo-500 transition-colors active:text-indigo-600 lg:w-32" />
|
||||
<Logo className="inline-block w-28 text-indigo-500 transition-colors active:text-indigo-600 lg:w-32" />
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
|
@ -40,7 +40,7 @@ const MobileNavigation: React.VoidFunctionComponent<{
|
|||
}> = ({ openLoginModal }) => {
|
||||
const { user } = useSession();
|
||||
return (
|
||||
<div className="fixed top-0 z-30 flex h-12 w-full shrink-0 items-center justify-between border-b bg-gray-50 px-4 shadow-sm lg:hidden">
|
||||
<div className="fixed top-0 z-40 flex h-12 w-full shrink-0 items-center justify-between border-b bg-gray-50 px-4 lg:hidden">
|
||||
<div>
|
||||
<HomeLink />
|
||||
</div>
|
||||
|
|
5
migrations/20220511113020_add_if_need_be/migration.sql
Normal file
5
migrations/20220511113020_add_if_need_be/migration.sql
Normal file
|
@ -0,0 +1,5 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "VoteType" AS ENUM ('yes', 'no', 'ifNeedBe');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Vote" ADD COLUMN "type" "VoteType" NOT NULL DEFAULT E'yes';
|
173
migrations/20220511180705_naming_convention_update/migration.sql
Normal file
173
migrations/20220511180705_naming_convention_update/migration.sql
Normal file
|
@ -0,0 +1,173 @@
|
|||
-- DropForeignKey
|
||||
ALTER TABLE "Comment" DROP CONSTRAINT "Comment_pollId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Comment" DROP CONSTRAINT "Comment_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Link" DROP CONSTRAINT "Link_pollId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Option" DROP CONSTRAINT "Option_pollId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Participant" DROP CONSTRAINT "Participant_pollId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Participant" DROP CONSTRAINT "Participant_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Poll" DROP CONSTRAINT "Poll_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Vote" DROP CONSTRAINT "Vote_optionId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Vote" DROP CONSTRAINT "Vote_participantId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Vote" DROP CONSTRAINT "Vote_pollId_fkey";
|
||||
|
||||
-- Rename table
|
||||
ALTER TABLE "Comment" RENAME TO "comments";
|
||||
|
||||
-- Rename table
|
||||
ALTER TABLE "Link" RENAME TO "links";
|
||||
|
||||
-- Rename table
|
||||
ALTER TABLE "Option" RENAME TO "options";
|
||||
|
||||
-- Rename table
|
||||
ALTER TABLE "Participant" RENAME TO "participants";
|
||||
|
||||
-- Rename table
|
||||
ALTER TABLE "Poll" RENAME TO "polls";
|
||||
|
||||
-- Rename table
|
||||
ALTER TABLE "User" RENAME TO "users";
|
||||
|
||||
-- Rename table
|
||||
ALTER TABLE "Vote" RENAME TO "votes";
|
||||
|
||||
-- Rename enum
|
||||
ALTER TYPE "PollType" RENAME TO "poll_type";
|
||||
|
||||
-- Rename enum
|
||||
ALTER TYPE "Role" RENAME TO "role";
|
||||
|
||||
-- Rename enum
|
||||
ALTER TYPE "VoteType" RENAME TO "vote_type";
|
||||
|
||||
-- Rename fields
|
||||
ALTER TABLE "comments" RENAME COLUMN "authorName" TO "author_name";
|
||||
ALTER TABLE "comments" RENAME COLUMN "createdAt" TO "created_at";
|
||||
ALTER TABLE "comments" RENAME COLUMN "guestId" TO "guest_id";
|
||||
ALTER TABLE "comments" RENAME COLUMN "pollId" TO "poll_id";
|
||||
ALTER TABLE "comments" RENAME COLUMN "updatedAt" TO "updated_at";
|
||||
ALTER TABLE "comments" RENAME COLUMN "userId" TO "user_id";
|
||||
|
||||
-- Rename fields
|
||||
ALTER TABLE "links" RENAME COLUMN "createdAt" TO "created_at";
|
||||
ALTER TABLE "links" RENAME COLUMN "pollId" TO "poll_id";
|
||||
ALTER TABLE "links" RENAME COLUMN "urlId" TO "url_id";
|
||||
|
||||
-- Rename fields
|
||||
ALTER TABLE "options" RENAME COLUMN "createdAt" TO "created_at";
|
||||
ALTER TABLE "options" RENAME COLUMN "pollId" TO "poll_id";
|
||||
ALTER TABLE "options" RENAME COLUMN "updatedAt" TO "updated_at";
|
||||
|
||||
-- Rename fields
|
||||
ALTER TABLE "participants" RENAME COLUMN "createdAt" TO "created_at";
|
||||
ALTER TABLE "participants" RENAME COLUMN "guestId" TO "guest_id";
|
||||
ALTER TABLE "participants" RENAME COLUMN "pollId" TO "poll_id";
|
||||
ALTER TABLE "participants" RENAME COLUMN "updatedAt" TO "updated_at";
|
||||
ALTER TABLE "participants" RENAME COLUMN "userId" TO "user_id";
|
||||
|
||||
-- Rename fields
|
||||
ALTER TABLE "polls" RENAME COLUMN "authorName" TO "author_name";
|
||||
ALTER TABLE "polls" RENAME COLUMN "userId" TO "user_id";
|
||||
ALTER TABLE "polls" RENAME COLUMN "createdAt" TO "created_at";
|
||||
ALTER TABLE "polls" RENAME COLUMN "timeZone" TO "time_zone";
|
||||
ALTER TABLE "polls" RENAME COLUMN "updatedAt" TO "updated_at";
|
||||
ALTER TABLE "polls" RENAME COLUMN "urlId" TO "url_id";
|
||||
|
||||
-- Rename fields
|
||||
ALTER TABLE "users" RENAME COLUMN "createdAt" TO "created_at";
|
||||
ALTER TABLE "users" RENAME COLUMN "updatedAt" TO "updated_at";
|
||||
|
||||
-- Rename fields
|
||||
ALTER TABLE "votes" RENAME COLUMN "createdAt" TO "created_at";
|
||||
ALTER TABLE "votes" RENAME COLUMN "optionId" TO "option_id";
|
||||
ALTER TABLE "votes" RENAME COLUMN "participantId" TO "participant_id";
|
||||
ALTER TABLE "votes" RENAME COLUMN "pollId" TO "poll_id";
|
||||
ALTER TABLE "votes" RENAME COLUMN "updatedAt" TO "updated_at";
|
||||
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "polls_url_id_key" ON "polls"("url_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "links_url_id_key" ON "links"("url_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "links_poll_id_role_key" ON "links"("poll_id", "role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "participants_id_poll_id_key" ON "participants"("id", "poll_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "comments_id_poll_id_key" ON "comments"("id", "poll_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "polls" ADD CONSTRAINT "polls_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "links" ADD CONSTRAINT "links_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "polls"("url_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "participants" ADD CONSTRAINT "participants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "participants" ADD CONSTRAINT "participants_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "polls"("url_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "options" ADD CONSTRAINT "options_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "polls"("url_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "votes" ADD CONSTRAINT "votes_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "polls"("url_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "votes" ADD CONSTRAINT "votes_participant_id_fkey" FOREIGN KEY ("participant_id") REFERENCES "participants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "votes" ADD CONSTRAINT "votes_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "polls"("url_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "comments" RENAME CONSTRAINT "Comment_pkey" TO "comments_pkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "links" RENAME CONSTRAINT "Link_pkey" TO "links_pkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "options" RENAME CONSTRAINT "Option_pkey" TO "options_pkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "participants" RENAME CONSTRAINT "Participant_pkey" TO "participants_pkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "polls" RENAME CONSTRAINT "Poll_pkey" TO "polls_pkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" RENAME CONSTRAINT "User_pkey" TO "users_pkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "votes" RENAME CONSTRAINT "Vote_pkey" TO "votes_pkey";
|
13
migrations/20220512093441_add_no_votes/migration.sql
Normal file
13
migrations/20220512093441_add_no_votes/migration.sql
Normal file
|
@ -0,0 +1,13 @@
|
|||
-- Previously we only stored "yes" and "ifNeedBe" votes and assumed missing votes are "no"
|
||||
-- Since we want to differentiate between "no" and did not vote yet we want to include "no" votes
|
||||
-- in this table
|
||||
INSERT INTO votes (id, poll_id, participant_id, option_id, type)
|
||||
SELECT nanoid(), poll_id, participant_id, option_id, 'no'
|
||||
FROM (
|
||||
SELECT o.poll_id, p.id participant_id, o.id option_id
|
||||
FROM options o
|
||||
JOIN participants p ON o.poll_id = p.poll_id
|
||||
EXCEPT
|
||||
SELECT poll_id, participant_id, option_id
|
||||
FROM votes
|
||||
) AS missing;
|
|
@ -25,10 +25,8 @@ export default function Document() {
|
|||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/inter-ui/3.19.3/inter.min.css"
|
||||
integrity="sha512-8vEtrrc40OAQaCUaqVjNMQtQEPyNtllVG1RYy6bGEuWQkivCBeqOzuDJPPhD+MO6y6QGLuQYPCr8Nlzu9lTYaQ=="
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<meta name="theme-color" content="#f9fafb" />
|
||||
</Head>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { VoteType } from "@prisma/client";
|
||||
import { GetPollApiResponse } from "api-client/get-poll";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getQueryParam } from "utils/api-utils";
|
||||
|
@ -50,18 +51,21 @@ export default async function handler(
|
|||
id: legacyParticipant._id.toString(),
|
||||
}));
|
||||
|
||||
const votes: Array<{ optionId: string; participantId: string }> = [];
|
||||
const votes: Array<{
|
||||
optionId: string;
|
||||
participantId: string;
|
||||
type: VoteType;
|
||||
}> = [];
|
||||
|
||||
newParticipants?.forEach((p, i) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const legacyVotes = legacyPoll.participants![i].votes;
|
||||
legacyVotes?.forEach((v, j) => {
|
||||
if (v) {
|
||||
votes.push({
|
||||
optionId: newOptions[j].id,
|
||||
participantId: p.id,
|
||||
type: v ? "yes" : "no",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -133,9 +137,6 @@ export default async function handler(
|
|||
},
|
||||
include: {
|
||||
options: {
|
||||
include: {
|
||||
votes: true,
|
||||
},
|
||||
orderBy: {
|
||||
value: "asc",
|
||||
},
|
||||
|
|
|
@ -2,9 +2,9 @@ import { sendEmailTemplate } from "utils/api-utils";
|
|||
import { createToken, withSessionRoute } from "utils/auth";
|
||||
import { nanoid } from "utils/nanoid";
|
||||
|
||||
import { CreatePollPayload } from "../../../api-client/create-poll";
|
||||
import { prisma } from "../../../db";
|
||||
import absoluteUrl from "../../../utils/absolute-url";
|
||||
import { CreatePollPayload } from "../../api-client/create-poll";
|
||||
import { prisma } from "../../db";
|
||||
import absoluteUrl from "../../utils/absolute-url";
|
||||
|
||||
export default withSessionRoute(async (req, res) => {
|
||||
switch (req.method) {
|
|
@ -1,9 +1,9 @@
|
|||
import { GetPollApiResponse } from "api-client/get-poll";
|
||||
import { resetDates } from "utils/legacy-utils";
|
||||
|
||||
import { UpdatePollPayload } from "../../../../api-client/update-poll";
|
||||
import { prisma } from "../../../../db";
|
||||
import { withLink } from "../../../../utils/api-utils";
|
||||
import { UpdatePollPayload } from "../../../api-client/update-poll";
|
||||
import { prisma } from "../../../db";
|
||||
import { withLink } from "../../../utils/api-utils";
|
||||
|
||||
export default withLink<
|
||||
GetPollApiResponse | { status: number; message: string }
|
||||
|
@ -18,9 +18,6 @@ export default withLink<
|
|||
},
|
||||
include: {
|
||||
options: {
|
||||
include: {
|
||||
votes: true,
|
||||
},
|
||||
orderBy: {
|
||||
value: "asc",
|
||||
},
|
||||
|
@ -111,9 +108,6 @@ export default withLink<
|
|||
},
|
||||
include: {
|
||||
options: {
|
||||
include: {
|
||||
votes: true,
|
||||
},
|
||||
orderBy: {
|
||||
value: "asc",
|
||||
},
|
|
@ -1,7 +1,7 @@
|
|||
import { createGuestUser, withSessionRoute } from "utils/auth";
|
||||
|
||||
import { prisma } from "../../../../../db";
|
||||
import { sendNotification, withLink } from "../../../../../utils/api-utils";
|
||||
import { prisma } from "../../../../db";
|
||||
import { sendNotification, withLink } from "../../../../utils/api-utils";
|
||||
|
||||
export default withSessionRoute(
|
||||
withLink(async ({ req, res, link }) => {
|
|
@ -1,8 +1,8 @@
|
|||
import { createGuestUser, withSessionRoute } from "utils/auth";
|
||||
|
||||
import { AddParticipantPayload } from "../../../../../api-client/add-participant";
|
||||
import { prisma } from "../../../../../db";
|
||||
import { sendNotification, withLink } from "../../../../../utils/api-utils";
|
||||
import { AddParticipantPayload } from "../../../../api-client/add-participant";
|
||||
import { prisma } from "../../../../db";
|
||||
import { sendNotification, withLink } from "../../../../utils/api-utils";
|
||||
|
||||
export default withSessionRoute(
|
||||
withLink(async ({ req, res, link }) => {
|
||||
|
@ -28,8 +28,9 @@ export default withSessionRoute(
|
|||
: undefined,
|
||||
votes: {
|
||||
createMany: {
|
||||
data: payload.votes.map((optionId) => ({
|
||||
data: payload.votes.map(({ optionId, type }) => ({
|
||||
optionId,
|
||||
type,
|
||||
pollId: link.pollId,
|
||||
})),
|
||||
},
|
|
@ -1,3 +1,5 @@
|
|||
import { UpdateParticipantPayload } from "api-client/update-participant";
|
||||
|
||||
import { prisma } from "../../../../../db";
|
||||
import { getQueryParam, withLink } from "../../../../../utils/api-utils";
|
||||
|
||||
|
@ -7,7 +9,9 @@ export default withLink(async ({ req, res, link }) => {
|
|||
const pollId = link.pollId;
|
||||
switch (req.method) {
|
||||
case "PATCH":
|
||||
await prisma.participant.update({
|
||||
const payload: UpdateParticipantPayload = req.body;
|
||||
|
||||
const participant = await prisma.participant.update({
|
||||
where: {
|
||||
id_pollId: {
|
||||
id: participantId,
|
||||
|
@ -20,17 +24,21 @@ export default withLink(async ({ req, res, link }) => {
|
|||
pollId,
|
||||
},
|
||||
createMany: {
|
||||
data: req.body.votes.map((optionId: string) => ({
|
||||
data: payload.votes.map(({ optionId, type }) => ({
|
||||
optionId,
|
||||
type,
|
||||
pollId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
name: req.body.name,
|
||||
},
|
||||
include: {
|
||||
votes: true,
|
||||
},
|
||||
});
|
||||
|
||||
return res.end();
|
||||
return res.json(participant);
|
||||
|
||||
case "DELETE":
|
||||
await prisma.participant.delete({
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { VoteType } from "@prisma/client";
|
||||
import { addMinutes } from "date-fns";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import absoluteUrl from "utils/absolute-url";
|
||||
|
@ -5,22 +6,22 @@ import { nanoid } from "utils/nanoid";
|
|||
|
||||
import { prisma } from "../../../db";
|
||||
|
||||
const participantData = [
|
||||
const participantData: Array<{ name: string; votes: VoteType[] }> = [
|
||||
{
|
||||
name: "Reed",
|
||||
votes: [0, 2],
|
||||
votes: ["yes", "no", "ifNeedBe", "no"],
|
||||
},
|
||||
{
|
||||
name: "Susan",
|
||||
votes: [0, 1, 2],
|
||||
votes: ["yes", "yes", "yes", "no"],
|
||||
},
|
||||
{
|
||||
name: "Johnny",
|
||||
votes: [2, 3],
|
||||
votes: ["no", "no", "yes", "yes"],
|
||||
},
|
||||
{
|
||||
name: "Ben",
|
||||
votes: [0, 1, 2, 3],
|
||||
votes: ["yes", "yes", "yes", "yes"],
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -49,7 +50,11 @@ export default async function handler(
|
|||
createdAt: Date;
|
||||
}> = [];
|
||||
|
||||
const votes: Array<{ optionId: string; participantId: string }> = [];
|
||||
const votes: Array<{
|
||||
optionId: string;
|
||||
participantId: string;
|
||||
type: VoteType;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < participantData.length; i++) {
|
||||
const { name, votes: participantVotes } = participantData[i];
|
||||
|
@ -61,9 +66,12 @@ export default async function handler(
|
|||
createdAt: addMinutes(today, i * -1),
|
||||
});
|
||||
|
||||
participantVotes.forEach((voteIndex) => {
|
||||
const option = options[voteIndex];
|
||||
votes.push({ optionId: option.id, participantId });
|
||||
options.forEach((option, index) => {
|
||||
votes.push({
|
||||
optionId: option.id,
|
||||
participantId,
|
||||
type: participantVotes[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import { withSessionSsr } from "utils/auth";
|
|||
|
||||
import ErrorPage from "@/components/error-page";
|
||||
import FullPageLoader from "@/components/full-page-loader";
|
||||
import { PollContextProvider } from "@/components/poll-context";
|
||||
import { SessionProps, withSession } from "@/components/session";
|
||||
|
||||
import { GetPollResponse } from "../api-client/get-poll";
|
||||
|
@ -74,7 +75,11 @@ const PollPageLoader: NextPage<SessionProps> = () => {
|
|||
}
|
||||
|
||||
if (poll) {
|
||||
return <PollPage poll={poll} />;
|
||||
return (
|
||||
<PollContextProvider value={poll}>
|
||||
<PollPage />
|
||||
</PollContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (didError) {
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
"voteCount_other": "{{count}} votes",
|
||||
"participantCount": "{{count}} participant",
|
||||
"participantCount_other": "{{count}} participants",
|
||||
"createdBy": "Created by <b>{{name}}</b>",
|
||||
"createdBy": "by <b>{{name}}</b>",
|
||||
"timeZone": "Time Zone:",
|
||||
"creatingDemo": "Creating demo poll…",
|
||||
"ok": "Ok",
|
||||
|
@ -49,5 +49,8 @@
|
|||
"monday": "Monday",
|
||||
"sunday": "Sunday",
|
||||
"12h": "12-hour",
|
||||
"24h": "24-hour"
|
||||
"24h": "24-hour",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"ifNeedBe": "If need be"
|
||||
}
|
||||
|
|
|
@ -11,105 +11,129 @@ model User {
|
|||
id String @id @default(cuid())
|
||||
name String
|
||||
email String @unique() @db.Citext
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
polls Poll[]
|
||||
participants Participant[]
|
||||
comments Comment[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
enum PollType {
|
||||
date
|
||||
|
||||
@@map("poll_type")
|
||||
}
|
||||
|
||||
model Poll {
|
||||
urlId String @id @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
urlId String @id @unique @map("url_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deadline DateTime?
|
||||
title String
|
||||
type PollType
|
||||
description String?
|
||||
location String?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
userId String @map("user_id")
|
||||
votes Vote[]
|
||||
timeZone String?
|
||||
timeZone String? @map("time_zone")
|
||||
verified Boolean @default(false)
|
||||
options Option[]
|
||||
participants Participant[]
|
||||
authorName String @default("")
|
||||
authorName String @default("") @map("author_name")
|
||||
demo Boolean @default(false)
|
||||
comments Comment[]
|
||||
links Link[]
|
||||
legacy Boolean @default(false)
|
||||
closed Boolean @default(false)
|
||||
notifications Boolean @default(false)
|
||||
|
||||
@@map("polls")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
admin
|
||||
participant
|
||||
|
||||
@@map("role")
|
||||
}
|
||||
|
||||
model Link {
|
||||
urlId String @id @unique
|
||||
urlId String @id @unique @map("url_id")
|
||||
role Role
|
||||
pollId String
|
||||
pollId String @map("poll_id")
|
||||
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([pollId, role])
|
||||
@@map("links")
|
||||
}
|
||||
|
||||
model Participant {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
guestId String?
|
||||
userId String? @map("user_id")
|
||||
guestId String? @map("guest_id")
|
||||
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
||||
pollId String
|
||||
pollId String @map("poll_id")
|
||||
votes Vote[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([id, pollId])
|
||||
@@map("participants")
|
||||
}
|
||||
|
||||
model Option {
|
||||
id String @id @default(cuid())
|
||||
value String
|
||||
pollId String
|
||||
pollId String @map("poll_id")
|
||||
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
votes Vote[]
|
||||
|
||||
@@map("options")
|
||||
}
|
||||
|
||||
enum VoteType {
|
||||
yes
|
||||
no
|
||||
ifNeedBe
|
||||
|
||||
@@map("vote_type")
|
||||
}
|
||||
|
||||
model Vote {
|
||||
id String @id @default(cuid())
|
||||
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
|
||||
participantId String
|
||||
participantId String @map("participant_id")
|
||||
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
optionId String
|
||||
optionId String @map("option_id")
|
||||
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
||||
pollId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
pollId String @map("poll_id")
|
||||
type VoteType @default(yes)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
|
||||
@@map("votes")
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
content String
|
||||
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
||||
pollId String
|
||||
authorName String
|
||||
pollId String @map("poll_id")
|
||||
authorName String @map("author_name")
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
guestId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
userId String? @map("user_id")
|
||||
guestId String? @map("guest_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([id, pollId])
|
||||
@@map("comments")
|
||||
}
|
||||
|
|
23
style.css
23
style.css
|
@ -32,13 +32,13 @@
|
|||
@apply outline-none;
|
||||
}
|
||||
a {
|
||||
@apply font-medium text-indigo-500 hover:text-indigo-400 hover:underline;
|
||||
@apply rounded-sm font-medium text-indigo-500 outline-none hover:text-indigo-400 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1;
|
||||
}
|
||||
label {
|
||||
@apply mb-1 block text-sm text-slate-800;
|
||||
}
|
||||
button {
|
||||
@apply cursor-default focus:outline-none focus:ring-indigo-600;
|
||||
@apply cursor-default outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1;
|
||||
}
|
||||
|
||||
#floating-ui-root {
|
||||
|
@ -66,14 +66,14 @@
|
|||
@apply h-4 w-4 rounded border-slate-300 text-indigo-500 shadow-sm focus:ring-indigo-500;
|
||||
}
|
||||
.btn {
|
||||
@apply inline-flex h-9 cursor-default items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1;
|
||||
@apply inline-flex h-9 cursor-default select-none items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all active:scale-95;
|
||||
}
|
||||
a.btn {
|
||||
@apply hover:no-underline;
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
@apply btn border-gray-300 bg-white text-gray-700 hover:text-indigo-500 focus-visible:border-transparent focus-visible:ring-indigo-500 focus-visible:ring-offset-0 active:bg-gray-100;
|
||||
@apply btn border-slate-300 bg-white text-slate-700 hover:bg-indigo-50/10 active:bg-slate-100;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn border-rose-600 bg-rose-500 text-white hover:bg-rose-600 focus-visible:ring-rose-500;
|
||||
|
@ -81,20 +81,15 @@
|
|||
.btn-link {
|
||||
@apply inline-flex items-center text-indigo-500 underline;
|
||||
}
|
||||
.btn-default.btn-disabled {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
.btn-disabled {
|
||||
@apply pointer-events-none;
|
||||
.btn.btn-disabled {
|
||||
text-shadow: none;
|
||||
@apply pointer-events-none border-gray-200 bg-slate-500/5 text-gray-400 shadow-none;
|
||||
}
|
||||
.btn-primary {
|
||||
text-shadow: rgb(0 0 0 / 20%) 0px 1px 1px;
|
||||
@apply btn border-indigo-600 bg-indigo-500 text-white hover:bg-opacity-90 focus-visible:ring-indigo-500 active:bg-indigo-600;
|
||||
}
|
||||
.btn-primary.btn-disabled {
|
||||
text-shadow: none;
|
||||
@apply border-gray-300/70 bg-gray-200/70 text-gray-400;
|
||||
}
|
||||
|
||||
a.btn-primary {
|
||||
@apply text-white;
|
||||
}
|
||||
|
@ -104,7 +99,7 @@
|
|||
}
|
||||
|
||||
.segment-button button {
|
||||
@apply inline-flex grow items-center justify-center border-t border-b border-r bg-white px-4 font-medium transition-colors first:rounded-l first:border-l last:rounded-r hover:bg-slate-50 focus:z-10 focus:ring-2 active:bg-slate-100;
|
||||
@apply inline-flex grow items-center justify-center border-t border-b border-r bg-white px-4 font-medium transition-colors first:rounded-l first:border-l last:rounded-r hover:bg-slate-50 focus:z-10 focus-visible:ring-2 focus-visible:ring-offset-0 active:bg-slate-100;
|
||||
}
|
||||
|
||||
.segment-button .segment-button-active {
|
||||
|
|
|
@ -35,7 +35,8 @@ module.exports = {
|
|||
xs: "375px",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter var", ...defaultTheme.fontFamily.sans],
|
||||
sans: ["Inter", ...defaultTheme.fontFamily.sans],
|
||||
mono: [...defaultTheme.fontFamily.mono],
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
"in-expo": "cubic-bezier(0.68, -0.6, 0.32, 1.6)",
|
||||
|
|
|
@ -8,8 +8,8 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
|
|||
await page.type('[placeholder="Your name"]', "Test user");
|
||||
// There is a hidden checkbox (nth=0) that exists so that the behaviour of the form is consistent even
|
||||
// when we only have a single option/checkbox.
|
||||
await page.locator('[name="votes"] >> nth=1').click();
|
||||
await page.locator('[name="votes"] >> nth=3').click();
|
||||
await page.locator("data-testid=vote-selector >> nth=0").click();
|
||||
await page.locator("data-testid=vote-selector >> nth=2").click();
|
||||
await page.click('[data-testid="submitNewParticipant"]');
|
||||
await expect(page.locator("text='Test user'")).toBeVisible();
|
||||
await expect(page.locator("text=Guest")).toBeVisible();
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { ObjectId } from "mongodb";
|
||||
import { getMongoClient } from "./mongodb-client";
|
||||
|
||||
import { prisma } from "../db";
|
||||
import { getMongoClient } from "./mongodb-client";
|
||||
|
||||
export interface LegacyPoll {
|
||||
__private: {
|
||||
|
@ -91,8 +92,8 @@ export const resetDates = async (legacyPollId: string) => {
|
|||
},
|
||||
include: {
|
||||
options: {
|
||||
include: {
|
||||
votes: true,
|
||||
orderBy: {
|
||||
value: "asc",
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue