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
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -39,4 +39,7 @@ yarn-error.log*
|
||||||
|
|
||||||
# playwright
|
# playwright
|
||||||
/playwright-report
|
/playwright-report
|
||||||
/test-results
|
/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";
|
import axios from "axios";
|
||||||
|
|
||||||
export interface AddParticipantPayload {
|
export interface AddParticipantPayload {
|
||||||
pollId: string;
|
pollId: string;
|
||||||
name: string;
|
name: string;
|
||||||
votes: string[];
|
votes: Array<{ optionId: string; type: VoteType }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AddParticipantResponse = Participant & {
|
export type AddParticipantResponse = Participant & {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Link, Option, Participant, Poll, User, Vote } from "@prisma/client";
|
import { Link, Option, Participant, Poll, User, Vote } from "@prisma/client";
|
||||||
|
|
||||||
export interface GetPollApiResponse extends Omit<Poll, "verificationCode"> {
|
export interface GetPollApiResponse extends Omit<Poll, "verificationCode"> {
|
||||||
options: Array<Option & { votes: Vote[] }>;
|
options: Option[];
|
||||||
participants: Array<Participant & { votes: Vote[] }>;
|
participants: Array<Participant & { votes: Vote[] }>;
|
||||||
user: User;
|
user: User;
|
||||||
role: "admin" | "participant";
|
role: "admin" | "participant";
|
||||||
|
|
|
@ -1,15 +1,20 @@
|
||||||
|
import { Participant, Vote, VoteType } from "@prisma/client";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export interface UpdateParticipantPayload {
|
export interface UpdateParticipantPayload {
|
||||||
pollId: string;
|
pollId: string;
|
||||||
participantId: string;
|
participantId: string;
|
||||||
name: string;
|
name: string;
|
||||||
votes: string[];
|
votes: Array<{ optionId: string; type: VoteType }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateParticipant = async (
|
export const updateParticipant = async (
|
||||||
payload: UpdateParticipantPayload,
|
payload: UpdateParticipantPayload,
|
||||||
): Promise<void> => {
|
): Promise<Participant & { votes: Vote[] }> => {
|
||||||
const { pollId, participantId, ...body } = payload;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -31,7 +31,7 @@ const DateCard: React.VoidFunctionComponent<DateCardProps> = ({
|
||||||
{dow}
|
{dow}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="text-center text-xs font-semibold uppercase text-gray-800">
|
||||||
{month}
|
{month}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTimeoutFn } from "react-use";
|
|
||||||
|
|
||||||
import DateCard from "../date-card";
|
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 UserAvatar from "../poll/user-avatar";
|
||||||
import VoteIcon from "../poll/vote-icon";
|
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 PollDemo: React.VoidFunctionComponent = () => {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [bestOption, setBestOption] = React.useState<number>();
|
|
||||||
useTimeoutFn(() => {
|
|
||||||
setBestOption(2);
|
|
||||||
}, 1500);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -48,7 +43,7 @@ const PollDemo: React.VoidFunctionComponent = () => {
|
||||||
>
|
>
|
||||||
<div className="flex border-b shadow-sm">
|
<div className="flex border-b shadow-sm">
|
||||||
<div
|
<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 }}
|
style={{ width: sidebarWidth }}
|
||||||
>
|
>
|
||||||
<div className="flex h-full grow items-end">
|
<div className="flex h-full grow items-end">
|
||||||
|
@ -66,17 +61,17 @@ const PollDemo: React.VoidFunctionComponent = () => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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 }}
|
style={{ width: 100 }}
|
||||||
>
|
>
|
||||||
<DateCard
|
<DateCard
|
||||||
day={format(d, "dd")}
|
day={format(d, "dd")}
|
||||||
dow={format(d, "E")}
|
dow={format(d, "E")}
|
||||||
month={format(d, "MMM")}
|
month={format(d, "MMM")}
|
||||||
annotation={
|
|
||||||
<Score count={score} highlight={i === bestOption} />
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<ScoreSummary yesScore={score} compact={true} />
|
||||||
|
</div>
|
||||||
</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">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<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" />
|
<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>
|
</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 { GetPollResponse } from "api-client/get-poll";
|
||||||
import { keyBy } from "lodash";
|
import { keyBy } from "lodash";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
@ -13,8 +13,6 @@ import { usePreferences } from "./preferences/use-preferences";
|
||||||
import { useSession } from "./session";
|
import { useSession } from "./session";
|
||||||
import { useRequiredContext } from "./use-required-context";
|
import { useRequiredContext } from "./use-required-context";
|
||||||
|
|
||||||
type VoteType = "yes" | "no";
|
|
||||||
|
|
||||||
type PollContextValue = {
|
type PollContextValue = {
|
||||||
userAlreadyVoted: boolean;
|
userAlreadyVoted: boolean;
|
||||||
poll: GetPollResponse;
|
poll: GetPollResponse;
|
||||||
|
@ -23,10 +21,11 @@ type PollContextValue = {
|
||||||
pollType: "date" | "timeSlot";
|
pollType: "date" | "timeSlot";
|
||||||
highScore: number;
|
highScore: number;
|
||||||
getParticipantsWhoVotedForOption: (optionId: string) => Participant[]; // maybe just attach votes to parsed options
|
getParticipantsWhoVotedForOption: (optionId: string) => Participant[]; // maybe just attach votes to parsed options
|
||||||
|
getScore: (optionId: string) => { yes: number; ifNeedBe: number };
|
||||||
getParticipantById: (
|
getParticipantById: (
|
||||||
participantId: string,
|
participantId: string,
|
||||||
) => (Participant & { votes: Vote[] }) | undefined;
|
) => (Participant & { votes: Vote[] }) | undefined;
|
||||||
getVote: (participantId: string, optionId: string) => VoteType;
|
getVote: (participantId: string, optionId: string) => VoteType | undefined;
|
||||||
} & (
|
} & (
|
||||||
| { pollType: "date"; options: ParsedDateOption[] }
|
| { pollType: "date"; options: ParsedDateOption[] }
|
||||||
| { pollType: "timeSlot"; options: ParsedTimeSlotOption[] }
|
| { pollType: "timeSlot"; options: ParsedTimeSlotOption[] }
|
||||||
|
@ -57,21 +56,46 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
||||||
const participantsByOptionId = React.useMemo(() => {
|
const participantsByOptionId = React.useMemo(() => {
|
||||||
const res: Record<string, Participant[]> = {};
|
const res: Record<string, Participant[]> = {};
|
||||||
poll.options.forEach((option) => {
|
poll.options.forEach((option) => {
|
||||||
res[option.id] = option.votes.map(
|
res[option.id] = poll.participants.filter((participant) =>
|
||||||
({ participantId }) => participantById[participantId],
|
participant.votes.some(
|
||||||
|
({ type, optionId }) => optionId === option.id && type !== "no",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return res;
|
return res;
|
||||||
}, [participantById, poll.options]);
|
}, [poll.options, poll.participants]);
|
||||||
|
|
||||||
const { locale } = usePreferences();
|
const { locale } = usePreferences();
|
||||||
|
|
||||||
|
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 contextValue = React.useMemo<PollContextValue>(() => {
|
||||||
let highScore = 1;
|
const highScore = poll.options.reduce((acc, curr) => {
|
||||||
poll.options.forEach((option) => {
|
const score = getScore(curr.id).yes;
|
||||||
if (option.votes.length > highScore) {
|
|
||||||
highScore = option.votes.length;
|
return score > acc ? score : acc;
|
||||||
}
|
}, 1);
|
||||||
});
|
|
||||||
|
|
||||||
const parsedOptions = decodeOptions(
|
const parsedOptions = decodeOptions(
|
||||||
poll.options,
|
poll.options,
|
||||||
|
@ -99,11 +123,6 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
||||||
return {
|
return {
|
||||||
userAlreadyVoted,
|
userAlreadyVoted,
|
||||||
poll,
|
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) => {
|
getParticipantById: (participantId) => {
|
||||||
return participantById[participantId];
|
return participantById[participantId];
|
||||||
},
|
},
|
||||||
|
@ -111,17 +130,18 @@ export const PollContextProvider: React.VoidFunctionComponent<{
|
||||||
getParticipantsWhoVotedForOption: (optionId: string) =>
|
getParticipantsWhoVotedForOption: (optionId: string) =>
|
||||||
participantsByOptionId[optionId],
|
participantsByOptionId[optionId],
|
||||||
getVote: (participantId, optionId) => {
|
getVote: (participantId, optionId) => {
|
||||||
return getParticipantById(participantId)?.votes.some(
|
const vote = getParticipantById(participantId)?.votes.find(
|
||||||
(vote) => vote.optionId === optionId,
|
(vote) => vote.optionId === optionId,
|
||||||
)
|
);
|
||||||
? "yes"
|
return vote?.type;
|
||||||
: "no";
|
|
||||||
},
|
},
|
||||||
|
getScore,
|
||||||
...parsedOptions,
|
...parsedOptions,
|
||||||
targetTimeZone,
|
targetTimeZone,
|
||||||
setTargetTimeZone,
|
setTargetTimeZone,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
getScore,
|
||||||
locale,
|
locale,
|
||||||
participantById,
|
participantById,
|
||||||
participantsByOptionId,
|
participantsByOptionId,
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { GetPollResponse } from "api-client/get-poll";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
@ -21,7 +20,8 @@ import NotificationsToggle from "./poll/notifications-toggle";
|
||||||
import PollSubheader from "./poll/poll-subheader";
|
import PollSubheader from "./poll/poll-subheader";
|
||||||
import TruncatedLinkify from "./poll/truncated-linkify";
|
import TruncatedLinkify from "./poll/truncated-linkify";
|
||||||
import { UserAvatarProvider } from "./poll/user-avatar";
|
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 Popover from "./popover";
|
||||||
import { useSession } from "./session";
|
import { useSession } from "./session";
|
||||||
import Sharing from "./sharing";
|
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 DesktopPoll = React.lazy(() => import("@/components/poll/desktop-poll"));
|
||||||
const MobilePoll = React.lazy(() => import("@/components/poll/mobile-poll"));
|
const MobilePoll = React.lazy(() => import("@/components/poll/mobile-poll"));
|
||||||
|
|
||||||
const PollInner: NextPage = () => {
|
const PollPage: NextPage = () => {
|
||||||
const { poll } = usePoll();
|
const { poll } = usePoll();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -121,13 +121,6 @@ const PollInner: NextPage = () => {
|
||||||
|
|
||||||
const PollComponent = isWideScreen ? DesktopPoll : MobilePoll;
|
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(
|
const names = React.useMemo(
|
||||||
() => poll.participants.map(({ name }) => name),
|
() => poll.participants.map(({ name }) => name),
|
||||||
[poll.participants],
|
[poll.participants],
|
||||||
|
@ -210,9 +203,28 @@ const PollInner: NextPage = () => {
|
||||||
This poll has been locked (voting is disabled)
|
This poll has been locked (voting is disabled)
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>}>
|
<React.Suspense fallback={<div>Loading…</div>}>
|
||||||
<div className="mb-4 lg:mb-8">
|
<div className="mb-4 lg:mb-8">
|
||||||
<PollComponent pollId={poll.urlId} highScore={highScore} />
|
<PollComponent pollId={poll.urlId} />
|
||||||
</div>
|
</div>
|
||||||
<Discussion pollId={poll.urlId} />
|
<Discussion pollId={poll.urlId} />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
|
@ -223,12 +235,4 @@ const PollInner: NextPage = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PollPage = ({ poll }: { poll: GetPollResponse }) => {
|
|
||||||
return (
|
|
||||||
<PollContextProvider value={poll}>
|
|
||||||
<PollInner />
|
|
||||||
</PollContextProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PollPage;
|
export default PollPage;
|
||||||
|
|
|
@ -23,6 +23,8 @@ if (typeof window !== "undefined") {
|
||||||
|
|
||||||
const MotionButton = motion(Button);
|
const MotionButton = motion(Button);
|
||||||
|
|
||||||
|
const MotionParticipantFormRow = motion(ParticipantRowForm);
|
||||||
|
|
||||||
const minSidebarWidth = 180;
|
const minSidebarWidth = 180;
|
||||||
|
|
||||||
const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
|
@ -111,7 +113,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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))` }}
|
style={{ width: `min(${pollWidth}px, calc(100vw - 3rem))` }}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
@ -131,22 +133,21 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex shrink-0">
|
<div className="flex shrink-0">
|
||||||
{!shouldShowNewParticipantForm && !poll.closed ? (
|
<Button
|
||||||
<Button
|
type="primary"
|
||||||
type="primary"
|
disabled={shouldShowNewParticipantForm || poll.closed}
|
||||||
icon={<PlusCircle />}
|
icon={<PlusCircle />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShouldShowNewParticipantForm(true);
|
setShouldShowNewParticipantForm(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
New Participant
|
New Participant
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div
|
<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 }}
|
style={{ width: sidebarWidth }}
|
||||||
>
|
>
|
||||||
<div className="flex h-full grow items-end">
|
<div className="flex h-full grow items-end">
|
||||||
|
@ -198,26 +199,31 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{shouldShowNewParticipantForm && !poll.closed ? (
|
<AnimatePresence initial={false}>
|
||||||
<ParticipantRowForm
|
{shouldShowNewParticipantForm && !poll.closed ? (
|
||||||
className="border-t bg-slate-100 bg-opacity-0"
|
<MotionParticipantFormRow
|
||||||
onSubmit={(data) => {
|
transition={{ duration: 0.2 }}
|
||||||
return new Promise((resolve, reject) => {
|
initial={{ opacity: 0, height: 0 }}
|
||||||
addParticipant(data, {
|
animate={{ opacity: 1, height: 55, y: 0 }}
|
||||||
onSuccess: () => {
|
className="border-t bg-slate-100 bg-opacity-0"
|
||||||
setShouldShowNewParticipantForm(false);
|
onSubmit={(data) => {
|
||||||
resolve();
|
return new Promise((resolve, reject) => {
|
||||||
},
|
addParticipant(data, {
|
||||||
onError: reject,
|
onSuccess: () => {
|
||||||
|
setShouldShowNewParticipantForm(false);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
onError: reject,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}}
|
||||||
}}
|
options={poll.options}
|
||||||
options={poll.options}
|
onCancel={() => {
|
||||||
onCancel={() => {
|
setShouldShowNewParticipantForm(false);
|
||||||
setShouldShowNewParticipantForm(false);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
) : null}
|
||||||
) : null}
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 overflow-y-auto">
|
<div className="min-h-0 overflow-y-auto">
|
||||||
{participants.map((participant, i) => {
|
{participants.map((participant, i) => {
|
||||||
|
|
|
@ -4,13 +4,14 @@ import * as React from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
|
||||||
import CompactButton from "@/components/compact-button";
|
import CompactButton from "@/components/compact-button";
|
||||||
|
import Check from "@/components/icons/check.svg";
|
||||||
import X from "@/components/icons/x.svg";
|
import X from "@/components/icons/x.svg";
|
||||||
import { useSession } from "@/components/session";
|
|
||||||
|
|
||||||
import { requiredString } from "../../../utils/form-validation";
|
import { requiredString } from "../../../utils/form-validation";
|
||||||
import Button from "../../button";
|
import Button from "../../button";
|
||||||
import NameInput from "../../name-input";
|
import NameInput from "../../name-input";
|
||||||
import { ParticipantForm } from "../types";
|
import { ParticipantForm } from "../types";
|
||||||
|
import { VoteSelector } from "../vote-selector";
|
||||||
import ControlledScrollArea from "./controlled-scroll-area";
|
import ControlledScrollArea from "./controlled-scroll-area";
|
||||||
import { usePollContext } from "./poll-context";
|
import { usePollContext } from "./poll-context";
|
||||||
|
|
||||||
|
@ -22,173 +23,162 @@ export interface ParticipantRowFormProps {
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
|
const ParticipantRowForm: React.ForwardRefRenderFunction<
|
||||||
({ defaultValues, onSubmit, className, options, onCancel }) => {
|
HTMLFormElement,
|
||||||
const {
|
ParticipantRowFormProps
|
||||||
setActiveOptionId,
|
> = ({ defaultValues, onSubmit, className, options, onCancel }, ref) => {
|
||||||
activeOptionId,
|
const {
|
||||||
columnWidth,
|
setActiveOptionId,
|
||||||
scrollPosition,
|
activeOptionId,
|
||||||
sidebarWidth,
|
columnWidth,
|
||||||
numberOfColumns,
|
scrollPosition,
|
||||||
goToNextPage,
|
sidebarWidth,
|
||||||
setScrollPosition,
|
numberOfColumns,
|
||||||
} = usePollContext();
|
goToNextPage,
|
||||||
|
setScrollPosition,
|
||||||
|
} = usePollContext();
|
||||||
|
|
||||||
const session = useSession();
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
formState: { errors, submitCount, isSubmitting },
|
||||||
|
reset,
|
||||||
|
} = useForm<ParticipantForm>({
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
votes: [],
|
||||||
|
...defaultValues,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
React.useEffect(() => {
|
||||||
handleSubmit,
|
window.addEventListener("keydown", (e) => {
|
||||||
register,
|
if (e.key === "Escape") {
|
||||||
control,
|
onCancel?.();
|
||||||
formState: { errors, submitCount, isSubmitting },
|
}
|
||||||
reset,
|
|
||||||
} = useForm<ParticipantForm>({
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
votes: [],
|
|
||||||
...defaultValues,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const isColumnVisible = (index: number) => {
|
||||||
window.addEventListener("keydown", (e) => {
|
return scrollPosition + numberOfColumns * columnWidth > columnWidth * index;
|
||||||
if (e.key === "Escape") {
|
|
||||||
onCancel?.();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
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 h-14 shrink-0", className)}
|
|
||||||
>
|
|
||||||
{checkboxGroupHack}
|
|
||||||
<div className="flex items-center px-2" style={{ width: sidebarWidth }}>
|
|
||||||
<Controller
|
|
||||||
name="name"
|
|
||||||
rules={{
|
|
||||||
validate: requiredString,
|
|
||||||
}}
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="w-full">
|
|
||||||
<NameInput
|
|
||||||
autoFocus={!session.user}
|
|
||||||
className={clsx("w-full", {
|
|
||||||
"input-error animate-wiggle":
|
|
||||||
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();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
control={control}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ControlledScrollArea>
|
|
||||||
{options.map((option, index) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={option.id}
|
|
||||||
className={clsx(
|
|
||||||
"flex shrink-0 items-center justify-center 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;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
{...checkboxProps}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el) {
|
|
||||||
checkboxRefs.current[index] = el;
|
|
||||||
checkboxProps.ref(el);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
setActiveOptionId(option.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ControlledScrollArea>
|
|
||||||
<div className="flex items-center space-x-2 px-2 transition-all">
|
|
||||||
<Button
|
|
||||||
htmlType="submit"
|
|
||||||
type="primary"
|
|
||||||
loading={isSubmitting}
|
|
||||||
data-testid="submitNewParticipant"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<CompactButton onClick={onCancel} icon={X} />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ParticipantRowForm;
|
const checkboxRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
ref={ref}
|
||||||
|
onSubmit={handleSubmit(async ({ name, votes }) => {
|
||||||
|
await onSubmit({
|
||||||
|
name,
|
||||||
|
// no need to create votes for "no"
|
||||||
|
votes,
|
||||||
|
});
|
||||||
|
reset();
|
||||||
|
})}
|
||||||
|
className={clsx("flex h-14 shrink-0", className)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center px-2" style={{ width: sidebarWidth }}>
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
rules={{
|
||||||
|
validate: requiredString,
|
||||||
|
}}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="w-full">
|
||||||
|
<NameInput
|
||||||
|
autoFocus={true}
|
||||||
|
className={clsx("w-full", {
|
||||||
|
"input-error animate-wiggle": 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();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
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-gray-50": activeOptionId === option.id,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={{ width: columnWidth }}
|
||||||
|
onMouseOver={() => setActiveOptionId(option.id)}
|
||||||
|
onMouseOut={() => setActiveOptionId(null)}
|
||||||
|
>
|
||||||
|
<VoteSelector
|
||||||
|
value={value?.type}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (
|
||||||
|
e.code === "Tab" &&
|
||||||
|
index < options.length - 1 &&
|
||||||
|
!isColumnVisible(index + 1)
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
goToNextPage();
|
||||||
|
setTimeout(() => {
|
||||||
|
checkboxRefs.current[index + 1]?.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(vote) => {
|
||||||
|
const newValue = [...field.value];
|
||||||
|
newValue[index] = { optionId: option.id, type: vote };
|
||||||
|
field.onChange(newValue);
|
||||||
|
}}
|
||||||
|
ref={(el) => {
|
||||||
|
checkboxRefs.current[index] = el;
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setActiveOptionId(option.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<CompactButton onClick={onCancel} icon={X} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.forwardRef(ParticipantRowForm);
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { Option, Participant, Vote } from "@prisma/client";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import Badge from "@/components/badge";
|
|
||||||
import CompactButton from "@/components/compact-button";
|
import CompactButton from "@/components/compact-button";
|
||||||
import Pencil from "@/components/icons/pencil-alt.svg";
|
import Pencil from "@/components/icons/pencil-alt.svg";
|
||||||
import Trash from "@/components/icons/trash.svg";
|
import Trash from "@/components/icons/trash.svg";
|
||||||
|
@ -20,7 +19,7 @@ import { usePollContext } from "./poll-context";
|
||||||
export interface ParticipantRowProps {
|
export interface ParticipantRowProps {
|
||||||
urlId: string;
|
urlId: string;
|
||||||
participant: Participant & { votes: Vote[] };
|
participant: Participant & { votes: Vote[] };
|
||||||
options: Array<Option & { votes: Vote[] }>;
|
options: Option[];
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
onChangeEditMode?: (value: boolean) => void;
|
onChangeEditMode?: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
@ -41,7 +40,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
||||||
const confirmDeleteParticipant = useDeleteParticipantModal();
|
const confirmDeleteParticipant = useDeleteParticipantModal();
|
||||||
|
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const { poll } = usePoll();
|
const { poll, getVote } = usePoll();
|
||||||
|
|
||||||
const isYou = session.user && session.ownsObject(participant) ? true : false;
|
const isYou = session.user && session.ownsObject(participant) ? true : false;
|
||||||
|
|
||||||
|
@ -55,13 +54,15 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
||||||
<ParticipantRowForm
|
<ParticipantRowForm
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
name: participant.name,
|
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 }) => {
|
onSubmit={async ({ name, votes }) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
updateParticipantMutation(
|
updateParticipantMutation(
|
||||||
{
|
{
|
||||||
pollId: participant.pollId,
|
|
||||||
participantId: participant.id,
|
participantId: participant.id,
|
||||||
votes,
|
votes,
|
||||||
name,
|
name,
|
||||||
|
@ -86,7 +87,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
||||||
<div
|
<div
|
||||||
key={participant.id}
|
key={participant.id}
|
||||||
data-testid="participant-row"
|
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
|
<div
|
||||||
className="flex shrink-0 items-center px-4"
|
className="flex shrink-0 items-center px-4"
|
||||||
|
@ -99,7 +100,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
||||||
isYou={isYou}
|
isYou={isYou}
|
||||||
/>
|
/>
|
||||||
{canEdit ? (
|
{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
|
<CompactButton
|
||||||
icon={Pencil}
|
icon={Pencil}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -117,26 +118,21 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
|
||||||
</div>
|
</div>
|
||||||
<ControlledScrollArea>
|
<ControlledScrollArea>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
|
const vote = getVote(participant.id, option.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex shrink-0 items-center justify-center transition-colors",
|
"flex shrink-0 items-center justify-center transition-colors",
|
||||||
{
|
{
|
||||||
"bg-slate-50": activeOptionId === option.id,
|
"bg-gray-50": activeOptionId === option.id,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
style={{ width: columnWidth }}
|
style={{ width: columnWidth }}
|
||||||
onMouseOver={() => setActiveOptionId(option.id)}
|
onMouseOver={() => setActiveOptionId(option.id)}
|
||||||
onMouseOut={() => setActiveOptionId(null)}
|
onMouseOut={() => setActiveOptionId(null)}
|
||||||
>
|
>
|
||||||
{option.votes.some(
|
<VoteIcon type={vote} />
|
||||||
(vote) => vote.participantId === participant.id,
|
|
||||||
) ? (
|
|
||||||
<VoteIcon type="yes" />
|
|
||||||
) : (
|
|
||||||
<VoteIcon type="no" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -4,9 +4,9 @@ import * as React from "react";
|
||||||
import DateCard from "@/components/date-card";
|
import DateCard from "@/components/date-card";
|
||||||
import { usePoll } from "@/components/poll-context";
|
import { usePoll } from "@/components/poll-context";
|
||||||
|
|
||||||
|
import { ScoreSummary } from "../score-summary";
|
||||||
import ControlledScrollArea from "./controlled-scroll-area";
|
import ControlledScrollArea from "./controlled-scroll-area";
|
||||||
import { usePollContext } from "./poll-context";
|
import { usePollContext } from "./poll-context";
|
||||||
import Score from "./score";
|
|
||||||
|
|
||||||
const TimeRange: React.VoidFunctionComponent<{
|
const TimeRange: React.VoidFunctionComponent<{
|
||||||
startTime: string;
|
startTime: string;
|
||||||
|
@ -16,7 +16,7 @@ const TimeRange: React.VoidFunctionComponent<{
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -27,21 +27,21 @@ const TimeRange: React.VoidFunctionComponent<{
|
||||||
};
|
};
|
||||||
|
|
||||||
const PollHeader: React.VoidFunctionComponent = () => {
|
const PollHeader: React.VoidFunctionComponent = () => {
|
||||||
const { options, getParticipantsWhoVotedForOption, highScore } = usePoll();
|
const { options, getScore } = usePoll();
|
||||||
const { activeOptionId, setActiveOptionId, columnWidth } = usePollContext();
|
const { activeOptionId, setActiveOptionId, columnWidth } = usePollContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlledScrollArea>
|
<ControlledScrollArea>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const { optionId } = option;
|
const { optionId } = option;
|
||||||
const numVotes = getParticipantsWhoVotedForOption(optionId).length;
|
const numVotes = getScore(optionId);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={optionId}
|
key={optionId}
|
||||||
className={clsx(
|
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 }}
|
style={{ width: columnWidth }}
|
||||||
|
@ -53,14 +53,6 @@ const PollHeader: React.VoidFunctionComponent = () => {
|
||||||
day={option.day}
|
day={option.day}
|
||||||
dow={option.dow}
|
dow={option.dow}
|
||||||
month={option.month}
|
month={option.month}
|
||||||
annotation={
|
|
||||||
numVotes > 0 ? (
|
|
||||||
<Score
|
|
||||||
count={numVotes}
|
|
||||||
highlight={numVotes > 1 && highScore === numVotes}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{option.type === "timeSlot" ? (
|
{option.type === "timeSlot" ? (
|
||||||
|
@ -70,6 +62,13 @@ const PollHeader: React.VoidFunctionComponent = () => {
|
||||||
endTime={option.endTime}
|
endTime={option.endTime}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<ScoreSummary
|
||||||
|
yesScore={numVotes.yes}
|
||||||
|
ifNeedBeScore={numVotes.ifNeedBe}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</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 { Placement } from "@floating-ui/react-dom-interactions";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { Trans, useTranslation } from "next-i18next";
|
import { Trans, useTranslation } from "next-i18next";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { encodeDateOption } from "utils/date-time-utils";
|
import { encodeDateOption } from "utils/date-time-utils";
|
||||||
|
@ -17,6 +16,7 @@ import { PollDetailsForm } from "../forms";
|
||||||
import { useModal } from "../modal";
|
import { useModal } from "../modal";
|
||||||
import { useModalContext } from "../modal/modal-provider";
|
import { useModalContext } from "../modal/modal-provider";
|
||||||
import { usePoll } from "../poll-context";
|
import { usePoll } from "../poll-context";
|
||||||
|
import { useCsvExporter } from "./manage-poll/use-csv-exporter";
|
||||||
import { useUpdatePollMutation } from "./mutations";
|
import { useUpdatePollMutation } from "./mutations";
|
||||||
|
|
||||||
const PollOptionsForm = React.lazy(() => import("../forms/poll-options-form"));
|
const PollOptionsForm = React.lazy(() => import("../forms/poll-options-form"));
|
||||||
|
@ -25,7 +25,9 @@ const ManagePoll: React.VoidFunctionComponent<{
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
}> = ({ placement }) => {
|
}> = ({ placement }) => {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const { poll, options } = usePoll();
|
const { poll, getParticipantsWhoVotedForOption } = usePoll();
|
||||||
|
|
||||||
|
const { exportToCsv } = useCsvExporter();
|
||||||
|
|
||||||
const modalContext = useModalContext();
|
const modalContext = useModalContext();
|
||||||
|
|
||||||
|
@ -105,7 +107,8 @@ const ManagePoll: React.VoidFunctionComponent<{
|
||||||
};
|
};
|
||||||
|
|
||||||
const optionsToDeleteThatHaveVotes = optionsToDelete.filter(
|
const optionsToDeleteThatHaveVotes = optionsToDelete.filter(
|
||||||
(option) => option.votes.length > 0,
|
(option) =>
|
||||||
|
getParticipantsWhoVotedForOption(option.id).length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (optionsToDeleteThatHaveVotes.length > 0) {
|
if (optionsToDeleteThatHaveVotes.length > 0) {
|
||||||
|
@ -181,55 +184,7 @@ const ManagePoll: React.VoidFunctionComponent<{
|
||||||
label="Edit options"
|
label="Edit options"
|
||||||
onClick={handleChangeOptions}
|
onClick={handleChangeOptions}
|
||||||
/>
|
/>
|
||||||
<DropdownItem
|
<DropdownItem icon={Save} label="Export to CSV" onClick={exportToCsv} />
|
||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{poll.closed ? (
|
{poll.closed ? (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
icon={LockOpen}
|
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 { Listbox } from "@headlessui/react";
|
||||||
import { Participant, Vote } from "@prisma/client";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
|
@ -7,6 +6,7 @@ import * as React from "react";
|
||||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||||
import smoothscroll from "smoothscroll-polyfill";
|
import smoothscroll from "smoothscroll-polyfill";
|
||||||
|
|
||||||
|
import Check from "@/components/icons/check.svg";
|
||||||
import ChevronDown from "@/components/icons/chevron-down.svg";
|
import ChevronDown from "@/components/icons/chevron-down.svg";
|
||||||
import Pencil from "@/components/icons/pencil-alt.svg";
|
import Pencil from "@/components/icons/pencil-alt.svg";
|
||||||
import PlusCircle from "@/components/icons/plus-circle.svg";
|
import PlusCircle from "@/components/icons/plus-circle.svg";
|
||||||
|
@ -37,17 +37,11 @@ if (typeof window !== "undefined") {
|
||||||
const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
const pollContext = usePoll();
|
const pollContext = usePoll();
|
||||||
|
|
||||||
const { poll, targetTimeZone, setTargetTimeZone } = pollContext;
|
const { poll, targetTimeZone, setTargetTimeZone, getParticipantById } =
|
||||||
|
pollContext;
|
||||||
|
|
||||||
const { timeZone, participants, role } = poll;
|
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 session = useSession();
|
||||||
|
|
||||||
const form = useForm<ParticipantForm>({
|
const form = useForm<ParticipantForm>({
|
||||||
|
@ -62,7 +56,7 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
React.useState<string>();
|
React.useState<string>();
|
||||||
|
|
||||||
const selectedParticipant = selectedParticipantId
|
const selectedParticipant = selectedParticipantId
|
||||||
? participantById[selectedParticipantId]
|
? getParticipantById(selectedParticipantId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = React.useState(false);
|
const [isEditing, setIsEditing] = React.useState(false);
|
||||||
|
@ -111,6 +105,7 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form
|
<form
|
||||||
|
@ -122,7 +117,6 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
updateParticipantMutation(
|
updateParticipantMutation(
|
||||||
{
|
{
|
||||||
participantId: selectedParticipant.id,
|
participantId: selectedParticipant.id,
|
||||||
pollId,
|
|
||||||
...data,
|
...data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -150,7 +144,9 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<Listbox
|
<Listbox
|
||||||
value={selectedParticipantId}
|
value={selectedParticipantId}
|
||||||
onChange={setSelectedParticipantId}
|
onChange={(participantId) => {
|
||||||
|
setSelectedParticipantId(participantId);
|
||||||
|
}}
|
||||||
disabled={isEditing}
|
disabled={isEditing}
|
||||||
>
|
>
|
||||||
<div className="menu min-w-0 grow">
|
<div className="menu min-w-0 grow">
|
||||||
|
@ -217,9 +213,10 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
reset({
|
reset({
|
||||||
name: selectedParticipant.name,
|
name: selectedParticipant.name,
|
||||||
votes: selectedParticipant.votes.map(
|
votes: selectedParticipant.votes.map((vote) => ({
|
||||||
(vote) => vote.optionId,
|
optionId: vote.optionId,
|
||||||
),
|
type: vote.type,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -241,7 +238,10 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusCircle />}
|
icon={<PlusCircle />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
reset({ name: "", votes: [] });
|
reset({
|
||||||
|
name: "",
|
||||||
|
votes: [],
|
||||||
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -269,6 +269,8 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
switch (pollContext.pollType) {
|
switch (pollContext.pollType) {
|
||||||
|
// we pass poll options as props since we are
|
||||||
|
// discriminating on poll type here
|
||||||
case "date":
|
case "date":
|
||||||
return (
|
return (
|
||||||
<PollOptions
|
<PollOptions
|
||||||
|
@ -347,7 +349,7 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
icon={<Save />}
|
icon={<Check />}
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
type="primary"
|
type="primary"
|
||||||
loading={formState.isSubmitting}
|
loading={formState.isSubmitting}
|
||||||
|
|
|
@ -1,18 +1,20 @@
|
||||||
import { Participant } from "@prisma/client";
|
import { Participant, VoteType } from "@prisma/client";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { ScoreSummary } from "../score-summary";
|
||||||
import UserAvatar from "../user-avatar";
|
import UserAvatar from "../user-avatar";
|
||||||
import VoteIcon from "../vote-icon";
|
import VoteIcon from "../vote-icon";
|
||||||
import PopularityScore from "./popularity-score";
|
import { VoteSelector } from "../vote-selector";
|
||||||
|
|
||||||
export interface PollOptionProps {
|
export interface PollOptionProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
numberOfVotes: number;
|
yesScore: number;
|
||||||
|
ifNeedBeScore: number;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
vote?: "yes" | "no";
|
vote?: VoteType;
|
||||||
onChange: (vote: "yes" | "no") => void;
|
onChange: (vote: VoteType) => void;
|
||||||
participants: Participant[];
|
participants: Participant[];
|
||||||
selectedParticipantId?: string;
|
selectedParticipantId?: string;
|
||||||
}
|
}
|
||||||
|
@ -66,39 +68,31 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
|
||||||
onChange,
|
onChange,
|
||||||
participants,
|
participants,
|
||||||
editable,
|
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 showVotes = !!(selectedParticipantId || editable);
|
||||||
|
|
||||||
|
const selectorRef = React.useRef<HTMLButtonElement>(null);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="poll-option"
|
data-testid="poll-option"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(vote === "yes" ? "no" : "yes");
|
if (selectorRef.current) {
|
||||||
|
selectorRef.current.click();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
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,
|
"active:bg-slate-400/5": editable,
|
||||||
"bg-indigo-50/50": editable && vote === "yes",
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none flex grow items-center">
|
<div className="flex grow items-center">
|
||||||
<div className="grow">{children}</div>
|
<div className="grow">{children}</div>
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<PopularityScore score={numberOfVotes + difference} />
|
<ScoreSummary yesScore={yesScore} ifNeedBeScore={ifNeedBeScore} />
|
||||||
{participants.length > 0 ? (
|
{participants.length > 0 ? (
|
||||||
<div className="mt-1 -mr-1">
|
<div className="mt-1 -mr-1">
|
||||||
<div className="-space-x-1">
|
<div className="-space-x-1">
|
||||||
|
@ -127,27 +121,20 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
|
||||||
expanded={showVotes}
|
expanded={showVotes}
|
||||||
className="relative flex h-12 items-center justify-center rounded-lg"
|
className="relative flex h-12 items-center justify-center rounded-lg"
|
||||||
>
|
>
|
||||||
<AnimatePresence>
|
{editable ? (
|
||||||
{editable ? (
|
<div className="flex h-full w-14 items-center justify-center">
|
||||||
<PopInOut className="h-full">
|
<VoteSelector ref={selectorRef} value={vote} onChange={onChange} />
|
||||||
<div className="flex h-full w-14 items-center justify-center">
|
</div>
|
||||||
<input
|
) : vote ? (
|
||||||
readOnly={true}
|
<AnimatePresence initial={false}>
|
||||||
type="checkbox"
|
|
||||||
className="checkbox"
|
|
||||||
checked={vote === "yes"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PopInOut>
|
|
||||||
) : vote ? (
|
|
||||||
<PopInOut
|
<PopInOut
|
||||||
key={vote}
|
key={vote}
|
||||||
className="absolute inset-0 flex h-full w-full items-center justify-center"
|
className="absolute inset-0 flex h-full w-full items-center justify-center"
|
||||||
>
|
>
|
||||||
<VoteIcon type={vote} />
|
<VoteIcon type={vote} />
|
||||||
</PopInOut>
|
</PopInOut>
|
||||||
) : null}
|
</AnimatePresence>
|
||||||
</AnimatePresence>
|
) : null}
|
||||||
</CollapsibleContainer>
|
</CollapsibleContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { VoteType } from "@prisma/client";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import { ParsedDateTimeOpton } from "utils/date-time-utils";
|
import { ParsedDateTimeOpton } from "utils/date-time-utils";
|
||||||
|
@ -20,43 +21,39 @@ const PollOptions: React.VoidFunctionComponent<PollOptions> = ({
|
||||||
selectedParticipantId,
|
selectedParticipantId,
|
||||||
}) => {
|
}) => {
|
||||||
const { control } = useFormContext<ParticipantForm>();
|
const { control } = useFormContext<ParticipantForm>();
|
||||||
const { getParticipantsWhoVotedForOption, getVote, getParticipantById } =
|
const {
|
||||||
usePoll();
|
getParticipantsWhoVotedForOption,
|
||||||
|
getParticipantById,
|
||||||
|
getScore,
|
||||||
|
getVote,
|
||||||
|
} = usePoll();
|
||||||
const selectedParticipant = selectedParticipantId
|
const selectedParticipant = selectedParticipantId
|
||||||
? getParticipantById(selectedParticipantId)
|
? getParticipantById(selectedParticipantId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{options.map((option) => {
|
{options.map((option, index) => {
|
||||||
const participants = getParticipantsWhoVotedForOption(option.optionId);
|
const participants = getParticipantsWhoVotedForOption(option.optionId);
|
||||||
|
const score = getScore(option.optionId);
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
key={option.optionId}
|
key={option.optionId}
|
||||||
control={control}
|
control={control}
|
||||||
name="votes"
|
name="votes"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const vote = editable
|
const vote =
|
||||||
? field.value.includes(option.optionId)
|
!editable && selectedParticipant
|
||||||
? "yes"
|
? getVote(selectedParticipant.id, option.optionId)
|
||||||
: "no"
|
: field.value[index]?.type;
|
||||||
: selectedParticipant
|
|
||||||
? getVote(selectedParticipant.id, option.optionId)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const handleChange = (newVote: "yes" | "no") => {
|
const handleChange = (newVote: VoteType) => {
|
||||||
if (!editable) {
|
if (!editable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newVote === "no") {
|
const newValue = [...field.value];
|
||||||
field.onChange(
|
newValue[index] = { optionId: option.optionId, type: newVote };
|
||||||
field.value.filter(
|
field.onChange(newValue);
|
||||||
(optionId) => optionId !== option.optionId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
field.onChange([...field.value, option.optionId]);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (option.type) {
|
switch (option.type) {
|
||||||
|
@ -64,7 +61,8 @@ const PollOptions: React.VoidFunctionComponent<PollOptions> = ({
|
||||||
return (
|
return (
|
||||||
<TimeSlotOption
|
<TimeSlotOption
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
numberOfVotes={participants.length}
|
yesScore={score.yes}
|
||||||
|
ifNeedBeScore={score.ifNeedBe}
|
||||||
participants={participants}
|
participants={participants}
|
||||||
vote={vote}
|
vote={vote}
|
||||||
startTime={option.startTime}
|
startTime={option.startTime}
|
||||||
|
@ -78,7 +76,8 @@ const PollOptions: React.VoidFunctionComponent<PollOptions> = ({
|
||||||
return (
|
return (
|
||||||
<DateOption
|
<DateOption
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
numberOfVotes={participants.length}
|
yesScore={score.yes}
|
||||||
|
ifNeedBeScore={score.ifNeedBe}
|
||||||
participants={participants}
|
participants={participants}
|
||||||
vote={vote}
|
vote={vote}
|
||||||
dow={option.dow}
|
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,
|
DeleteParticipantPayload,
|
||||||
} from "../../api-client/delete-participant";
|
} from "../../api-client/delete-participant";
|
||||||
import { GetPollResponse } from "../../api-client/get-poll";
|
import { GetPollResponse } from "../../api-client/get-poll";
|
||||||
import {
|
import { updateParticipant } from "../../api-client/update-participant";
|
||||||
updateParticipant,
|
|
||||||
UpdateParticipantPayload,
|
|
||||||
} from "../../api-client/update-participant";
|
|
||||||
import { usePoll } from "../poll-context";
|
import { usePoll } from "../poll-context";
|
||||||
import { useSession } from "../session";
|
import { useSession } from "../session";
|
||||||
import { ParticipantForm } from "./types";
|
import { ParticipantForm } from "./types";
|
||||||
|
@ -20,12 +17,17 @@ export const useAddParticipantMutation = (pollId: string) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const plausible = usePlausible();
|
const plausible = usePlausible();
|
||||||
|
const { options } = usePoll();
|
||||||
|
|
||||||
return useMutation(
|
return useMutation(
|
||||||
(payload: ParticipantForm) =>
|
(payload: ParticipantForm) =>
|
||||||
addParticipant({
|
addParticipant({
|
||||||
pollId,
|
pollId,
|
||||||
name: payload.name.trim(),
|
name: payload.name.trim(),
|
||||||
votes: payload.votes,
|
votes: options.map(
|
||||||
|
(option, i) =>
|
||||||
|
payload.votes[i] ?? { optionId: option.optionId, type: "no" },
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
onSuccess: (participant) => {
|
onSuccess: (participant) => {
|
||||||
|
@ -39,15 +41,6 @@ export const useAddParticipantMutation = (pollId: string) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
poll.participants = [participant, ...poll.participants];
|
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;
|
return poll;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -60,18 +53,38 @@ export const useAddParticipantMutation = (pollId: string) => {
|
||||||
export const useUpdateParticipantMutation = (pollId: string) => {
|
export const useUpdateParticipantMutation = (pollId: string) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const plausible = usePlausible();
|
const plausible = usePlausible();
|
||||||
|
const { options } = usePoll();
|
||||||
|
|
||||||
return useMutation(
|
return useMutation(
|
||||||
(payload: UpdateParticipantPayload) =>
|
(payload: ParticipantForm & { participantId: string }) =>
|
||||||
updateParticipant({
|
updateParticipant({
|
||||||
pollId,
|
pollId,
|
||||||
participantId: payload.participantId,
|
participantId: payload.participantId,
|
||||||
name: payload.name.trim(),
|
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");
|
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: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries(["getPoll", pollId]);
|
queryClient.invalidateQueries(["getPoll", pollId]);
|
||||||
|
@ -83,9 +96,28 @@ export const useUpdateParticipantMutation = (pollId: string) => {
|
||||||
export const useDeleteParticipantMutation = () => {
|
export const useDeleteParticipantMutation = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const plausible = usePlausible();
|
const plausible = usePlausible();
|
||||||
|
const { poll } = usePoll();
|
||||||
return useMutation(
|
return useMutation(
|
||||||
(payload: DeleteParticipantPayload) => deleteParticipant(payload),
|
(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: () => {
|
onSuccess: () => {
|
||||||
plausible("Remove participant");
|
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 {
|
export interface ParticipantForm {
|
||||||
name: string;
|
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 {
|
export interface PollProps {
|
||||||
pollId: string;
|
pollId: string;
|
||||||
highScore: number;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,27 @@
|
||||||
|
import { VoteType } from "@prisma/client";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import CheckCircle from "@/components/icons/check-circle.svg";
|
import CheckCircle from "@/components/icons/check-circle.svg";
|
||||||
|
import IfNeedBe from "@/components/icons/if-need-be.svg";
|
||||||
|
|
||||||
const VoteIcon: React.VoidFunctionComponent<{
|
const VoteIcon: React.VoidFunctionComponent<{
|
||||||
type: "yes" | "no";
|
type?: VoteType;
|
||||||
}> = ({ type }) => {
|
}> = ({ type }) => {
|
||||||
if (type === "yes") {
|
switch (type) {
|
||||||
return <CheckCircle className="h-5 w-5 text-green-400" />;
|
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;
|
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 (
|
return (
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<a>
|
<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>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
@ -40,7 +40,7 @@ const MobileNavigation: React.VoidFunctionComponent<{
|
||||||
}> = ({ openLoginModal }) => {
|
}> = ({ openLoginModal }) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
return (
|
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>
|
<div>
|
||||||
<HomeLink />
|
<HomeLink />
|
||||||
</div>
|
</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="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<link
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||||
rel="stylesheet"
|
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" />
|
<meta name="theme-color" content="#f9fafb" />
|
||||||
</Head>
|
</Head>
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { VoteType } from "@prisma/client";
|
||||||
import { GetPollApiResponse } from "api-client/get-poll";
|
import { GetPollApiResponse } from "api-client/get-poll";
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { getQueryParam } from "utils/api-utils";
|
import { getQueryParam } from "utils/api-utils";
|
||||||
|
@ -50,18 +51,21 @@ export default async function handler(
|
||||||
id: legacyParticipant._id.toString(),
|
id: legacyParticipant._id.toString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const votes: Array<{ optionId: string; participantId: string }> = [];
|
const votes: Array<{
|
||||||
|
optionId: string;
|
||||||
|
participantId: string;
|
||||||
|
type: VoteType;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
newParticipants?.forEach((p, i) => {
|
newParticipants?.forEach((p, i) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const legacyVotes = legacyPoll.participants![i].votes;
|
const legacyVotes = legacyPoll.participants![i].votes;
|
||||||
legacyVotes?.forEach((v, j) => {
|
legacyVotes?.forEach((v, j) => {
|
||||||
if (v) {
|
votes.push({
|
||||||
votes.push({
|
optionId: newOptions[j].id,
|
||||||
optionId: newOptions[j].id,
|
participantId: p.id,
|
||||||
participantId: p.id,
|
type: v ? "yes" : "no",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -133,9 +137,6 @@ export default async function handler(
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
options: {
|
options: {
|
||||||
include: {
|
|
||||||
votes: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
orderBy: {
|
||||||
value: "asc",
|
value: "asc",
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,9 +2,9 @@ import { sendEmailTemplate } from "utils/api-utils";
|
||||||
import { createToken, withSessionRoute } from "utils/auth";
|
import { createToken, withSessionRoute } from "utils/auth";
|
||||||
import { nanoid } from "utils/nanoid";
|
import { nanoid } from "utils/nanoid";
|
||||||
|
|
||||||
import { CreatePollPayload } from "../../../api-client/create-poll";
|
import { CreatePollPayload } from "../../api-client/create-poll";
|
||||||
import { prisma } from "../../../db";
|
import { prisma } from "../../db";
|
||||||
import absoluteUrl from "../../../utils/absolute-url";
|
import absoluteUrl from "../../utils/absolute-url";
|
||||||
|
|
||||||
export default withSessionRoute(async (req, res) => {
|
export default withSessionRoute(async (req, res) => {
|
||||||
switch (req.method) {
|
switch (req.method) {
|
|
@ -1,9 +1,9 @@
|
||||||
import { GetPollApiResponse } from "api-client/get-poll";
|
import { GetPollApiResponse } from "api-client/get-poll";
|
||||||
import { resetDates } from "utils/legacy-utils";
|
import { resetDates } from "utils/legacy-utils";
|
||||||
|
|
||||||
import { UpdatePollPayload } from "../../../../api-client/update-poll";
|
import { UpdatePollPayload } from "../../../api-client/update-poll";
|
||||||
import { prisma } from "../../../../db";
|
import { prisma } from "../../../db";
|
||||||
import { withLink } from "../../../../utils/api-utils";
|
import { withLink } from "../../../utils/api-utils";
|
||||||
|
|
||||||
export default withLink<
|
export default withLink<
|
||||||
GetPollApiResponse | { status: number; message: string }
|
GetPollApiResponse | { status: number; message: string }
|
||||||
|
@ -18,9 +18,6 @@ export default withLink<
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
options: {
|
options: {
|
||||||
include: {
|
|
||||||
votes: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
orderBy: {
|
||||||
value: "asc",
|
value: "asc",
|
||||||
},
|
},
|
||||||
|
@ -111,9 +108,6 @@ export default withLink<
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
options: {
|
options: {
|
||||||
include: {
|
|
||||||
votes: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
orderBy: {
|
||||||
value: "asc",
|
value: "asc",
|
||||||
},
|
},
|
|
@ -1,7 +1,7 @@
|
||||||
import { createGuestUser, withSessionRoute } from "utils/auth";
|
import { createGuestUser, withSessionRoute } from "utils/auth";
|
||||||
|
|
||||||
import { prisma } from "../../../../../db";
|
import { prisma } from "../../../../db";
|
||||||
import { sendNotification, withLink } from "../../../../../utils/api-utils";
|
import { sendNotification, withLink } from "../../../../utils/api-utils";
|
||||||
|
|
||||||
export default withSessionRoute(
|
export default withSessionRoute(
|
||||||
withLink(async ({ req, res, link }) => {
|
withLink(async ({ req, res, link }) => {
|
|
@ -1,8 +1,8 @@
|
||||||
import { createGuestUser, withSessionRoute } from "utils/auth";
|
import { createGuestUser, withSessionRoute } from "utils/auth";
|
||||||
|
|
||||||
import { AddParticipantPayload } from "../../../../../api-client/add-participant";
|
import { AddParticipantPayload } from "../../../../api-client/add-participant";
|
||||||
import { prisma } from "../../../../../db";
|
import { prisma } from "../../../../db";
|
||||||
import { sendNotification, withLink } from "../../../../../utils/api-utils";
|
import { sendNotification, withLink } from "../../../../utils/api-utils";
|
||||||
|
|
||||||
export default withSessionRoute(
|
export default withSessionRoute(
|
||||||
withLink(async ({ req, res, link }) => {
|
withLink(async ({ req, res, link }) => {
|
||||||
|
@ -28,8 +28,9 @@ export default withSessionRoute(
|
||||||
: undefined,
|
: undefined,
|
||||||
votes: {
|
votes: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: payload.votes.map((optionId) => ({
|
data: payload.votes.map(({ optionId, type }) => ({
|
||||||
optionId,
|
optionId,
|
||||||
|
type,
|
||||||
pollId: link.pollId,
|
pollId: link.pollId,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { UpdateParticipantPayload } from "api-client/update-participant";
|
||||||
|
|
||||||
import { prisma } from "../../../../../db";
|
import { prisma } from "../../../../../db";
|
||||||
import { getQueryParam, withLink } from "../../../../../utils/api-utils";
|
import { getQueryParam, withLink } from "../../../../../utils/api-utils";
|
||||||
|
|
||||||
|
@ -7,7 +9,9 @@ export default withLink(async ({ req, res, link }) => {
|
||||||
const pollId = link.pollId;
|
const pollId = link.pollId;
|
||||||
switch (req.method) {
|
switch (req.method) {
|
||||||
case "PATCH":
|
case "PATCH":
|
||||||
await prisma.participant.update({
|
const payload: UpdateParticipantPayload = req.body;
|
||||||
|
|
||||||
|
const participant = await prisma.participant.update({
|
||||||
where: {
|
where: {
|
||||||
id_pollId: {
|
id_pollId: {
|
||||||
id: participantId,
|
id: participantId,
|
||||||
|
@ -20,17 +24,21 @@ export default withLink(async ({ req, res, link }) => {
|
||||||
pollId,
|
pollId,
|
||||||
},
|
},
|
||||||
createMany: {
|
createMany: {
|
||||||
data: req.body.votes.map((optionId: string) => ({
|
data: payload.votes.map(({ optionId, type }) => ({
|
||||||
optionId,
|
optionId,
|
||||||
|
type,
|
||||||
pollId,
|
pollId,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
name: req.body.name,
|
name: req.body.name,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
votes: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.end();
|
return res.json(participant);
|
||||||
|
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
await prisma.participant.delete({
|
await prisma.participant.delete({
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { VoteType } from "@prisma/client";
|
||||||
import { addMinutes } from "date-fns";
|
import { addMinutes } from "date-fns";
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
import absoluteUrl from "utils/absolute-url";
|
import absoluteUrl from "utils/absolute-url";
|
||||||
|
@ -5,22 +6,22 @@ import { nanoid } from "utils/nanoid";
|
||||||
|
|
||||||
import { prisma } from "../../../db";
|
import { prisma } from "../../../db";
|
||||||
|
|
||||||
const participantData = [
|
const participantData: Array<{ name: string; votes: VoteType[] }> = [
|
||||||
{
|
{
|
||||||
name: "Reed",
|
name: "Reed",
|
||||||
votes: [0, 2],
|
votes: ["yes", "no", "ifNeedBe", "no"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Susan",
|
name: "Susan",
|
||||||
votes: [0, 1, 2],
|
votes: ["yes", "yes", "yes", "no"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Johnny",
|
name: "Johnny",
|
||||||
votes: [2, 3],
|
votes: ["no", "no", "yes", "yes"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Ben",
|
name: "Ben",
|
||||||
votes: [0, 1, 2, 3],
|
votes: ["yes", "yes", "yes", "yes"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -49,7 +50,11 @@ export default async function handler(
|
||||||
createdAt: Date;
|
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++) {
|
for (let i = 0; i < participantData.length; i++) {
|
||||||
const { name, votes: participantVotes } = participantData[i];
|
const { name, votes: participantVotes } = participantData[i];
|
||||||
|
@ -61,9 +66,12 @@ export default async function handler(
|
||||||
createdAt: addMinutes(today, i * -1),
|
createdAt: addMinutes(today, i * -1),
|
||||||
});
|
});
|
||||||
|
|
||||||
participantVotes.forEach((voteIndex) => {
|
options.forEach((option, index) => {
|
||||||
const option = options[voteIndex];
|
votes.push({
|
||||||
votes.push({ optionId: option.id, participantId });
|
optionId: option.id,
|
||||||
|
participantId,
|
||||||
|
type: participantVotes[index],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { withSessionSsr } from "utils/auth";
|
||||||
|
|
||||||
import ErrorPage from "@/components/error-page";
|
import ErrorPage from "@/components/error-page";
|
||||||
import FullPageLoader from "@/components/full-page-loader";
|
import FullPageLoader from "@/components/full-page-loader";
|
||||||
|
import { PollContextProvider } from "@/components/poll-context";
|
||||||
import { SessionProps, withSession } from "@/components/session";
|
import { SessionProps, withSession } from "@/components/session";
|
||||||
|
|
||||||
import { GetPollResponse } from "../api-client/get-poll";
|
import { GetPollResponse } from "../api-client/get-poll";
|
||||||
|
@ -74,7 +75,11 @@ const PollPageLoader: NextPage<SessionProps> = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (poll) {
|
if (poll) {
|
||||||
return <PollPage poll={poll} />;
|
return (
|
||||||
|
<PollContextProvider value={poll}>
|
||||||
|
<PollPage />
|
||||||
|
</PollContextProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (didError) {
|
if (didError) {
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
"voteCount_other": "{{count}} votes",
|
"voteCount_other": "{{count}} votes",
|
||||||
"participantCount": "{{count}} participant",
|
"participantCount": "{{count}} participant",
|
||||||
"participantCount_other": "{{count}} participants",
|
"participantCount_other": "{{count}} participants",
|
||||||
"createdBy": "Created by <b>{{name}}</b>",
|
"createdBy": "by <b>{{name}}</b>",
|
||||||
"timeZone": "Time Zone:",
|
"timeZone": "Time Zone:",
|
||||||
"creatingDemo": "Creating demo poll…",
|
"creatingDemo": "Creating demo poll…",
|
||||||
"ok": "Ok",
|
"ok": "Ok",
|
||||||
|
@ -49,5 +49,8 @@
|
||||||
"monday": "Monday",
|
"monday": "Monday",
|
||||||
"sunday": "Sunday",
|
"sunday": "Sunday",
|
||||||
"12h": "12-hour",
|
"12h": "12-hour",
|
||||||
"24h": "24-hour"
|
"24h": "24-hour",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"ifNeedBe": "If need be"
|
||||||
}
|
}
|
||||||
|
|
116
schema.prisma
116
schema.prisma
|
@ -11,105 +11,129 @@ model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
email String @unique() @db.Citext
|
email String @unique() @db.Citext
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime? @updatedAt
|
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||||
polls Poll[]
|
polls Poll[]
|
||||||
participants Participant[]
|
participants Participant[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PollType {
|
enum PollType {
|
||||||
date
|
date
|
||||||
|
|
||||||
|
@@map("poll_type")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Poll {
|
model Poll {
|
||||||
urlId String @id @unique
|
urlId String @id @unique @map("url_id")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
deadline DateTime?
|
deadline DateTime?
|
||||||
title String
|
title String
|
||||||
type PollType
|
type PollType
|
||||||
description String?
|
description String?
|
||||||
location String?
|
location String?
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
userId String
|
userId String @map("user_id")
|
||||||
votes Vote[]
|
votes Vote[]
|
||||||
timeZone String?
|
timeZone String? @map("time_zone")
|
||||||
verified Boolean @default(false)
|
verified Boolean @default(false)
|
||||||
options Option[]
|
options Option[]
|
||||||
participants Participant[]
|
participants Participant[]
|
||||||
authorName String @default("")
|
authorName String @default("") @map("author_name")
|
||||||
demo Boolean @default(false)
|
demo Boolean @default(false)
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
links Link[]
|
links Link[]
|
||||||
legacy Boolean @default(false)
|
legacy Boolean @default(false)
|
||||||
closed Boolean @default(false)
|
closed Boolean @default(false)
|
||||||
notifications Boolean @default(false)
|
notifications Boolean @default(false)
|
||||||
|
|
||||||
|
@@map("polls")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
admin
|
admin
|
||||||
participant
|
participant
|
||||||
|
|
||||||
|
@@map("role")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Link {
|
model Link {
|
||||||
urlId String @id @unique
|
urlId String @id @unique @map("url_id")
|
||||||
role Role
|
role Role
|
||||||
pollId String
|
pollId String @map("poll_id")
|
||||||
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@unique([pollId, role])
|
@@unique([pollId, role])
|
||||||
|
@@map("links")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Participant {
|
model Participant {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId String?
|
userId String? @map("user_id")
|
||||||
guestId String?
|
guestId String? @map("guest_id")
|
||||||
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
||||||
pollId String
|
pollId String @map("poll_id")
|
||||||
votes Vote[]
|
votes Vote[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime? @updatedAt
|
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@unique([id, pollId])
|
@@unique([id, pollId])
|
||||||
|
@@map("participants")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Option {
|
model Option {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
value String
|
value String
|
||||||
pollId String
|
pollId String @map("poll_id")
|
||||||
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime? @updatedAt
|
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||||
votes Vote[]
|
votes Vote[]
|
||||||
|
|
||||||
|
@@map("options")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VoteType {
|
||||||
|
yes
|
||||||
|
no
|
||||||
|
ifNeedBe
|
||||||
|
|
||||||
|
@@map("vote_type")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Vote {
|
model Vote {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
|
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)
|
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)
|
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
||||||
pollId String
|
pollId String @map("poll_id")
|
||||||
createdAt DateTime @default(now())
|
type VoteType @default(yes)
|
||||||
updatedAt DateTime? @updatedAt
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("votes")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
content String
|
content String
|
||||||
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
|
||||||
pollId String
|
pollId String @map("poll_id")
|
||||||
authorName String
|
authorName String @map("author_name")
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId String?
|
userId String? @map("user_id")
|
||||||
guestId String?
|
guestId String? @map("guest_id")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime? @updatedAt
|
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@unique([id, pollId])
|
@@unique([id, pollId])
|
||||||
|
@@map("comments")
|
||||||
}
|
}
|
||||||
|
|
23
style.css
23
style.css
|
@ -32,13 +32,13 @@
|
||||||
@apply outline-none;
|
@apply outline-none;
|
||||||
}
|
}
|
||||||
a {
|
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 {
|
label {
|
||||||
@apply mb-1 block text-sm text-slate-800;
|
@apply mb-1 block text-sm text-slate-800;
|
||||||
}
|
}
|
||||||
button {
|
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 {
|
#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;
|
@apply h-4 w-4 rounded border-slate-300 text-indigo-500 shadow-sm focus:ring-indigo-500;
|
||||||
}
|
}
|
||||||
.btn {
|
.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 {
|
a.btn {
|
||||||
@apply hover:no-underline;
|
@apply hover:no-underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-default {
|
.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 {
|
.btn-danger {
|
||||||
@apply btn border-rose-600 bg-rose-500 text-white hover:bg-rose-600 focus-visible:ring-rose-500;
|
@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 {
|
.btn-link {
|
||||||
@apply inline-flex items-center text-indigo-500 underline;
|
@apply inline-flex items-center text-indigo-500 underline;
|
||||||
}
|
}
|
||||||
.btn-default.btn-disabled {
|
.btn.btn-disabled {
|
||||||
@apply bg-gray-50;
|
text-shadow: none;
|
||||||
}
|
@apply pointer-events-none border-gray-200 bg-slate-500/5 text-gray-400 shadow-none;
|
||||||
.btn-disabled {
|
|
||||||
@apply pointer-events-none;
|
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
text-shadow: rgb(0 0 0 / 20%) 0px 1px 1px;
|
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;
|
@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 {
|
a.btn-primary {
|
||||||
@apply text-white;
|
@apply text-white;
|
||||||
}
|
}
|
||||||
|
@ -104,7 +99,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment-button button {
|
.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 {
|
.segment-button .segment-button-active {
|
||||||
|
|
|
@ -35,7 +35,8 @@ module.exports = {
|
||||||
xs: "375px",
|
xs: "375px",
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["Inter var", ...defaultTheme.fontFamily.sans],
|
sans: ["Inter", ...defaultTheme.fontFamily.sans],
|
||||||
|
mono: [...defaultTheme.fontFamily.mono],
|
||||||
},
|
},
|
||||||
transitionTimingFunction: {
|
transitionTimingFunction: {
|
||||||
"in-expo": "cubic-bezier(0.68, -0.6, 0.32, 1.6)",
|
"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");
|
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
|
// 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.
|
// when we only have a single option/checkbox.
|
||||||
await page.locator('[name="votes"] >> nth=1').click();
|
await page.locator("data-testid=vote-selector >> nth=0").click();
|
||||||
await page.locator('[name="votes"] >> nth=3').click();
|
await page.locator("data-testid=vote-selector >> nth=2").click();
|
||||||
await page.click('[data-testid="submitNewParticipant"]');
|
await page.click('[data-testid="submitNewParticipant"]');
|
||||||
await expect(page.locator("text='Test user'")).toBeVisible();
|
await expect(page.locator("text='Test user'")).toBeVisible();
|
||||||
await expect(page.locator("text=Guest")).toBeVisible();
|
await expect(page.locator("text=Guest")).toBeVisible();
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { ObjectId } from "mongodb";
|
import { ObjectId } from "mongodb";
|
||||||
import { getMongoClient } from "./mongodb-client";
|
|
||||||
import { prisma } from "../db";
|
import { prisma } from "../db";
|
||||||
|
import { getMongoClient } from "./mongodb-client";
|
||||||
|
|
||||||
export interface LegacyPoll {
|
export interface LegacyPoll {
|
||||||
__private: {
|
__private: {
|
||||||
|
@ -91,8 +92,8 @@ export const resetDates = async (legacyPollId: string) => {
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
options: {
|
options: {
|
||||||
include: {
|
orderBy: {
|
||||||
votes: true,
|
value: "asc",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
participants: {
|
participants: {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue