Add ability to change participant's name (#577)

This commit is contained in:
Luke Vella 2023-03-17 19:00:14 +00:00 committed by GitHub
parent 05d2c7b1d0
commit cb52adab01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 406 additions and 261 deletions

View file

@ -12,6 +12,9 @@
"calendarHelp": "You can't create a poll without any options. Add at least one option to continue.",
"calendarHelpTitle": "Forget something?",
"cancel": "Cancel",
"changeName": "Change name",
"changeNameDescription": "Enter a new name for this participant.",
"changeNameInfo": "This will not affect any votes you have already made.",
"comment": "Comment",
"commentPlaceholder": "Leave a comment on this poll (visible to everyone)",
"comments": "Comments",
@ -28,6 +31,8 @@
"deleteDate": "Delete date",
"deletedPoll": "Deleted poll",
"deletedPollInfo": "This poll doesn't exist anymore.",
"deleteParticipant": "Delete {{name}}?",
"deleteParticipantDescription": "Are you sure you want to delete this participant? This action cannot be undone.",
"deletePoll": "Delete poll",
"deletePollDescription": "All data related to this poll will be deleted. To confirm, please type <s>“{{confirmText}}”</s> in to the input below:",
"deletingOptionsWarning": "You are deleting options that participants have voted for. Their votes will also be deleted.",
@ -38,6 +43,7 @@
"edit": "Edit",
"editDetails": "Edit details",
"editOptions": "Edit options",
"editVotes": "Edit votes",
"email": "Email",
"emailNotAllowed": "This email is not allowed.",
"emailPlaceholder": "jessie.smith@email.com",

View file

@ -92,9 +92,10 @@ export const DropdownItem: React.FunctionComponent<{
// in a dropdown
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
disabled={disabled}
onClick={onClick}
className={clsx(
"relative flex w-full select-none items-center whitespace-nowrap rounded py-1.5 pl-2 pr-4 font-medium text-slate-600",
"flex w-full items-center whitespace-nowrap rounded py-1.5 pl-2 pr-4 font-medium text-slate-600",
{
"bg-slate-100": active,
"opacity-50": disabled,

View file

@ -0,0 +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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>

After

Width:  |  Height:  |  Size: 321 B

View file

@ -66,7 +66,10 @@ const Modal: React.FunctionComponent<ModalProps> = ({
exit={{ opacity: 0, scale: 0.9 }}
className="relative z-50 m-3 inline-block max-w-full transform text-left align-middle sm:m-8"
>
<div className="shadow-huge max-w-full overflow-hidden rounded-md bg-white">
<div
data-testid="modal"
className="shadow-huge max-w-full overflow-hidden rounded-md bg-white"
>
{showClose ? (
<button
role="button"

View file

@ -25,3 +25,12 @@ export const useModal = (
);
return [modal, () => setVisible(true), () => setVisible(false)];
};
export const useModalState = (): [boolean, () => void, () => void] => {
const [visible, setVisible] = React.useState(false);
const hide = React.useCallback(() => setVisible(false), []);
const show = React.useCallback(() => setVisible(true), []);
return [visible, show, hide];
};

View file

@ -2,6 +2,7 @@ import { VoteType } from "@rallly/database";
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { useMount } from "react-use";
import { useFormValidation } from "../utils/form-validation";
import { Button } from "./button";
@ -26,52 +27,60 @@ const VoteSummary = ({
votes,
className,
}: {
className: string;
className?: string;
votes: { optionId: string; type: VoteType }[];
}) => {
const { t } = useTranslation("app");
const voteByType = votes.reduce<Record<VoteType, number>>(
const voteByType = votes.reduce<Record<VoteType, string[]>>(
(acc, vote) => {
acc[vote.type] = acc[vote.type] ? acc[vote.type] + 1 : 1;
acc[vote.type] = [...acc[vote.type], vote.optionId];
return acc;
},
{ yes: 0, ifNeedBe: 0, no: 0 },
{ yes: [], ifNeedBe: [], no: [] },
);
const voteTypes = Object.keys(voteByType) as VoteType[];
return (
<div className={clsx("space-y-1", className)}>
<div className="flex items-center gap-2">
<VoteIcon type="yes" />
<div>{t("yes")}</div>
<div className="rounded bg-white px-2 text-sm shadow-sm">
{voteByType["yes"]}
</div>
</div>
<div className="flex items-center gap-2">
<VoteIcon type="ifNeedBe" />
<div>{t("ifNeedBe")}</div>
<div className="rounded bg-white px-2 text-sm shadow-sm">
{voteByType["ifNeedBe"]}
</div>
</div>
<div className="flex items-center gap-2">
<VoteIcon type="no" />
<div>{t("no")}</div>
<div className="rounded bg-white px-2 text-sm shadow-sm">
{voteByType["no"]}
</div>
</div>
<div
className={clsx("flex flex-wrap gap-1.5 rounded border p-1.5", className)}
>
{voteTypes.map((voteType) => {
const votes = voteByType[voteType];
const count = votes.length;
if (count === 0) {
return null;
}
return (
<div
key={voteType}
className="flex h-8 select-none divide-x rounded border bg-gray-50 text-sm"
>
<div className="flex items-center gap-2 pl-2 pr-3">
<VoteIcon type={voteType} />
<div>{t(voteType)}</div>
</div>
<div className="flex h-full items-center justify-center px-2 text-sm font-semibold text-slate-800">
{voteByType[voteType].length}
</div>
</div>
);
})}
</div>
);
};
export const NewParticipantModal = (props: NewParticipantModalProps) => {
const { t } = useTranslation("app");
const { register, formState, handleSubmit } =
const { register, formState, setFocus, handleSubmit } =
useForm<NewParticipantFormData>();
const { requiredString, validEmail } = useFormValidation();
const { poll } = usePoll();
const addParticipant = useAddParticipantMutation();
useMount(() => {
setFocus("name");
});
return (
<div className="max-w-full p-4">
<div className="text-lg font-semibold text-slate-800">
@ -96,7 +105,6 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
</label>
<TextInput
className="w-full"
autoFocus={true}
error={!!formState.errors.name}
disabled={formState.isSubmitting}
placeholder={t("namePlaceholder")}
@ -132,10 +140,7 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
</fieldset>
<fieldset>
<label className="text-slate-500">{t("response")}</label>
<VoteSummary
votes={props.votes}
className="rounded border bg-gray-50 py-2 px-3"
/>
<VoteSummary votes={props.votes} />
</fieldset>
<div className="flex gap-2">
<Button onClick={props.onCancel}>{t("cancel")}</Button>
@ -157,6 +162,8 @@ export const useNewParticipantModal = () => {
const showNewParticipantModal = (props: NewParticipantModalProps) => {
return modalContext.render({
showClose: true,
overlayClosable: true,
content: function Content({ close }) {
return (
<NewParticipantModal

View file

@ -0,0 +1,173 @@
import { useTranslation } from "next-i18next";
import { posthog } from "posthog-js";
import React from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { useMount } from "react-use";
import { Button } from "@/components/button";
import Dropdown, { DropdownItem } from "@/components/dropdown";
import DotsHorizontal from "@/components/icons/dots-horizontal.svg";
import Pencil from "@/components/icons/pencil-alt.svg";
import Tag from "@/components/icons/tag.svg";
import Trash from "@/components/icons/trash.svg";
import Modal from "@/components/modal/modal";
import { useModalState } from "@/components/modal/use-modal";
import { useDeleteParticipantModal } from "@/components/poll/use-delete-participant-modal";
import { TextInput } from "@/components/text-input";
import { useFormValidation } from "@/utils/form-validation";
import { trpc } from "@/utils/trpc";
import { Participant } from ".prisma/client";
export const ParticipantDropdown = ({
participant,
onEdit,
disabled,
}: {
disabled?: boolean;
participant: Participant;
onEdit: () => void;
}) => {
const { t } = useTranslation("app");
const confirmDeleteParticipant = useDeleteParticipantModal();
const [isChangeNameModalVisible, showChangeNameModal, hideChangeNameModal] =
useModalState();
return (
<>
<Dropdown
placement="bottom-start"
trigger={
<Button data-testid="participant-menu">
<DotsHorizontal className="h-4 text-slate-500" />
</Button>
}
>
<DropdownItem
disabled={disabled}
icon={Pencil}
onClick={onEdit}
label={t("editVotes")}
/>
<DropdownItem
disabled={disabled}
icon={Tag}
onClick={showChangeNameModal}
label={t("changeName")}
/>
<DropdownItem
disabled={disabled}
icon={Trash}
onClick={() =>
confirmDeleteParticipant(participant.id, participant.name)
}
label={t("delete")}
/>
</Dropdown>
<Modal
showClose={true}
overlayClosable={true}
visible={isChangeNameModalVisible}
onCancel={hideChangeNameModal}
footer={null}
content={
<ChangeNameModal
oldName={participant.name}
onDone={hideChangeNameModal}
participantId={participant.id}
/>
}
/>
</>
);
};
type ChangeNameForm = {
name: string;
};
const ChangeNameModal = (props: {
oldName: string;
participantId: string;
onDone: () => void;
}) => {
const changeName = trpc.polls.participants.rename.useMutation({
onSuccess: (_, { participantId, newName }) => {
posthog.capture("changed name", {
participantId,
oldName: props.oldName,
newName,
});
},
});
const { register, handleSubmit, setFocus, formState } =
useForm<ChangeNameForm>({
defaultValues: {
name: props.oldName,
},
});
const { errors } = formState;
useMount(() => {
setFocus("name", {
shouldSelect: true,
});
});
const handler = React.useCallback<SubmitHandler<ChangeNameForm>>(
async ({ name }) => {
if (formState.isDirty) {
// change name
await changeName.mutateAsync({
participantId: props.participantId,
newName: name,
});
}
props.onDone();
},
[changeName, formState.isDirty, props],
);
const { requiredString } = useFormValidation();
const { t } = useTranslation("app");
return (
<form onSubmit={handleSubmit(handler)} className="max-w-sm space-y-3 p-4">
<div>
<div className="text-lg font-semibold text-slate-800">
{t("changeName")}
</div>
<div>{t("changeNameDescription")}</div>
</div>
<fieldset>
<label className="text-slate-500">{t("name")}</label>
<TextInput
className="w-full"
error={!!errors.name}
disabled={formState.isSubmitting}
{...register("name", {
validate: requiredString(t("name")),
})}
/>
{errors.name ? (
<div className="text-sm text-rose-500">{errors.name.message}</div>
) : null}
<div className="mt-2 text-sm text-slate-400">{t("changeNameInfo")}</div>
</fieldset>
<div className="flex gap-2 ">
<Button disabled={formState.isSubmitting} onClick={props.onDone}>
{t("cancel")}
</Button>
<Button
loading={formState.isSubmitting}
htmlType="submit"
type="primary"
>
{t("save")}
</Button>
</div>
</form>
);
};

View file

@ -1,4 +1,3 @@
import { AnimatePresence, m } from "framer-motion";
import { Trans, useTranslation } from "next-i18next";
import * as React from "react";
import { useMeasure } from "react-use";
@ -62,7 +61,7 @@ const Poll: React.FunctionComponent = () => {
columnWidth * options.length - columnWidth * numberOfVisibleColumns;
const [shouldShowNewParticipantForm, setShouldShowNewParticipantForm] =
React.useState(!poll.closed && !userAlreadyVoted);
React.useState(!(poll.closed || userAlreadyVoted));
const pollWidth = sidebarWidth + options.length * columnWidth;
@ -109,18 +108,20 @@ const Poll: React.FunctionComponent = () => {
>
<div className="flex flex-col overflow-hidden">
<div className="flex h-14 shrink-0 items-center justify-between border-b bg-gradient-to-b from-gray-50 to-gray-100/50 p-3">
<div className="p-1">
<div>
{shouldShowNewParticipantForm || editingParticipantId ? (
<Trans
t={t}
i18nKey="saveInstruction"
values={{
action: shouldShowNewParticipantForm
? t("continue")
: t("save"),
}}
components={{ b: <strong /> }}
/>
<div className="px-1">
<Trans
t={t}
i18nKey="saveInstruction"
values={{
action: shouldShowNewParticipantForm
? t("continue")
: t("save"),
}}
components={{ b: <strong /> }}
/>
</div>
) : (
<div className="flex gap-2">
<div className="font-semibold text-slate-800">
@ -141,7 +142,7 @@ const Poll: React.FunctionComponent = () => {
)}
</div>
<div className="flex items-center gap-3">
<div className="p-1">
<div className="px-3">
{t("optionCount", { count: options.length })}
</div>
{maxScrollPosition > 0 ? (
@ -182,111 +183,91 @@ const Poll: React.FunctionComponent = () => {
<div>
<div className="flex py-3">
<div
className="flex shrink-0 items-center pl-4 pr-2 font-medium"
className="flex shrink-0 items-end pl-4 pr-2 font-medium"
style={{ width: sidebarWidth }}
></div>
>
<div className="font-semibold text-slate-800"></div>
</div>
<PollHeader />
</div>
</div>
<div className="pb-2">
<AnimatePresence initial={false}>
<div>
<div>
{shouldShowNewParticipantForm &&
!poll.closed &&
!editingParticipantId ? (
<m.div
variants={{
hidden: { height: 0, y: -50, opacity: 0 },
visible: { height: "auto", y: 0, opacity: 1 },
exit: { height: 0, opacity: 0, y: -25 },
}}
initial="hidden"
animate="visible"
>
<ParticipantRowForm
className="shrink-0"
onSubmit={async ({ votes }) => {
showNewParticipantModal({
votes,
onSubmit: () => {
setShouldShowNewParticipantForm(false);
},
});
}}
/>
</m.div>
) : null}
</AnimatePresence>
{participants.map((participant, i) => {
return (
<ParticipantRow
key={i}
className={
editingParticipantId &&
editingParticipantId !== participant.id
? "opacity-50"
: ""
}
participant={participant}
disableEditing={!!editingParticipantId}
editMode={editingParticipantId === participant.id}
onChangeEditMode={(isEditing) => {
if (isEditing) {
setShouldShowNewParticipantForm(false);
setEditingParticipantId(participant.id);
}
}}
<ParticipantRowForm
className="mb-2 shrink-0"
onSubmit={async ({ votes }) => {
await updateParticipant.mutateAsync({
participantId: participant.id,
pollId: poll.id,
showNewParticipantModal({
votes,
onSubmit: () => {
setShouldShowNewParticipantForm(false);
},
});
setEditingParticipantId(null);
}}
/>
);
})}
</div>
<AnimatePresence initial={false}>
{!poll.closed &&
(shouldShowNewParticipantForm || editingParticipantId) ? (
<m.div
variants={{
hidden: { height: 0, y: 30, opacity: 0 },
visible: { height: "auto", y: 0, opacity: 1 },
}}
initial="hidden"
animate="visible"
exit="hidden"
className="flex shrink-0 items-center border-t bg-gray-50"
>
<div className="flex w-full items-center justify-between gap-3 p-3">
<Button
onClick={() => {
if (editingParticipantId) {
setEditingParticipantId(null);
} else {
setShouldShowNewParticipantForm(false);
}
}}
>
{t("cancel")}
</Button>
<Button
key="submit"
form="participant-row-form"
htmlType="submit"
type="primary"
loading={
addParticipant.isLoading || updateParticipant.isLoading
}
>
{shouldShowNewParticipantForm ? t("continue") : t("save")}
</Button>
) : null}
{participants.length > 0 ? (
<div className="py-2">
{participants.map((participant, i) => {
return (
<ParticipantRow
key={i}
participant={participant}
disableEditing={!!editingParticipantId}
editMode={editingParticipantId === participant.id}
onChangeEditMode={(isEditing) => {
if (isEditing) {
setShouldShowNewParticipantForm(false);
setEditingParticipantId(participant.id);
}
}}
onSubmit={async ({ votes }) => {
await updateParticipant.mutateAsync({
participantId: participant.id,
pollId: poll.id,
votes,
});
setEditingParticipantId(null);
}}
/>
);
})}
</div>
</m.div>
) : null}
</AnimatePresence>
) : null}
</div>
</div>
{!poll.closed &&
(shouldShowNewParticipantForm || editingParticipantId) ? (
<div className="flex shrink-0 items-center border-t bg-gray-50">
<div className="flex w-full items-center justify-between gap-3 p-3">
<Button
onClick={() => {
if (editingParticipantId) {
setEditingParticipantId(null);
} else {
setShouldShowNewParticipantForm(false);
}
}}
>
{t("cancel")}
</Button>
<Button
key="submit"
form="participant-row-form"
htmlType="submit"
type="primary"
loading={
addParticipant.isLoading || updateParticipant.isLoading
}
>
{shouldShowNewParticipantForm ? t("continue") : t("save")}
</Button>
</div>
</div>
) : null}
</div>
</div>
</PollContext.Provider>

View file

@ -1,17 +1,12 @@
import { Participant, Vote, VoteType } from "@rallly/database";
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import * as React from "react";
import DotsVertical from "@/components/icons/dots-vertical.svg";
import Pencil from "@/components/icons/pencil-alt.svg";
import Trash from "@/components/icons/trash.svg";
import { ParticipantDropdown } from "@/components/participant-dropdown";
import { usePoll } from "@/components/poll-context";
import { useUser } from "@/components/user-provider";
import Dropdown, { DropdownItem } from "../../dropdown";
import { ParticipantFormSubmitted } from "../types";
import { useDeleteParticipantModal } from "../use-delete-participant-modal";
import UserAvatar from "../user-avatar";
import VoteIcon from "../vote-icon";
import ControlledScrollArea from "./controlled-scroll-area";
@ -29,11 +24,9 @@ export interface ParticipantRowProps {
export const ParticipantRowView: React.FunctionComponent<{
name: string;
editable?: boolean;
action?: React.ReactNode;
color?: string;
votes: Array<VoteType | undefined>;
onEdit?: () => void;
onDelete?: () => void;
columnWidth: number;
className?: string;
sidebarWidth: number;
@ -41,18 +34,15 @@ export const ParticipantRowView: React.FunctionComponent<{
participantId: string;
}> = ({
name,
editable,
action,
votes,
onEdit,
className,
onDelete,
sidebarWidth,
columnWidth,
isYou,
color,
participantId,
}) => {
const { t } = useTranslation("app");
return (
<div
data-testid="participant-row"
@ -64,28 +54,7 @@ export const ParticipantRowView: React.FunctionComponent<{
style={{ width: sidebarWidth }}
>
<UserAvatar name={name} showName={true} isYou={isYou} color={color} />
{editable ? (
<div className="flex">
<Dropdown
placement="bottom-start"
trigger={
<button
data-testid="participant-menu"
className="text-slate-500 hover:text-slate-800"
>
<DotsVertical className="h-3" />
</button>
}
>
<DropdownItem icon={Pencil} onClick={onEdit} label={t("edit")} />
<DropdownItem
icon={Trash}
onClick={onDelete}
label={t("delete")}
/>
</Dropdown>
</div>
) : null}
{action}
</div>
<ControlledScrollArea className="h-full">
{votes.map((vote, i) => {
@ -97,7 +66,7 @@ export const ParticipantRowView: React.FunctionComponent<{
>
<div
className={clsx(
"flex h-full w-full items-center justify-center rounded border border-slate-200 bg-slate-50/75",
"flex h-full w-full items-center justify-center rounded border bg-gray-50",
)}
>
<VoteIcon type={vote} />
@ -120,8 +89,6 @@ const ParticipantRow: React.FunctionComponent<ParticipantRowProps> = ({
}) => {
const { columnWidth, sidebarWidth } = usePollContext();
const confirmDeleteParticipant = useDeleteParticipantModal();
const session = useUser();
const { poll, getVote, options, admin } = usePoll();
@ -153,24 +120,27 @@ const ParticipantRow: React.FunctionComponent<ParticipantRowProps> = ({
}
return (
<ParticipantRowView
sidebarWidth={sidebarWidth}
columnWidth={columnWidth}
className={className}
name={participant.name}
votes={options.map(({ optionId }) => {
return getVote(participant.id, optionId);
})}
participantId={participant.id}
editable={canEdit}
isYou={isYou}
onEdit={() => {
onChangeEditMode?.(true);
}}
onDelete={() => {
confirmDeleteParticipant(participant.id);
}}
/>
<>
<ParticipantRowView
sidebarWidth={sidebarWidth}
columnWidth={columnWidth}
className={className}
name={participant.name}
votes={options.map(({ optionId }) => {
return getVote(participant.id, optionId);
})}
participantId={participant.id}
action={
canEdit ? (
<ParticipantDropdown
participant={participant}
onEdit={() => onChangeEditMode?.(true)}
/>
) : null
}
isYou={isYou}
/>
</>
);
};

View file

@ -6,9 +6,8 @@ import { FormProvider, useForm } from "react-hook-form";
import smoothscroll from "smoothscroll-polyfill";
import ChevronDown from "@/components/icons/chevron-down.svg";
import Pencil from "@/components/icons/pencil-alt.svg";
import PlusCircle from "@/components/icons/plus-circle.svg";
import Trash from "@/components/icons/trash.svg";
import { ParticipantDropdown } from "@/components/participant-dropdown";
import { usePoll } from "@/components/poll-context";
import { You } from "@/components/you";
@ -21,7 +20,6 @@ import { isUnclaimed, useUser } from "../user-provider";
import GroupedOptions from "./mobile-poll/grouped-options";
import { normalizeVotes, useUpdateParticipantMutation } from "./mutations";
import { ParticipantForm } from "./types";
import { useDeleteParticipantModal } from "./use-delete-participant-modal";
import UserAvatar from "./user-avatar";
if (typeof window !== "undefined") {
@ -57,8 +55,10 @@ const MobilePoll: React.FunctionComponent = () => {
const [selectedParticipantId, setSelectedParticipantId] = React.useState<
string | undefined
>(() => {
const participant = participants.find((p) => session.ownsObject(p));
return participant?.id;
if (!admin) {
const participant = participants.find((p) => session.ownsObject(p));
return participant?.id;
}
});
const selectedParticipant = selectedParticipantId
@ -75,8 +75,6 @@ const MobilePoll: React.FunctionComponent = () => {
const updateParticipant = useUpdateParticipantMutation();
const confirmDeleteParticipant = useDeleteParticipantModal();
const showNewParticipantModal = useNewParticipantModal();
return (
@ -178,52 +176,28 @@ const MobilePoll: React.FunctionComponent = () => {
{t("cancel")}
</Button>
) : selectedParticipant ? (
<div className="flex space-x-2">
<Button
icon={<Pencil />}
disabled={
poll.closed ||
// if user is participant (not admin)
(!admin &&
// and does not own this participant
!session.ownsObject(selectedParticipant) &&
// and the participant has been claimed by a different user
!isUnclaimed(selectedParticipant))
// not allowed to edit
}
onClick={() => {
setIsEditing(true);
reset({
votes: optionIds.map((optionId) => ({
optionId,
type: getVote(selectedParticipant.id, optionId),
})),
});
}}
>
{t("edit")}
</Button>
<Button
icon={<Trash />}
disabled={
poll.closed ||
// if user is participant (not admin)
(!admin &&
// and does not own this participant
!session.ownsObject(selectedParticipant) &&
// or the participant has been claimed by a different user
!isUnclaimed(selectedParticipant))
// not allowed to edit
}
data-testid="delete-participant-button"
type="danger"
onClick={() => {
if (selectedParticipant) {
confirmDeleteParticipant(selectedParticipant.id);
}
}}
/>
</div>
<ParticipantDropdown
disabled={
poll.closed ||
// if user is participant (not admin)
(!admin &&
// and does not own this participant
!session.ownsObject(selectedParticipant) &&
// and the participant has been claimed by a different user
!isUnclaimed(selectedParticipant))
// not allowed to edit
}
participant={selectedParticipant}
onEdit={() => {
setIsEditing(true);
reset({
votes: optionIds.map((optionId) => ({
optionId,
type: getVote(selectedParticipant.id, optionId),
})),
});
}}
/>
) : (
<Button
type="primary"
@ -262,7 +236,7 @@ const MobilePoll: React.FunctionComponent = () => {
{isEditing ? (
<m.div
variants={{
hidden: { opacity: 0, y: -100, height: 0 },
hidden: { opacity: 0, y: -20, height: 0 },
visible: { opacity: 1, y: 0, height: "auto" },
}}
initial="hidden"

View file

@ -28,12 +28,10 @@ export const ScoreSummary: React.FunctionComponent<PopularityScoreProps> =
duration: 0.1,
}}
initial={{
opacity: 0,
y: 10 * direction,
}}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: 10 * direction,
}}
key={score}

View file

@ -1,3 +1,5 @@
import { useTranslation } from "next-i18next";
import { useModalContext } from "../modal/modal-provider";
import { usePoll } from "../poll-context";
import { useDeleteParticipantMutation } from "./mutations";
@ -8,22 +10,24 @@ export const useDeleteParticipantModal = () => {
const deleteParticipant = useDeleteParticipantMutation();
const { poll } = usePoll();
return (participantId: string) => {
const { t } = useTranslation("app");
return (participantId: string, participantName: string) => {
return render({
title: "Delete participant?",
description:
"Are you sure you want to remove this participant from the poll?",
title: t("deleteParticipant", { name: participantName }),
description: t("deleteParticipantDescription"),
okButtonProps: {
type: "danger",
},
okText: "Delete",
okText: t("delete"),
onOk: () => {
deleteParticipant.mutate({
pollId: poll.id,
participantId,
});
},
cancelText: "Cancel",
overlayClosable: true,
cancelText: t("cancel"),
});
};
};

View file

@ -20,7 +20,7 @@ export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
ref={ref}
type="text"
className={clsx(
"appearance-none rounded border border-gray-300 text-slate-700 shadow-sm placeholder:text-slate-400",
"appearance-none rounded border border-gray-300 text-slate-700 placeholder:text-slate-400",
className,
{
"px-2 py-1": size === "md",

View file

@ -123,6 +123,19 @@ export const participants = router({
return participant;
}),
rename: publicProcedure
.input(z.object({ participantId: z.string(), newName: z.string() }))
.mutation(async ({ input: { participantId, newName } }) => {
await prisma.participant.update({
where: {
id: participantId,
},
data: {
name: newName,
},
select: null,
});
}),
update: publicProcedure
.input(
z.object({

View file

@ -21,11 +21,14 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
page.locator("data-testid=participant-selector").locator("text=You"),
).toBeVisible();
await page.click("text=Edit");
await page.getByTestId("participant-menu").click();
await page.getByText("Edit votes").click();
await page.click("data-testid=poll-option >> nth=1");
await page.click("text=Save");
await page.click("data-testid=delete-participant-button");
await page.getByTestId("participant-menu").click();
await page.locator("button", { hasText: "Delete" }).click();
const modal = page.getByTestId("modal");
await modal.locator("button", { hasText: "Delete" }).click();
await expect(page.locator("text='Test user'")).not.toBeVisible();
});