Add option to delete poll (#174)

This commit is contained in:
Luke Vella 2022-05-18 17:47:23 +01:00 committed by GitHub
parent 2c4157ea24
commit c170e03b6a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 271 additions and 104 deletions

View file

@ -53,5 +53,8 @@
"24h": "24-hour",
"yes": "Yes",
"no": "No",
"ifNeedBe": "If need be"
"ifNeedBe": "If need be",
"areYouSure": "Are you sure?",
"deletePollDescription": "All data related to this poll will be deleted. This action <b>cannot be undone</b>. To confirm, please type <s>“{{confirmText}}”</s> in to the input below:",
"deletePoll": "Delete poll"
}

69
src/components/button.tsx Normal file
View file

@ -0,0 +1,69 @@
import clsx from "clsx";
import * as React from "react";
import SpinnerIcon from "@/components/icons/spinner.svg";
export interface ButtonProps {
children?: React.ReactNode;
className?: string;
disabled?: boolean;
loading?: boolean;
icon?: React.ReactElement;
htmlType?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
type?: "default" | "primary" | "danger" | "link";
form?: string;
rounded?: boolean;
title?: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
children,
loading,
type = "default",
htmlType = "button",
className,
icon,
disabled,
rounded,
...passThroughProps
},
ref,
) {
return (
<button
ref={ref}
type={htmlType}
className={clsx(
{
"btn-default": type === "default",
"btn-primary": type === "primary",
"btn-danger": type === "danger",
"btn-link": type === "link",
"btn-disabled": disabled,
"h-auto rounded-full p-2": rounded,
"w-10 p-0": !children,
},
className,
)}
{...passThroughProps}
disabled={disabled || loading}
>
{loading ? (
<SpinnerIcon
className={clsx("inline-block w-5 animate-spin", {
"mr-2": !!children,
})}
/>
) : icon ? (
React.cloneElement(icon, {
className: clsx("w-5 h-5", { "-ml-1 mr-2": !!children }),
})
) : null}
{children}
</button>
);
},
);

View file

@ -1,69 +0,0 @@
import clsx from "clsx";
import * as React from "react";
import SpinnerIcon from "../icons/spinner.svg";
export interface ButtonProps {
children?: React.ReactNode;
className?: string;
disabled?: boolean;
loading?: boolean;
icon?: React.ReactElement;
htmlType?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
type?: "default" | "primary" | "danger" | "link";
form?: string;
rounded?: boolean;
title?: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
}
const Button: React.ForwardRefRenderFunction<HTMLButtonElement, ButtonProps> = (
{
children,
loading,
type = "default",
htmlType = "button",
className,
icon,
disabled,
rounded,
...passThroughProps
},
ref,
) => {
return (
<button
ref={ref}
type={htmlType}
className={clsx(
{
"btn-default": type === "default",
"btn-primary": type === "primary",
"btn-danger": type === "danger",
"btn-link": type === "link",
"btn-disabled": disabled,
"h-auto rounded-full p-2": rounded,
"w-10 p-0": !children,
},
className,
)}
{...passThroughProps}
disabled={disabled || loading}
>
{loading ? (
<SpinnerIcon
className={clsx("inline-block w-5 animate-spin", {
"mr-2": !!children,
})}
/>
) : icon ? (
React.cloneElement(icon, {
className: clsx("w-5 h-5", { "-ml-1 mr-2": !!children }),
})
) : null}
{children}
</button>
);
};
export default React.forwardRef(Button);

View file

@ -1,2 +0,0 @@
export type { ButtonProps } from "./button";
export { default } from "./button";

View file

@ -8,7 +8,7 @@ import { useSessionStorage } from "react-use";
import { encodeDateOption } from "../utils/date-time-utils";
import { trpc } from "../utils/trpc";
import Button from "./button";
import { Button } from "./button";
import {
NewEventData,
PollDetailsData,

View file

@ -2,7 +2,7 @@ import * as React from "react";
import Chat from "@/components/icons/chat.svg";
import Button from "./button";
import { Button } from "./button";
const crispWebsiteId = process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID;

View file

@ -7,7 +7,7 @@ import { Controller, useForm } from "react-hook-form";
import { requiredString } from "../../utils/form-validation";
import { trpc } from "../../utils/trpc";
import Button from "../button";
import { Button } from "../button";
import CompactButton from "../compact-button";
import Dropdown, { DropdownItem } from "../dropdown";
import DotsHorizontal from "../icons/dots-horizontal.svg";

View file

@ -2,7 +2,7 @@ import Head from "next/head";
import Link from "next/link";
import * as React from "react";
import Button from "@/components/button";
import { Button } from "@/components/button";
import Chat from "@/components/icons/chat.svg";
import EmojiSad from "@/components/icons/emoji-sad.svg";
@ -28,7 +28,7 @@ const ErrorPage: React.VoidFunctionComponent<ComponentProps> = ({
<div className="flex items-start">
<div className="text-center">
<Icon className="mb-4 inline-block w-24 text-slate-400" />
<div className="text-3xl font-bold uppercase text-indigo-500 ">
<div className="mb-2 text-3xl font-bold text-indigo-500 ">
{title}
</div>
<p>{description}</p>

View file

@ -12,7 +12,7 @@ import {
getDateProps,
removeAllOptionsForDay,
} from "../../../../utils/date-time-utils";
import Button from "../../../button";
import { Button } from "../../../button";
import CompactButton from "../../../compact-button";
import DateCard from "../../../date-card";
import Dropdown, { DropdownItem } from "../../../dropdown";

View file

@ -1,3 +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="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>

Before

Width:  |  Height:  |  Size: 272 B

After

Width:  |  Height:  |  Size: 329 B

View file

@ -4,7 +4,7 @@ import { usePlausible } from "next-plausible";
import * as React from "react";
import { useForm } from "react-hook-form";
import Button from "@/components/button";
import { Button } from "@/components/button";
import Magic from "@/components/icons/magic.svg";
import { validEmail } from "@/utils/form-validation";

View file

@ -8,9 +8,15 @@ export interface ModalProviderProps {
children?: React.ReactNode;
}
type ModalContentProps = { close: () => void };
interface ModalConfig extends ModalProps {
content?: React.ReactNode | ((props: ModalContentProps) => React.ReactNode);
}
const ModalContext =
React.createContext<{
render: (el: ModalProps) => void;
render: (el: ModalConfig) => void;
} | null>(null);
ModalContext.displayName = "<ModalProvider />";
@ -22,7 +28,7 @@ export const useModalContext = () => {
const ModalProvider: React.VoidFunctionComponent<ModalProviderProps> = ({
children,
}) => {
const [modals, { push, removeAt, updateAt }] = useList<ModalProps>([]);
const [modals, { push, removeAt, updateAt }] = useList<ModalConfig>([]);
const removeModalAt = (index: number) => {
updateAt(index, { ...modals[index], visible: false });
@ -44,6 +50,11 @@ const ModalProvider: React.VoidFunctionComponent<ModalProviderProps> = ({
key={i}
visible={true}
{...props}
content={
typeof props.content === "function"
? props.content({ close: () => removeModalAt(i) })
: props.content
}
onOk={() => {
props.onOk?.();
removeModalAt(i);

View file

@ -4,7 +4,7 @@ import * as React from "react";
import X from "@/components/icons/x.svg";
import Button, { ButtonProps } from "../button";
import { Button, ButtonProps } from "../button";
export interface ModalProps {
description?: React.ReactNode;

View file

@ -2,6 +2,7 @@ import { Participant, Vote, VoteType } from "@prisma/client";
import { keyBy } from "lodash";
import React from "react";
import Trash from "@/components/icons/trash.svg";
import {
decodeOptions,
getBrowserTimeZone,
@ -10,6 +11,7 @@ import {
} from "@/utils/date-time-utils";
import { GetPollApiResponse } from "@/utils/trpc/types";
import ErrorPage from "./error-page";
import { useParticipants } from "./participants-provider";
import { usePreferences } from "./preferences/use-preferences";
import { useSession } from "./session";
@ -22,6 +24,8 @@ type PollContextValue = {
setTargetTimeZone: (timeZone: string) => void;
pollType: "date" | "timeSlot";
highScore: number;
isDeleted: boolean;
setDeleted: React.Dispatch<React.SetStateAction<boolean>>;
optionIds: string[];
// TODO (Luke Vella) [2022-05-18]: Move this stuff to participants provider
getParticipantsWhoVotedForOption: (optionId: string) => Participant[]; // maybe just attach votes to parsed options
@ -49,7 +53,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
children?: React.ReactNode;
}> = ({ value: poll, children }) => {
const { participants } = useParticipants();
const [isDeleted, setDeleted] = React.useState(false);
const { user } = useSession();
const [targetTimeZone, setTargetTimeZone] =
React.useState(getBrowserTimeZone);
@ -145,9 +149,20 @@ export const PollContextProvider: React.VoidFunctionComponent<{
...parsedOptions,
targetTimeZone,
setTargetTimeZone,
isDeleted,
setDeleted,
};
}, [getScore, locale, participants, poll, targetTimeZone, user]);
}, [getScore, isDeleted, locale, participants, poll, targetTimeZone, user]);
if (isDeleted) {
return (
<ErrorPage
icon={Trash}
title="Deleted poll"
description="This poll doesn't exist anymore."
/>
);
}
return (
<PollContext.Provider value={contextValue}>{children}</PollContext.Provider>
);

View file

@ -6,7 +6,7 @@ import React from "react";
import toast from "react-hot-toast";
import { useMount } from "react-use";
import Button from "@/components/button";
import { Button } from "@/components/button";
import LocationMarker from "@/components/icons/location-marker.svg";
import LockClosed from "@/components/icons/lock-closed.svg";
import Share from "@/components/icons/share.svg";

View file

@ -4,7 +4,7 @@ import * as React from "react";
import { useMeasure } from "react-use";
import smoothscroll from "smoothscroll-polyfill";
import Button from "../button";
import { Button } from "../button";
import ArrowLeft from "../icons/arrow-left.svg";
import ArrowRight from "../icons/arrow-right.svg";
import PlusCircle from "../icons/plus-circle.svg";

View file

@ -7,7 +7,7 @@ import Check from "@/components/icons/check.svg";
import X from "@/components/icons/x.svg";
import { requiredString } from "../../../utils/form-validation";
import Button from "../../button";
import { Button } from "../../button";
import NameInput from "../../name-input";
import { usePoll } from "../../poll-context";
import { normalizeVotes } from "../mutations";

View file

@ -2,13 +2,14 @@ import { Placement } from "@floating-ui/react-dom-interactions";
import { Trans, useTranslation } from "next-i18next";
import * as React from "react";
import Button from "@/components/button";
import { Button } from "@/components/button";
import Cog from "@/components/icons/cog.svg";
import LockClosed from "@/components/icons/lock-closed.svg";
import LockOpen from "@/components/icons/lock-open.svg";
import Pencil from "@/components/icons/pencil-alt.svg";
import Save from "@/components/icons/save.svg";
import Table from "@/components/icons/table.svg";
import Trash from "@/components/icons/trash.svg";
import { encodeDateOption } from "@/utils/date-time-utils";
import Dropdown, { DropdownItem } from "../dropdown";
@ -16,6 +17,7 @@ import { PollDetailsForm } from "../forms";
import { useModal } from "../modal";
import { useModalContext } from "../modal/modal-provider";
import { usePoll } from "../poll-context";
import { DeletePollForm } from "./manage-poll/delete-poll-form";
import { useCsvExporter } from "./manage-poll/use-csv-exporter";
import { useUpdatePollMutation } from "./mutations";
@ -25,7 +27,7 @@ const ManagePoll: React.VoidFunctionComponent<{
placement?: Placement;
}> = ({ placement }) => {
const { t } = useTranslation("app");
const { poll, getParticipantsWhoVotedForOption } = usePoll();
const { poll, getParticipantsWhoVotedForOption, setDeleted } = usePoll();
const { exportToCsv } = useCsvExporter();
@ -206,6 +208,26 @@ const ManagePoll: React.VoidFunctionComponent<{
}
/>
)}
<DropdownItem
icon={Trash}
label="Delete poll"
onClick={() => {
modalContext.render({
overlayClosable: true,
content: ({ close }) => (
<DeletePollForm
onConfirm={async () => {
close();
setDeleted(true);
}}
onCancel={close}
urlId={poll.urlId}
/>
),
footer: null,
});
}}
/>
</Dropdown>
</div>
);

View file

@ -0,0 +1,89 @@
import clsx from "clsx";
import { Trans, useTranslation } from "next-i18next";
import { usePlausible } from "next-plausible";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/button";
import Exclamation from "@/components/icons/exclamation.svg";
import { trpc } from "../../../utils/trpc";
const confirmText = "delete-me";
export const DeletePollForm: React.VoidFunctionComponent<{
onCancel: () => void;
onConfirm: () => void;
urlId: string;
}> = ({ onCancel, onConfirm, urlId }) => {
const { register, handleSubmit, formState, watch } =
useForm<{ confirmation: string }>();
const plausible = usePlausible();
const confirmationText = watch("confirmation");
const canDelete = confirmationText === confirmText;
const deletePoll = trpc.useMutation("polls.delete", {
onSuccess: () => {
plausible("Deleted poll");
},
});
const { t } = useTranslation("app");
return (
<div className="flex max-w-lg space-x-6 p-5">
<div className="">
<div className="rounded-full bg-rose-100 p-3">
<Exclamation className="w-8 text-rose-500" />
</div>
</div>
<form
data-testid="delete-poll-form"
onSubmit={handleSubmit(async () => {
await deletePoll.mutateAsync({ urlId });
onConfirm();
})}
>
<div className="mb-3 text-xl font-medium text-slate-800">
{t("areYouSure")}
</div>
<p className="text-slate-500">
<Trans
t={t}
i18nKey="deletePollDescription"
values={{ confirmText }}
components={{
b: <strong />,
s: <span className="whitespace-nowrap" />,
}}
/>
</p>
<div className="mb-6">
<input
type="text"
className={clsx("input w-full", {
"input-error": formState.errors.confirmation,
})}
placeholder={confirmText}
{...register("confirmation", {
validate: (value) => value === confirmText,
})}
readOnly={formState.isSubmitting}
/>
</div>
<div className="flex space-x-3">
<Button onClick={onCancel}>{t("cancel")}</Button>
<Button
disabled={!canDelete}
htmlType="submit"
type="danger"
loading={formState.isSubmitting}
>
{t("deletePoll")}
</Button>
</div>
</form>
</div>
);
};

View file

@ -15,7 +15,7 @@ import Trash from "@/components/icons/trash.svg";
import { usePoll } from "@/components/poll-context";
import { requiredString } from "../../utils/form-validation";
import Button from "../button";
import { Button } from "../button";
import { styleMenuItem } from "../menu-styles";
import NameInput from "../name-input";
import { useParticipants } from "../participants-provider";

View file

@ -2,7 +2,7 @@ import { Trans, useTranslation } from "next-i18next";
import { usePlausible } from "next-plausible";
import * as React from "react";
import Button from "@/components/button";
import { Button } from "@/components/button";
import Bell from "@/components/icons/bell.svg";
import BellCrossed from "@/components/icons/bell-crossed.svg";

View file

@ -4,7 +4,7 @@ import * as React from "react";
import { trpc } from "../../utils/trpc";
import Badge from "../badge";
import Button from "../button";
import { Button } from "../button";
import { usePoll } from "../poll-context";
import Popover from "../popover";
import { usePreferences } from "../preferences/use-preferences";

View file

@ -6,7 +6,7 @@ import * as React from "react";
import toast from "react-hot-toast";
import { useCopyToClipboard } from "react-use";
import Button from "./button";
import { Button } from "./button";
export interface SharingProps {
links: Link[];

View file

@ -1,6 +1,8 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { prisma } from "~/prisma/db";
import { absoluteUrl } from "../../utils/absolute-url";
import { sendEmailTemplate } from "../../utils/api-utils";
import { createToken } from "../../utils/auth";
@ -199,4 +201,20 @@ export const polls = createRouter()
return createPollResponse(poll, link);
},
})
.mutation("delete", {
input: z.object({
urlId: z.string(),
}),
resolve: async ({ input: { urlId } }) => {
const link = await getLink(urlId);
if (link.role !== "admin") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Tried to delete poll using participant url",
});
}
await prisma.poll.delete({ where: { urlId: link.pollId } });
},
});

View file

@ -1,15 +1,7 @@
import { expect, test } from "@playwright/test";
test("should be able to create a new poll", async ({ page, context }) => {
await context.addCookies([
{
name: "rallly_cookie_consent",
value: "1",
url: "http://localhost",
},
]);
test("should be able to create a new poll and delete it", async ({ page }) => {
await page.goto("/new");
// // // Find an element with the text 'About Page' and click on it
await page.type('[placeholder="Monthly Meetup"]', "Monthly Meetup");
// click on label to focus on input
await page.click('text="Location"');
@ -23,6 +15,7 @@ test("should be able to create a new poll", async ({ page, context }) => {
await page.click('[title="Next month"]');
// Select a few days
await page.click("text=/^5$/");
await page.click("text=/^7$/");
await page.click("text=/^10$/");
@ -38,4 +31,22 @@ test("should be able to create a new poll", async ({ page, context }) => {
await expect(page.locator("data-testid=poll-title")).toHaveText(
"Monthly Meetup",
);
// let's delete the poll we just created
await page.click("text=Manage");
await page.click("text=Delete poll");
const deletePollForm = page.locator("data-testid=delete-poll-form");
// button should be disabled
await expect(deletePollForm.locator("text=Delete poll")).toBeDisabled();
// enter confirmation text
await page.type("[placeholder=delete-me]", "delete-me");
// button should now be enabled
await deletePollForm.locator("text=Delete poll").click();
// expect delete message to appear
await expect(page.locator("text=Deleted poll")).toBeVisible();
});