If need be (#168)

This commit is contained in:
Luke Vella 2022-05-13 08:44:35 +01:00 committed by GitHub
parent 6375e80641
commit 17dc9519d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1033 additions and 642 deletions

3
.gitignore vendored
View file

@ -40,3 +40,6 @@ yarn-error.log*
# playwright
/playwright-report
/test-results
# ts
tsconfig.tsbuildinfo

View file

@ -1,10 +1,10 @@
import { Participant, Vote } from "@prisma/client";
import { Participant, Vote, VoteType } from "@prisma/client";
import axios from "axios";
export interface AddParticipantPayload {
pollId: string;
name: string;
votes: string[];
votes: Array<{ optionId: string; type: VoteType }>;
}
export type AddParticipantResponse = Participant & {

View file

@ -1,7 +1,7 @@
import { Link, Option, Participant, Poll, User, Vote } from "@prisma/client";
export interface GetPollApiResponse extends Omit<Poll, "verificationCode"> {
options: Array<Option & { votes: Vote[] }>;
options: Option[];
participants: Array<Participant & { votes: Vote[] }>;
user: User;
role: "admin" | "participant";

View file

@ -1,15 +1,20 @@
import { Participant, Vote, VoteType } from "@prisma/client";
import axios from "axios";
export interface UpdateParticipantPayload {
pollId: string;
participantId: string;
name: string;
votes: string[];
votes: Array<{ optionId: string; type: VoteType }>;
}
export const updateParticipant = async (
payload: UpdateParticipantPayload,
): Promise<void> => {
): Promise<Participant & { votes: Vote[] }> => {
const { pollId, participantId, ...body } = payload;
await axios.patch(`/api/poll/${pollId}/participant/${participantId}`, body);
const res = await axios.patch<Participant & { votes: Vote[] }>(
`/api/poll/${pollId}/participant/${participantId}`,
body,
);
return res.data;
};

View file

@ -19,7 +19,7 @@ const DateCard: React.VoidFunctionComponent<DateCardProps> = ({
return (
<div
className={clsx(
"relative inline-block h-14 w-14 rounded-md border bg-white text-center shadow-md shadow-slate-100",
"relative mt-1 inline-block h-14 w-14 rounded-md border bg-white text-center shadow-md shadow-slate-100",
className,
)}
>
@ -31,7 +31,7 @@ const DateCard: React.VoidFunctionComponent<DateCardProps> = ({
{dow}
</span>
</div>
<div className="-mb-1 text-center text-lg text-red-500">{day}</div>
<div className="-mb-1 text-center text-lg text-rose-500">{day}</div>
<div className="text-center text-xs font-semibold uppercase text-gray-800">
{month}
</div>

View file

@ -1,10 +1,9 @@
import { format } from "date-fns";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { useTimeoutFn } from "react-use";
import DateCard from "../date-card";
import Score from "../poll/desktop-poll/score";
import { ScoreSummary } from "../poll/score-summary";
import UserAvatar from "../poll/user-avatar";
import VoteIcon from "../poll/vote-icon";
@ -36,10 +35,6 @@ const options = ["2022-12-14", "2022-12-15", "2022-12-16", "2022-12-17"];
const PollDemo: React.VoidFunctionComponent = () => {
const { t } = useTranslation("app");
const [bestOption, setBestOption] = React.useState<number>();
useTimeoutFn(() => {
setBestOption(2);
}, 1500);
return (
<div
@ -48,7 +43,7 @@ const PollDemo: React.VoidFunctionComponent = () => {
>
<div className="flex border-b shadow-sm">
<div
className="flex shrink-0 items-center py-4 pl-4 pr-2 font-medium"
className="flex shrink-0 items-center py-2 pl-4 pr-2 font-medium"
style={{ width: sidebarWidth }}
>
<div className="flex h-full grow items-end">
@ -66,17 +61,17 @@ const PollDemo: React.VoidFunctionComponent = () => {
return (
<div
key={i}
className="shrink-0 py-4 text-center transition-colors"
className="shrink-0 space-y-3 py-2 pt-3 text-center transition-colors"
style={{ width: 100 }}
>
<DateCard
day={format(d, "dd")}
dow={format(d, "E")}
month={format(d, "MMM")}
annotation={
<Score count={score} highlight={i === bestOption} />
}
/>
<div>
<ScoreSummary yesScore={score} compact={true} />
</div>
</div>
);
})}

View 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

View 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

View 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

View 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

View file

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 374 B

Before After
Before After

View file

@ -1,4 +1,4 @@
import { Participant, Vote } from "@prisma/client";
import { Participant, Vote, VoteType } from "@prisma/client";
import { GetPollResponse } from "api-client/get-poll";
import { keyBy } from "lodash";
import React from "react";
@ -13,8 +13,6 @@ import { usePreferences } from "./preferences/use-preferences";
import { useSession } from "./session";
import { useRequiredContext } from "./use-required-context";
type VoteType = "yes" | "no";
type PollContextValue = {
userAlreadyVoted: boolean;
poll: GetPollResponse;
@ -23,10 +21,11 @@ type PollContextValue = {
pollType: "date" | "timeSlot";
highScore: number;
getParticipantsWhoVotedForOption: (optionId: string) => Participant[]; // maybe just attach votes to parsed options
getScore: (optionId: string) => { yes: number; ifNeedBe: number };
getParticipantById: (
participantId: string,
) => (Participant & { votes: Vote[] }) | undefined;
getVote: (participantId: string, optionId: string) => VoteType;
getVote: (participantId: string, optionId: string) => VoteType | undefined;
} & (
| { pollType: "date"; options: ParsedDateOption[] }
| { pollType: "timeSlot"; options: ParsedTimeSlotOption[] }
@ -57,21 +56,46 @@ export const PollContextProvider: React.VoidFunctionComponent<{
const participantsByOptionId = React.useMemo(() => {
const res: Record<string, Participant[]> = {};
poll.options.forEach((option) => {
res[option.id] = option.votes.map(
({ participantId }) => participantById[participantId],
res[option.id] = poll.participants.filter((participant) =>
participant.votes.some(
({ type, optionId }) => optionId === option.id && type !== "no",
),
);
});
return res;
}, [participantById, poll.options]);
}, [poll.options, poll.participants]);
const { locale } = usePreferences();
const contextValue = React.useMemo<PollContextValue>(() => {
let highScore = 1;
poll.options.forEach((option) => {
if (option.votes.length > highScore) {
highScore = option.votes.length;
const getScore = React.useCallback(
(optionId: string) => {
return poll.participants.reduce(
(acc, curr) => {
curr.votes.forEach((vote) => {
if (vote.optionId !== optionId) {
return;
}
if (vote.type === "yes") {
acc.yes += 1;
}
if (vote.type === "ifNeedBe") {
acc.ifNeedBe += 1;
}
});
return acc;
},
{ yes: 0, ifNeedBe: 0 },
);
},
[poll.participants],
);
const contextValue = React.useMemo<PollContextValue>(() => {
const highScore = poll.options.reduce((acc, curr) => {
const score = getScore(curr.id).yes;
return score > acc ? score : acc;
}, 1);
const parsedOptions = decodeOptions(
poll.options,
@ -99,11 +123,6 @@ export const PollContextProvider: React.VoidFunctionComponent<{
return {
userAlreadyVoted,
poll,
getVotesForOption: (optionId: string) => {
// TODO (Luke Vella) [2022-04-16]: Build an index instead
const option = poll.options.find(({ id }) => id === optionId);
return option?.votes ?? [];
},
getParticipantById: (participantId) => {
return participantById[participantId];
},
@ -111,17 +130,18 @@ export const PollContextProvider: React.VoidFunctionComponent<{
getParticipantsWhoVotedForOption: (optionId: string) =>
participantsByOptionId[optionId],
getVote: (participantId, optionId) => {
return getParticipantById(participantId)?.votes.some(
const vote = getParticipantById(participantId)?.votes.find(
(vote) => vote.optionId === optionId,
)
? "yes"
: "no";
);
return vote?.type;
},
getScore,
...parsedOptions,
targetTimeZone,
setTargetTimeZone,
};
}, [
getScore,
locale,
participantById,
participantsByOptionId,

View file

@ -1,4 +1,3 @@
import { GetPollResponse } from "api-client/get-poll";
import axios from "axios";
import { NextPage } from "next";
import Head from "next/head";
@ -21,7 +20,8 @@ import NotificationsToggle from "./poll/notifications-toggle";
import PollSubheader from "./poll/poll-subheader";
import TruncatedLinkify from "./poll/truncated-linkify";
import { UserAvatarProvider } from "./poll/user-avatar";
import { PollContextProvider, usePoll } from "./poll-context";
import VoteIcon from "./poll/vote-icon";
import { usePoll } from "./poll-context";
import Popover from "./popover";
import { useSession } from "./session";
import Sharing from "./sharing";
@ -32,7 +32,7 @@ const Discussion = React.lazy(() => import("@/components/discussion"));
const DesktopPoll = React.lazy(() => import("@/components/poll/desktop-poll"));
const MobilePoll = React.lazy(() => import("@/components/poll/mobile-poll"));
const PollInner: NextPage = () => {
const PollPage: NextPage = () => {
const { poll } = usePoll();
const router = useRouter();
@ -121,13 +121,6 @@ const PollInner: NextPage = () => {
const PollComponent = isWideScreen ? DesktopPoll : MobilePoll;
let highScore = 1; // set to one because we don't want to highlight
poll.options.forEach((option) => {
if (option.votes.length > highScore) {
highScore = option.votes.length;
}
});
const names = React.useMemo(
() => poll.participants.map(({ name }) => name),
[poll.participants],
@ -210,9 +203,28 @@ const PollInner: NextPage = () => {
This poll has been locked (voting is disabled)
</div>
) : null}
<div className="flex items-center space-x-3 px-4 py-2 sm:justify-end">
<span className="text-xs font-semibold text-slate-500">
Legend:
</span>
<span className="inline-flex items-center space-x-2">
<VoteIcon type="yes" />
<span className="text-xs text-slate-500">Yes</span>
</span>
<span className="inline-flex items-center space-x-2">
<VoteIcon type="ifNeedBe" />
<span className="text-xs text-slate-500">If need be</span>
</span>
<span className="inline-flex items-center space-x-2">
<VoteIcon type="no" />
<span className="text-xs text-slate-500">No</span>
</span>
</div>
<React.Suspense fallback={<div>Loading</div>}>
<div className="mb-4 lg:mb-8">
<PollComponent pollId={poll.urlId} highScore={highScore} />
<PollComponent pollId={poll.urlId} />
</div>
<Discussion pollId={poll.urlId} />
</React.Suspense>
@ -223,12 +235,4 @@ const PollInner: NextPage = () => {
);
};
const PollPage = ({ poll }: { poll: GetPollResponse }) => {
return (
<PollContextProvider value={poll}>
<PollInner />
</PollContextProvider>
);
};
export default PollPage;

View file

@ -23,6 +23,8 @@ if (typeof window !== "undefined") {
const MotionButton = motion(Button);
const MotionParticipantFormRow = motion(ParticipantRowForm);
const minSidebarWidth = 180;
const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
@ -111,7 +113,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
}}
>
<div
className="relative min-w-full max-w-full" // Don't add styles like border, margin, padding that can mess up the sizing calculations
className="relative min-w-full max-w-full select-none" // Don't add styles like border, margin, padding that can mess up the sizing calculations
style={{ width: `min(${pollWidth}px, calc(100vw - 3rem))` }}
ref={ref}
>
@ -131,9 +133,9 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
</div>
) : null}
<div className="flex shrink-0">
{!shouldShowNewParticipantForm && !poll.closed ? (
<Button
type="primary"
disabled={shouldShowNewParticipantForm || poll.closed}
icon={<PlusCircle />}
onClick={() => {
setShouldShowNewParticipantForm(true);
@ -141,12 +143,11 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
>
New Participant
</Button>
) : null}
</div>
</div>
<div className="flex">
<div
className="flex shrink-0 items-center py-4 pl-4 pr-2 font-medium"
className="flex shrink-0 items-center py-2 pl-4 pr-2 font-medium"
style={{ width: sidebarWidth }}
>
<div className="flex h-full grow items-end">
@ -198,8 +199,12 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
) : null}
</div>
</div>
<AnimatePresence initial={false}>
{shouldShowNewParticipantForm && !poll.closed ? (
<ParticipantRowForm
<MotionParticipantFormRow
transition={{ duration: 0.2 }}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 55, y: 0 }}
className="border-t bg-slate-100 bg-opacity-0"
onSubmit={(data) => {
return new Promise((resolve, reject) => {
@ -218,6 +223,7 @@ const Poll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
}}
/>
) : null}
</AnimatePresence>
</div>
<div className="min-h-0 overflow-y-auto">
{participants.map((participant, i) => {

View file

@ -4,13 +4,14 @@ import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import CompactButton from "@/components/compact-button";
import Check from "@/components/icons/check.svg";
import X from "@/components/icons/x.svg";
import { useSession } from "@/components/session";
import { requiredString } from "../../../utils/form-validation";
import Button from "../../button";
import NameInput from "../../name-input";
import { ParticipantForm } from "../types";
import { VoteSelector } from "../vote-selector";
import ControlledScrollArea from "./controlled-scroll-area";
import { usePollContext } from "./poll-context";
@ -22,8 +23,10 @@ export interface ParticipantRowFormProps {
onCancel?: () => void;
}
const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
({ defaultValues, onSubmit, className, options, onCancel }) => {
const ParticipantRowForm: React.ForwardRefRenderFunction<
HTMLFormElement,
ParticipantRowFormProps
> = ({ defaultValues, onSubmit, className, options, onCancel }, ref) => {
const {
setActiveOptionId,
activeOptionId,
@ -35,11 +38,8 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
setScrollPosition,
} = usePollContext();
const session = useSession();
const {
handleSubmit,
register,
control,
formState: { errors, submitCount, isSubmitting },
reset,
@ -60,39 +60,24 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
}, [onCancel]);
const isColumnVisible = (index: number) => {
return (
scrollPosition + numberOfColumns * columnWidth > columnWidth * index
);
return scrollPosition + numberOfColumns * columnWidth > columnWidth * index;
};
const checkboxRefs = React.useRef<HTMLInputElement[]>([]);
const isAnimatingRef = React.useRef(false);
// This hack is necessary because when there is only one checkbox,
// react-hook-form does not know to format the value into an array.
// See: https://github.com/react-hook-form/react-hook-form/issues/7834
const checkboxProps = register("votes", {
onBlur: () => setActiveOptionId(null),
});
const checkboxGroupHack = (
<input type="checkbox" className="hidden" {...checkboxProps} />
);
const checkboxRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
return (
<form
ref={ref}
onSubmit={handleSubmit(async ({ name, votes }) => {
await onSubmit({
name,
// if there is only one checkbox then we get a string rather than array
// See this issue with using dot notation: https://github.com/react-hook-form/react-hook-form/issues/7834
votes: Array.isArray(votes) ? votes : [votes],
// no need to create votes for "no"
votes,
});
reset();
})}
className={clsx("flex h-14 shrink-0", className)}
>
{checkboxGroupHack}
<div className="flex items-center px-2" style={{ width: sidebarWidth }}>
<Controller
name="name"
@ -102,10 +87,9 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
render={({ field }) => (
<div className="w-full">
<NameInput
autoFocus={!session.user}
autoFocus={true}
className={clsx("w-full", {
"input-error animate-wiggle":
errors.name && submitCount > 0,
"input-error animate-wiggle": errors.name && submitCount > 0,
})}
placeholder="Your name"
{...field}
@ -114,7 +98,7 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
e.preventDefault();
setScrollPosition(0);
setTimeout(() => {
checkboxRefs.current[0].focus();
checkboxRefs.current[0]?.focus();
}, 100);
}
}}
@ -124,49 +108,50 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
control={control}
/>
</div>
<Controller
control={control}
name="votes"
render={({ field }) => {
return (
<ControlledScrollArea>
{options.map((option, index) => {
const value = field.value[index];
return (
<div
key={option.id}
className={clsx(
"flex shrink-0 items-center justify-center transition-colors",
{
"bg-slate-50": activeOptionId === option.id,
"bg-gray-50": activeOptionId === option.id,
},
)}
style={{ width: columnWidth }}
onMouseOver={() => setActiveOptionId(option.id)}
onMouseOut={() => setActiveOptionId(null)}
>
<input
className="checkbox"
type="checkbox"
value={option.id}
<VoteSelector
value={value?.type}
onKeyDown={(e) => {
if (isAnimatingRef.current) {
return e.preventDefault();
}
if (
e.code === "Tab" &&
index < options.length - 1 &&
!isColumnVisible(index + 1)
) {
isAnimatingRef.current = true;
e.preventDefault();
goToNextPage();
setTimeout(() => {
checkboxRefs.current[index + 1].focus();
isAnimatingRef.current = false;
checkboxRefs.current[index + 1]?.focus();
}, 100);
}
}}
{...checkboxProps}
onChange={(vote) => {
const newValue = [...field.value];
newValue[index] = { optionId: option.id, type: vote };
field.onChange(newValue);
}}
ref={(el) => {
if (el) {
checkboxRefs.current[index] = el;
checkboxProps.ref(el);
}
}}
onFocus={() => {
setActiveOptionId(option.id);
@ -176,9 +161,14 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
);
})}
</ControlledScrollArea>
);
}}
/>
<div className="flex items-center space-x-2 px-2 transition-all">
<Button
htmlType="submit"
icon={<Check />}
type="primary"
loading={isSubmitting}
data-testid="submitNewParticipant"
@ -189,6 +179,6 @@ const ParticipantRowForm: React.VoidFunctionComponent<ParticipantRowFormProps> =
</div>
</form>
);
};
};
export default ParticipantRowForm;
export default React.forwardRef(ParticipantRowForm);

View file

@ -2,7 +2,6 @@ import { Option, Participant, Vote } from "@prisma/client";
import clsx from "clsx";
import * as React from "react";
import Badge from "@/components/badge";
import CompactButton from "@/components/compact-button";
import Pencil from "@/components/icons/pencil-alt.svg";
import Trash from "@/components/icons/trash.svg";
@ -20,7 +19,7 @@ import { usePollContext } from "./poll-context";
export interface ParticipantRowProps {
urlId: string;
participant: Participant & { votes: Vote[] };
options: Array<Option & { votes: Vote[] }>;
options: Option[];
editMode: boolean;
onChangeEditMode?: (value: boolean) => void;
}
@ -41,7 +40,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
const confirmDeleteParticipant = useDeleteParticipantModal();
const session = useSession();
const { poll } = usePoll();
const { poll, getVote } = usePoll();
const isYou = session.user && session.ownsObject(participant) ? true : false;
@ -55,13 +54,15 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
<ParticipantRowForm
defaultValues={{
name: participant.name,
votes: participant.votes.map(({ optionId }) => optionId),
votes: options.map(({ id }) => {
const type = getVote(participant.id, id);
return type ? { optionId: id, type } : undefined;
}),
}}
onSubmit={async ({ name, votes }) => {
return new Promise((resolve, reject) => {
updateParticipantMutation(
{
pollId: participant.pollId,
participantId: participant.id,
votes,
name,
@ -86,7 +87,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
<div
key={participant.id}
data-testid="participant-row"
className="group flex h-14 transition-colors hover:bg-slate-50"
className="group flex h-14 transition-colors hover:bg-slate-300/10"
>
<div
className="flex shrink-0 items-center px-4"
@ -99,7 +100,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
isYou={isYou}
/>
{canEdit ? (
<div className="hidden shrink-0 items-center space-x-2 overflow-hidden px-2 group-hover:flex">
<div className="hidden shrink-0 items-center space-x-2 overflow-hidden group-hover:flex">
<CompactButton
icon={Pencil}
onClick={() => {
@ -117,26 +118,21 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
</div>
<ControlledScrollArea>
{options.map((option) => {
const vote = getVote(participant.id, option.id);
return (
<div
key={option.id}
className={clsx(
"flex shrink-0 items-center justify-center transition-colors",
{
"bg-slate-50": activeOptionId === option.id,
"bg-gray-50": activeOptionId === option.id,
},
)}
style={{ width: columnWidth }}
onMouseOver={() => setActiveOptionId(option.id)}
onMouseOut={() => setActiveOptionId(null)}
>
{option.votes.some(
(vote) => vote.participantId === participant.id,
) ? (
<VoteIcon type="yes" />
) : (
<VoteIcon type="no" />
)}
<VoteIcon type={vote} />
</div>
);
})}

View file

@ -4,9 +4,9 @@ import * as React from "react";
import DateCard from "@/components/date-card";
import { usePoll } from "@/components/poll-context";
import { ScoreSummary } from "../score-summary";
import ControlledScrollArea from "./controlled-scroll-area";
import { usePollContext } from "./poll-context";
import Score from "./score";
const TimeRange: React.VoidFunctionComponent<{
startTime: string;
@ -16,7 +16,7 @@ const TimeRange: React.VoidFunctionComponent<{
return (
<div
className={clsx(
"relative inline-block pr-2 text-right text-xs font-semibold after:absolute after:top-2 after:right-0 after:h-4 after:w-1 after:border-t after:border-r after:border-b after:border-slate-300 after:content-['']",
"relative -mr-2 inline-block pr-2 text-right text-xs font-semibold after:absolute after:top-2 after:right-0 after:h-4 after:w-1 after:border-t after:border-r after:border-b after:border-slate-300 after:content-['']",
className,
)}
>
@ -27,21 +27,21 @@ const TimeRange: React.VoidFunctionComponent<{
};
const PollHeader: React.VoidFunctionComponent = () => {
const { options, getParticipantsWhoVotedForOption, highScore } = usePoll();
const { options, getScore } = usePoll();
const { activeOptionId, setActiveOptionId, columnWidth } = usePollContext();
return (
<ControlledScrollArea>
{options.map((option) => {
const { optionId } = option;
const numVotes = getParticipantsWhoVotedForOption(optionId).length;
const numVotes = getScore(optionId);
return (
<div
key={optionId}
className={clsx(
"shrink-0 pt-4 pb-3 text-center transition-colors",
"shrink-0 space-y-3 py-3 text-center transition-colors",
{
"bg-slate-50": activeOptionId === optionId,
"bg-gray-50": activeOptionId === optionId,
},
)}
style={{ width: columnWidth }}
@ -53,14 +53,6 @@ const PollHeader: React.VoidFunctionComponent = () => {
day={option.day}
dow={option.dow}
month={option.month}
annotation={
numVotes > 0 ? (
<Score
count={numVotes}
highlight={numVotes > 1 && highScore === numVotes}
/>
) : null
}
/>
</div>
{option.type === "timeSlot" ? (
@ -70,6 +62,13 @@ const PollHeader: React.VoidFunctionComponent = () => {
endTime={option.endTime}
/>
) : null}
<div className="flex justify-center">
<ScoreSummary
yesScore={numVotes.yes}
ifNeedBeScore={numVotes.ifNeedBe}
compact={true}
/>
</div>
</div>
);
})}

View file

@ -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;

View file

@ -1,5 +1,4 @@
import { Placement } from "@floating-ui/react-dom-interactions";
import { format } from "date-fns";
import { Trans, useTranslation } from "next-i18next";
import * as React from "react";
import { encodeDateOption } from "utils/date-time-utils";
@ -17,6 +16,7 @@ import { PollDetailsForm } from "../forms";
import { useModal } from "../modal";
import { useModalContext } from "../modal/modal-provider";
import { usePoll } from "../poll-context";
import { useCsvExporter } from "./manage-poll/use-csv-exporter";
import { useUpdatePollMutation } from "./mutations";
const PollOptionsForm = React.lazy(() => import("../forms/poll-options-form"));
@ -25,7 +25,9 @@ const ManagePoll: React.VoidFunctionComponent<{
placement?: Placement;
}> = ({ placement }) => {
const { t } = useTranslation("app");
const { poll, options } = usePoll();
const { poll, getParticipantsWhoVotedForOption } = usePoll();
const { exportToCsv } = useCsvExporter();
const modalContext = useModalContext();
@ -105,7 +107,8 @@ const ManagePoll: React.VoidFunctionComponent<{
};
const optionsToDeleteThatHaveVotes = optionsToDelete.filter(
(option) => option.votes.length > 0,
(option) =>
getParticipantsWhoVotedForOption(option.id).length > 0,
);
if (optionsToDeleteThatHaveVotes.length > 0) {
@ -181,55 +184,7 @@ const ManagePoll: React.VoidFunctionComponent<{
label="Edit options"
onClick={handleChangeOptions}
/>
<DropdownItem
icon={Save}
label="Export to CSV"
onClick={() => {
const header = [
t("participantCount", {
count: poll.participants.length,
}),
...options.map((decodedOption) => {
const day = `${decodedOption.dow} ${decodedOption.day} ${decodedOption.month}`;
return decodedOption.type === "date"
? day
: `${day} ${decodedOption.startTime} - ${decodedOption.endTime}`;
}),
].join(",");
const rows = poll.participants.map((participant) => {
return [
participant.name,
...poll.options.map((option) => {
if (
participant.votes.some((vote) => {
return vote.optionId === option.id;
})
) {
return "Yes";
}
return "No";
}),
].join(",");
});
const csv = `data:text/csv;charset=utf-8,${[header, ...rows].join(
"\r\n",
)}`;
const encodedCsv = encodeURI(csv);
const link = document.createElement("a");
link.setAttribute("href", encodedCsv);
link.setAttribute(
"download",
`${poll.title.replace(/\s/g, "_")}-${format(
Date.now(),
"yyyyMMddhhmm",
)}`,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
/>
<DropdownItem icon={Save} label="Export to CSV" onClick={exportToCsv} />
{poll.closed ? (
<DropdownItem
icon={LockOpen}

View 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);
},
};
};

View file

@ -1,5 +1,4 @@
import { Listbox } from "@headlessui/react";
import { Participant, Vote } from "@prisma/client";
import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslation } from "next-i18next";
@ -7,6 +6,7 @@ import * as React from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import smoothscroll from "smoothscroll-polyfill";
import Check from "@/components/icons/check.svg";
import ChevronDown from "@/components/icons/chevron-down.svg";
import Pencil from "@/components/icons/pencil-alt.svg";
import PlusCircle from "@/components/icons/plus-circle.svg";
@ -37,17 +37,11 @@ if (typeof window !== "undefined") {
const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
const pollContext = usePoll();
const { poll, targetTimeZone, setTargetTimeZone } = pollContext;
const { poll, targetTimeZone, setTargetTimeZone, getParticipantById } =
pollContext;
const { timeZone, participants, role } = poll;
const participantById = participants.reduce<
Record<string, Participant & { votes: Vote[] }>
>((acc, curr) => {
acc[curr.id] = { ...curr };
return acc;
}, {});
const session = useSession();
const form = useForm<ParticipantForm>({
@ -62,7 +56,7 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
React.useState<string>();
const selectedParticipant = selectedParticipantId
? participantById[selectedParticipantId]
? getParticipantById(selectedParticipantId)
: undefined;
const [isEditing, setIsEditing] = React.useState(false);
@ -111,6 +105,7 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
});
}
};
return (
<FormProvider {...form}>
<form
@ -122,7 +117,6 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
updateParticipantMutation(
{
participantId: selectedParticipant.id,
pollId,
...data,
},
{
@ -150,7 +144,9 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
<div className="flex space-x-3">
<Listbox
value={selectedParticipantId}
onChange={setSelectedParticipantId}
onChange={(participantId) => {
setSelectedParticipantId(participantId);
}}
disabled={isEditing}
>
<div className="menu min-w-0 grow">
@ -217,9 +213,10 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
setIsEditing(true);
reset({
name: selectedParticipant.name,
votes: selectedParticipant.votes.map(
(vote) => vote.optionId,
),
votes: selectedParticipant.votes.map((vote) => ({
optionId: vote.optionId,
type: vote.type,
})),
});
}}
>
@ -241,7 +238,10 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
type="primary"
icon={<PlusCircle />}
onClick={() => {
reset({ name: "", votes: [] });
reset({
name: "",
votes: [],
});
setIsEditing(true);
}}
>
@ -269,6 +269,8 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
</div>
{(() => {
switch (pollContext.pollType) {
// we pass poll options as props since we are
// discriminating on poll type here
case "date":
return (
<PollOptions
@ -347,7 +349,7 @@ const MobilePoll: React.VoidFunctionComponent<PollProps> = ({ pollId }) => {
/>
</div>
<Button
icon={<Save />}
icon={<Check />}
htmlType="submit"
type="primary"
loading={formState.isSubmitting}

View file

@ -1,18 +1,20 @@
import { Participant } from "@prisma/client";
import { Participant, VoteType } from "@prisma/client";
import clsx from "clsx";
import { AnimatePresence, motion } from "framer-motion";
import * as React from "react";
import { ScoreSummary } from "../score-summary";
import UserAvatar from "../user-avatar";
import VoteIcon from "../vote-icon";
import PopularityScore from "./popularity-score";
import { VoteSelector } from "../vote-selector";
export interface PollOptionProps {
children?: React.ReactNode;
numberOfVotes: number;
yesScore: number;
ifNeedBeScore: number;
editable?: boolean;
vote?: "yes" | "no";
onChange: (vote: "yes" | "no") => void;
vote?: VoteType;
onChange: (vote: VoteType) => void;
participants: Participant[];
selectedParticipantId?: string;
}
@ -66,39 +68,31 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
onChange,
participants,
editable,
numberOfVotes,
yesScore,
ifNeedBeScore,
}) => {
const difference = selectedParticipantId
? participants.some(({ id }) => id === selectedParticipantId)
? vote === "yes"
? 0
: -1
: vote === "yes"
? 1
: 0
: vote === "yes"
? 1
: 0;
const showVotes = !!(selectedParticipantId || editable);
const selectorRef = React.useRef<HTMLButtonElement>(null);
return (
<div
data-testid="poll-option"
onClick={() => {
onChange(vote === "yes" ? "no" : "yes");
if (selectorRef.current) {
selectorRef.current.click();
}
}}
className={clsx(
"flex items-center space-x-3 px-4 py-3 transition duration-75",
"flex select-none items-center space-x-3 px-4 py-3 transition duration-75",
{
"active:bg-indigo-50": editable,
"bg-indigo-50/50": editable && vote === "yes",
"active:bg-slate-400/5": editable,
},
)}
>
<div className="pointer-events-none flex grow items-center">
<div className="flex grow items-center">
<div className="grow">{children}</div>
<div className="flex flex-col items-end">
<PopularityScore score={numberOfVotes + difference} />
<ScoreSummary yesScore={yesScore} ifNeedBeScore={ifNeedBeScore} />
{participants.length > 0 ? (
<div className="mt-1 -mr-1">
<div className="-space-x-1">
@ -127,27 +121,20 @@ const PollOption: React.VoidFunctionComponent<PollOptionProps> = ({
expanded={showVotes}
className="relative flex h-12 items-center justify-center rounded-lg"
>
<AnimatePresence>
{editable ? (
<PopInOut className="h-full">
<div className="flex h-full w-14 items-center justify-center">
<input
readOnly={true}
type="checkbox"
className="checkbox"
checked={vote === "yes"}
/>
<VoteSelector ref={selectorRef} value={vote} onChange={onChange} />
</div>
</PopInOut>
) : vote ? (
<AnimatePresence initial={false}>
<PopInOut
key={vote}
className="absolute inset-0 flex h-full w-full items-center justify-center"
>
<VoteIcon type={vote} />
</PopInOut>
) : null}
</AnimatePresence>
) : null}
</CollapsibleContainer>
</div>
);

View file

@ -1,3 +1,4 @@
import { VoteType } from "@prisma/client";
import * as React from "react";
import { Controller, useFormContext } from "react-hook-form";
import { ParsedDateTimeOpton } from "utils/date-time-utils";
@ -20,43 +21,39 @@ const PollOptions: React.VoidFunctionComponent<PollOptions> = ({
selectedParticipantId,
}) => {
const { control } = useFormContext<ParticipantForm>();
const { getParticipantsWhoVotedForOption, getVote, getParticipantById } =
usePoll();
const {
getParticipantsWhoVotedForOption,
getParticipantById,
getScore,
getVote,
} = usePoll();
const selectedParticipant = selectedParticipantId
? getParticipantById(selectedParticipantId)
: undefined;
return (
<div className="divide-y">
{options.map((option) => {
{options.map((option, index) => {
const participants = getParticipantsWhoVotedForOption(option.optionId);
const score = getScore(option.optionId);
return (
<Controller
key={option.optionId}
control={control}
name="votes"
render={({ field }) => {
const vote = editable
? field.value.includes(option.optionId)
? "yes"
: "no"
: selectedParticipant
const vote =
!editable && selectedParticipant
? getVote(selectedParticipant.id, option.optionId)
: undefined;
: field.value[index]?.type;
const handleChange = (newVote: "yes" | "no") => {
const handleChange = (newVote: VoteType) => {
if (!editable) {
return;
}
if (newVote === "no") {
field.onChange(
field.value.filter(
(optionId) => optionId !== option.optionId,
),
);
} else {
field.onChange([...field.value, option.optionId]);
}
const newValue = [...field.value];
newValue[index] = { optionId: option.optionId, type: newVote };
field.onChange(newValue);
};
switch (option.type) {
@ -64,7 +61,8 @@ const PollOptions: React.VoidFunctionComponent<PollOptions> = ({
return (
<TimeSlotOption
onChange={handleChange}
numberOfVotes={participants.length}
yesScore={score.yes}
ifNeedBeScore={score.ifNeedBe}
participants={participants}
vote={vote}
startTime={option.startTime}
@ -78,7 +76,8 @@ const PollOptions: React.VoidFunctionComponent<PollOptions> = ({
return (
<DateOption
onChange={handleChange}
numberOfVotes={participants.length}
yesScore={score.yes}
ifNeedBeScore={score.ifNeedBe}
participants={participants}
vote={vote}
dow={option.dow}

View file

@ -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);

View file

@ -8,10 +8,7 @@ import {
DeleteParticipantPayload,
} from "../../api-client/delete-participant";
import { GetPollResponse } from "../../api-client/get-poll";
import {
updateParticipant,
UpdateParticipantPayload,
} from "../../api-client/update-participant";
import { updateParticipant } from "../../api-client/update-participant";
import { usePoll } from "../poll-context";
import { useSession } from "../session";
import { ParticipantForm } from "./types";
@ -20,12 +17,17 @@ export const useAddParticipantMutation = (pollId: string) => {
const queryClient = useQueryClient();
const session = useSession();
const plausible = usePlausible();
const { options } = usePoll();
return useMutation(
(payload: ParticipantForm) =>
addParticipant({
pollId,
name: payload.name.trim(),
votes: payload.votes,
votes: options.map(
(option, i) =>
payload.votes[i] ?? { optionId: option.optionId, type: "no" },
),
}),
{
onSuccess: (participant) => {
@ -39,15 +41,6 @@ export const useAddParticipantMutation = (pollId: string) => {
);
}
poll.participants = [participant, ...poll.participants];
participant.votes.forEach((vote) => {
const votedOption = poll.options.find(
(option) => option.id === vote.optionId,
);
votedOption?.votes.push(vote);
});
poll.options.forEach((option) => {
participant.votes.some(({ optionId }) => optionId === option.id);
});
return poll;
},
);
@ -60,18 +53,38 @@ export const useAddParticipantMutation = (pollId: string) => {
export const useUpdateParticipantMutation = (pollId: string) => {
const queryClient = useQueryClient();
const plausible = usePlausible();
const { options } = usePoll();
return useMutation(
(payload: UpdateParticipantPayload) =>
(payload: ParticipantForm & { participantId: string }) =>
updateParticipant({
pollId,
participantId: payload.participantId,
name: payload.name.trim(),
votes: payload.votes,
votes: options.map(
(option, i) =>
payload.votes[i] ?? { optionId: option.optionId, type: "no" },
),
}),
{
onSuccess: () => {
onSuccess: (participant) => {
plausible("Update participant");
queryClient.setQueryData<GetPollResponse>(
["getPoll", pollId],
(poll) => {
if (!poll) {
throw new Error(
"Tried to update poll but no result found in query cache",
);
}
poll.participants = poll.participants.map((p) =>
p.id === participant.id ? participant : p,
);
return poll;
},
);
},
onSettled: () => {
queryClient.invalidateQueries(["getPoll", pollId]);
@ -83,9 +96,28 @@ export const useUpdateParticipantMutation = (pollId: string) => {
export const useDeleteParticipantMutation = () => {
const queryClient = useQueryClient();
const plausible = usePlausible();
const { poll } = usePoll();
return useMutation(
(payload: DeleteParticipantPayload) => deleteParticipant(payload),
{
onMutate: ({ participantId }) => {
queryClient.setQueryData<GetPollResponse>(
["getPoll", poll.urlId],
(poll) => {
if (!poll) {
throw new Error(
"Tried to update poll but no result found in query cache",
);
}
poll.participants = poll.participants.filter(
({ id }) => id !== participantId,
);
return poll;
},
);
},
onSuccess: () => {
plausible("Remove participant");
},

View 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>
);
});

View file

@ -1,8 +1,20 @@
import { VoteType } from "@prisma/client";
export interface ParticipantForm {
name: string;
votes: string[];
votes: Array<
| {
optionId: string;
type: VoteType;
}
| undefined
>;
}
export interface ParticipantFormSubmitted {
name: string;
votes: Array<{ optionId: string; type: VoteType }>;
}
export interface PollProps {
pollId: string;
highScore: number;
}

View file

@ -1,14 +1,27 @@
import { VoteType } from "@prisma/client";
import * as React from "react";
import CheckCircle from "@/components/icons/check-circle.svg";
import IfNeedBe from "@/components/icons/if-need-be.svg";
const VoteIcon: React.VoidFunctionComponent<{
type: "yes" | "no";
type?: VoteType;
}> = ({ type }) => {
if (type === "yes") {
switch (type) {
case "yes":
return <CheckCircle className="h-5 w-5 text-green-400" />;
case "ifNeedBe":
return <IfNeedBe className="h-5 w-5 text-yellow-400" />;
case "no":
return (
<span className="inline-block h-2 w-2 rounded-full bg-slate-300" />
);
default:
return <span className="inline-block font-bold text-slate-300">?</span>;
}
return <span className="inline-block h-2 w-2 rounded-full bg-slate-300" />;
};
export default VoteIcon;

View 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>
);
});

View file

@ -29,7 +29,7 @@ const HomeLink = () => {
return (
<Link href="/">
<a>
<Logo className="w-28 text-indigo-500 transition-colors active:text-indigo-600 lg:w-32" />
<Logo className="inline-block w-28 text-indigo-500 transition-colors active:text-indigo-600 lg:w-32" />
</a>
</Link>
);
@ -40,7 +40,7 @@ const MobileNavigation: React.VoidFunctionComponent<{
}> = ({ openLoginModal }) => {
const { user } = useSession();
return (
<div className="fixed top-0 z-30 flex h-12 w-full shrink-0 items-center justify-between border-b bg-gray-50 px-4 shadow-sm lg:hidden">
<div className="fixed top-0 z-40 flex h-12 w-full shrink-0 items-center justify-between border-b bg-gray-50 px-4 lg:hidden">
<div>
<HomeLink />
</div>

View 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';

View 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";

View 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;

View file

@ -25,10 +25,8 @@ export default function Document() {
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="manifest" href="/site.webmanifest" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/inter-ui/3.19.3/inter.min.css"
integrity="sha512-8vEtrrc40OAQaCUaqVjNMQtQEPyNtllVG1RYy6bGEuWQkivCBeqOzuDJPPhD+MO6y6QGLuQYPCr8Nlzu9lTYaQ=="
crossOrigin="anonymous"
/>
<meta name="theme-color" content="#f9fafb" />
</Head>

View file

@ -1,3 +1,4 @@
import { VoteType } from "@prisma/client";
import { GetPollApiResponse } from "api-client/get-poll";
import { NextApiRequest, NextApiResponse } from "next";
import { getQueryParam } from "utils/api-utils";
@ -50,18 +51,21 @@ export default async function handler(
id: legacyParticipant._id.toString(),
}));
const votes: Array<{ optionId: string; participantId: string }> = [];
const votes: Array<{
optionId: string;
participantId: string;
type: VoteType;
}> = [];
newParticipants?.forEach((p, i) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const legacyVotes = legacyPoll.participants![i].votes;
legacyVotes?.forEach((v, j) => {
if (v) {
votes.push({
optionId: newOptions[j].id,
participantId: p.id,
type: v ? "yes" : "no",
});
}
});
});
@ -133,9 +137,6 @@ export default async function handler(
},
include: {
options: {
include: {
votes: true,
},
orderBy: {
value: "asc",
},

View file

@ -2,9 +2,9 @@ import { sendEmailTemplate } from "utils/api-utils";
import { createToken, withSessionRoute } from "utils/auth";
import { nanoid } from "utils/nanoid";
import { CreatePollPayload } from "../../../api-client/create-poll";
import { prisma } from "../../../db";
import absoluteUrl from "../../../utils/absolute-url";
import { CreatePollPayload } from "../../api-client/create-poll";
import { prisma } from "../../db";
import absoluteUrl from "../../utils/absolute-url";
export default withSessionRoute(async (req, res) => {
switch (req.method) {

View file

@ -1,9 +1,9 @@
import { GetPollApiResponse } from "api-client/get-poll";
import { resetDates } from "utils/legacy-utils";
import { UpdatePollPayload } from "../../../../api-client/update-poll";
import { prisma } from "../../../../db";
import { withLink } from "../../../../utils/api-utils";
import { UpdatePollPayload } from "../../../api-client/update-poll";
import { prisma } from "../../../db";
import { withLink } from "../../../utils/api-utils";
export default withLink<
GetPollApiResponse | { status: number; message: string }
@ -18,9 +18,6 @@ export default withLink<
},
include: {
options: {
include: {
votes: true,
},
orderBy: {
value: "asc",
},
@ -111,9 +108,6 @@ export default withLink<
},
include: {
options: {
include: {
votes: true,
},
orderBy: {
value: "asc",
},

View file

@ -1,7 +1,7 @@
import { createGuestUser, withSessionRoute } from "utils/auth";
import { prisma } from "../../../../../db";
import { sendNotification, withLink } from "../../../../../utils/api-utils";
import { prisma } from "../../../../db";
import { sendNotification, withLink } from "../../../../utils/api-utils";
export default withSessionRoute(
withLink(async ({ req, res, link }) => {

View file

@ -1,8 +1,8 @@
import { createGuestUser, withSessionRoute } from "utils/auth";
import { AddParticipantPayload } from "../../../../../api-client/add-participant";
import { prisma } from "../../../../../db";
import { sendNotification, withLink } from "../../../../../utils/api-utils";
import { AddParticipantPayload } from "../../../../api-client/add-participant";
import { prisma } from "../../../../db";
import { sendNotification, withLink } from "../../../../utils/api-utils";
export default withSessionRoute(
withLink(async ({ req, res, link }) => {
@ -28,8 +28,9 @@ export default withSessionRoute(
: undefined,
votes: {
createMany: {
data: payload.votes.map((optionId) => ({
data: payload.votes.map(({ optionId, type }) => ({
optionId,
type,
pollId: link.pollId,
})),
},

View file

@ -1,3 +1,5 @@
import { UpdateParticipantPayload } from "api-client/update-participant";
import { prisma } from "../../../../../db";
import { getQueryParam, withLink } from "../../../../../utils/api-utils";
@ -7,7 +9,9 @@ export default withLink(async ({ req, res, link }) => {
const pollId = link.pollId;
switch (req.method) {
case "PATCH":
await prisma.participant.update({
const payload: UpdateParticipantPayload = req.body;
const participant = await prisma.participant.update({
where: {
id_pollId: {
id: participantId,
@ -20,17 +24,21 @@ export default withLink(async ({ req, res, link }) => {
pollId,
},
createMany: {
data: req.body.votes.map((optionId: string) => ({
data: payload.votes.map(({ optionId, type }) => ({
optionId,
type,
pollId,
})),
},
},
name: req.body.name,
},
include: {
votes: true,
},
});
return res.end();
return res.json(participant);
case "DELETE":
await prisma.participant.delete({

View file

@ -1,3 +1,4 @@
import { VoteType } from "@prisma/client";
import { addMinutes } from "date-fns";
import { NextApiRequest, NextApiResponse } from "next";
import absoluteUrl from "utils/absolute-url";
@ -5,22 +6,22 @@ import { nanoid } from "utils/nanoid";
import { prisma } from "../../../db";
const participantData = [
const participantData: Array<{ name: string; votes: VoteType[] }> = [
{
name: "Reed",
votes: [0, 2],
votes: ["yes", "no", "ifNeedBe", "no"],
},
{
name: "Susan",
votes: [0, 1, 2],
votes: ["yes", "yes", "yes", "no"],
},
{
name: "Johnny",
votes: [2, 3],
votes: ["no", "no", "yes", "yes"],
},
{
name: "Ben",
votes: [0, 1, 2, 3],
votes: ["yes", "yes", "yes", "yes"],
},
];
@ -49,7 +50,11 @@ export default async function handler(
createdAt: Date;
}> = [];
const votes: Array<{ optionId: string; participantId: string }> = [];
const votes: Array<{
optionId: string;
participantId: string;
type: VoteType;
}> = [];
for (let i = 0; i < participantData.length; i++) {
const { name, votes: participantVotes } = participantData[i];
@ -61,9 +66,12 @@ export default async function handler(
createdAt: addMinutes(today, i * -1),
});
participantVotes.forEach((voteIndex) => {
const option = options[voteIndex];
votes.push({ optionId: option.id, participantId });
options.forEach((option, index) => {
votes.push({
optionId: option.id,
participantId,
type: participantVotes[index],
});
});
}

View file

@ -11,6 +11,7 @@ import { withSessionSsr } from "utils/auth";
import ErrorPage from "@/components/error-page";
import FullPageLoader from "@/components/full-page-loader";
import { PollContextProvider } from "@/components/poll-context";
import { SessionProps, withSession } from "@/components/session";
import { GetPollResponse } from "../api-client/get-poll";
@ -74,7 +75,11 @@ const PollPageLoader: NextPage<SessionProps> = () => {
}
if (poll) {
return <PollPage poll={poll} />;
return (
<PollContextProvider value={poll}>
<PollPage />
</PollContextProvider>
);
}
if (didError) {

View file

@ -31,7 +31,7 @@
"voteCount_other": "{{count}} votes",
"participantCount": "{{count}} participant",
"participantCount_other": "{{count}} participants",
"createdBy": "Created by <b>{{name}}</b>",
"createdBy": "by <b>{{name}}</b>",
"timeZone": "Time Zone:",
"creatingDemo": "Creating demo poll…",
"ok": "Ok",
@ -49,5 +49,8 @@
"monday": "Monday",
"sunday": "Sunday",
"12h": "12-hour",
"24h": "24-hour"
"24h": "24-hour",
"yes": "Yes",
"no": "No",
"ifNeedBe": "If need be"
}

View file

@ -11,105 +11,129 @@ model User {
id String @id @default(cuid())
name String
email String @unique() @db.Citext
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
polls Poll[]
participants Participant[]
comments Comment[]
@@map("users")
}
enum PollType {
date
@@map("poll_type")
}
model Poll {
urlId String @id @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
urlId String @id @unique @map("url_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deadline DateTime?
title String
type PollType
description String?
location String?
user User @relation(fields: [userId], references: [id])
userId String
userId String @map("user_id")
votes Vote[]
timeZone String?
timeZone String? @map("time_zone")
verified Boolean @default(false)
options Option[]
participants Participant[]
authorName String @default("")
authorName String @default("") @map("author_name")
demo Boolean @default(false)
comments Comment[]
links Link[]
legacy Boolean @default(false)
closed Boolean @default(false)
notifications Boolean @default(false)
@@map("polls")
}
enum Role {
admin
participant
@@map("role")
}
model Link {
urlId String @id @unique
urlId String @id @unique @map("url_id")
role Role
pollId String
pollId String @map("poll_id")
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
createdAt DateTime @default(now())
createdAt DateTime @default(now()) @map("created_at")
@@unique([pollId, role])
@@map("links")
}
model Participant {
id String @id @default(cuid())
name String
user User? @relation(fields: [userId], references: [id])
userId String?
guestId String?
userId String? @map("user_id")
guestId String? @map("guest_id")
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
pollId String
pollId String @map("poll_id")
votes Vote[]
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
@@unique([id, pollId])
@@map("participants")
}
model Option {
id String @id @default(cuid())
value String
pollId String
pollId String @map("poll_id")
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
votes Vote[]
@@map("options")
}
enum VoteType {
yes
no
ifNeedBe
@@map("vote_type")
}
model Vote {
id String @id @default(cuid())
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
participantId String
participantId String @map("participant_id")
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
optionId String
optionId String @map("option_id")
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
pollId String
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
pollId String @map("poll_id")
type VoteType @default(yes)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
@@map("votes")
}
model Comment {
id String @id @default(cuid())
content String
poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade)
pollId String
authorName String
pollId String @map("poll_id")
authorName String @map("author_name")
user User? @relation(fields: [userId], references: [id])
userId String?
guestId String?
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
userId String? @map("user_id")
guestId String? @map("guest_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
@@unique([id, pollId])
@@map("comments")
}

View file

@ -32,13 +32,13 @@
@apply outline-none;
}
a {
@apply font-medium text-indigo-500 hover:text-indigo-400 hover:underline;
@apply rounded-sm font-medium text-indigo-500 outline-none hover:text-indigo-400 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1;
}
label {
@apply mb-1 block text-sm text-slate-800;
}
button {
@apply cursor-default focus:outline-none focus:ring-indigo-600;
@apply cursor-default outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1;
}
#floating-ui-root {
@ -66,14 +66,14 @@
@apply h-4 w-4 rounded border-slate-300 text-indigo-500 shadow-sm focus:ring-indigo-500;
}
.btn {
@apply inline-flex h-9 cursor-default items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1;
@apply inline-flex h-9 cursor-default select-none items-center justify-center whitespace-nowrap rounded-md border px-3 font-medium shadow-sm transition-all active:scale-95;
}
a.btn {
@apply hover:no-underline;
}
.btn-default {
@apply btn border-gray-300 bg-white text-gray-700 hover:text-indigo-500 focus-visible:border-transparent focus-visible:ring-indigo-500 focus-visible:ring-offset-0 active:bg-gray-100;
@apply btn border-slate-300 bg-white text-slate-700 hover:bg-indigo-50/10 active:bg-slate-100;
}
.btn-danger {
@apply btn border-rose-600 bg-rose-500 text-white hover:bg-rose-600 focus-visible:ring-rose-500;
@ -81,20 +81,15 @@
.btn-link {
@apply inline-flex items-center text-indigo-500 underline;
}
.btn-default.btn-disabled {
@apply bg-gray-50;
}
.btn-disabled {
@apply pointer-events-none;
.btn.btn-disabled {
text-shadow: none;
@apply pointer-events-none border-gray-200 bg-slate-500/5 text-gray-400 shadow-none;
}
.btn-primary {
text-shadow: rgb(0 0 0 / 20%) 0px 1px 1px;
@apply btn border-indigo-600 bg-indigo-500 text-white hover:bg-opacity-90 focus-visible:ring-indigo-500 active:bg-indigo-600;
}
.btn-primary.btn-disabled {
text-shadow: none;
@apply border-gray-300/70 bg-gray-200/70 text-gray-400;
}
a.btn-primary {
@apply text-white;
}
@ -104,7 +99,7 @@
}
.segment-button button {
@apply inline-flex grow items-center justify-center border-t border-b border-r bg-white px-4 font-medium transition-colors first:rounded-l first:border-l last:rounded-r hover:bg-slate-50 focus:z-10 focus:ring-2 active:bg-slate-100;
@apply inline-flex grow items-center justify-center border-t border-b border-r bg-white px-4 font-medium transition-colors first:rounded-l first:border-l last:rounded-r hover:bg-slate-50 focus:z-10 focus-visible:ring-2 focus-visible:ring-offset-0 active:bg-slate-100;
}
.segment-button .segment-button-active {

View file

@ -35,7 +35,8 @@ module.exports = {
xs: "375px",
},
fontFamily: {
sans: ["Inter var", ...defaultTheme.fontFamily.sans],
sans: ["Inter", ...defaultTheme.fontFamily.sans],
mono: [...defaultTheme.fontFamily.mono],
},
transitionTimingFunction: {
"in-expo": "cubic-bezier(0.68, -0.6, 0.32, 1.6)",

View file

@ -8,8 +8,8 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
await page.type('[placeholder="Your name"]', "Test user");
// There is a hidden checkbox (nth=0) that exists so that the behaviour of the form is consistent even
// when we only have a single option/checkbox.
await page.locator('[name="votes"] >> nth=1').click();
await page.locator('[name="votes"] >> nth=3').click();
await page.locator("data-testid=vote-selector >> nth=0").click();
await page.locator("data-testid=vote-selector >> nth=2").click();
await page.click('[data-testid="submitNewParticipant"]');
await expect(page.locator("text='Test user'")).toBeVisible();
await expect(page.locator("text=Guest")).toBeVisible();

View file

@ -1,6 +1,7 @@
import { ObjectId } from "mongodb";
import { getMongoClient } from "./mongodb-client";
import { prisma } from "../db";
import { getMongoClient } from "./mongodb-client";
export interface LegacyPoll {
__private: {
@ -91,8 +92,8 @@ export const resetDates = async (legacyPollId: string) => {
},
include: {
options: {
include: {
votes: true,
orderBy: {
value: "asc",
},
},
participants: {