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.", "calendarHelp": "You can't create a poll without any options. Add at least one option to continue.",
"calendarHelpTitle": "Forget something?", "calendarHelpTitle": "Forget something?",
"cancel": "Cancel", "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", "comment": "Comment",
"commentPlaceholder": "Leave a comment on this poll (visible to everyone)", "commentPlaceholder": "Leave a comment on this poll (visible to everyone)",
"comments": "Comments", "comments": "Comments",
@ -28,6 +31,8 @@
"deleteDate": "Delete date", "deleteDate": "Delete date",
"deletedPoll": "Deleted poll", "deletedPoll": "Deleted poll",
"deletedPollInfo": "This poll doesn't exist anymore.", "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", "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:", "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.", "deletingOptionsWarning": "You are deleting options that participants have voted for. Their votes will also be deleted.",
@ -38,6 +43,7 @@
"edit": "Edit", "edit": "Edit",
"editDetails": "Edit details", "editDetails": "Edit details",
"editOptions": "Edit options", "editOptions": "Edit options",
"editVotes": "Edit votes",
"email": "Email", "email": "Email",
"emailNotAllowed": "This email is not allowed.", "emailNotAllowed": "This email is not allowed.",
"emailPlaceholder": "jessie.smith@email.com", "emailPlaceholder": "jessie.smith@email.com",

View file

@ -92,9 +92,10 @@ export const DropdownItem: React.FunctionComponent<{
// in a dropdown // in a dropdown
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any} href={href as any}
disabled={disabled}
onClick={onClick} onClick={onClick}
className={clsx( 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, "bg-slate-100": active,
"opacity-50": disabled, "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 }} 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" 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 ? ( {showClose ? (
<button <button
role="button" role="button"

View file

@ -25,3 +25,12 @@ export const useModal = (
); );
return [modal, () => setVisible(true), () => setVisible(false)]; 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 clsx from "clsx";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useMount } from "react-use";
import { useFormValidation } from "../utils/form-validation"; import { useFormValidation } from "../utils/form-validation";
import { Button } from "./button"; import { Button } from "./button";
@ -26,52 +27,60 @@ const VoteSummary = ({
votes, votes,
className, className,
}: { }: {
className: string; className?: string;
votes: { optionId: string; type: VoteType }[]; votes: { optionId: string; type: VoteType }[];
}) => { }) => {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const voteByType = votes.reduce<Record<VoteType, number>>( const voteByType = votes.reduce<Record<VoteType, string[]>>(
(acc, vote) => { (acc, vote) => {
acc[vote.type] = acc[vote.type] ? acc[vote.type] + 1 : 1; acc[vote.type] = [...acc[vote.type], vote.optionId];
return acc; return acc;
}, },
{ yes: 0, ifNeedBe: 0, no: 0 }, { yes: [], ifNeedBe: [], no: [] },
); );
const voteTypes = Object.keys(voteByType) as VoteType[];
return ( return (
<div className={clsx("space-y-1", className)}> <div
<div className="flex items-center gap-2"> className={clsx("flex flex-wrap gap-1.5 rounded border p-1.5", className)}
<VoteIcon type="yes" /> >
<div>{t("yes")}</div> {voteTypes.map((voteType) => {
<div className="rounded bg-white px-2 text-sm shadow-sm"> const votes = voteByType[voteType];
{voteByType["yes"]} const count = votes.length;
</div> if (count === 0) {
</div> return null;
<div className="flex items-center gap-2"> }
<VoteIcon type="ifNeedBe" /> return (
<div>{t("ifNeedBe")}</div> <div
<div className="rounded bg-white px-2 text-sm shadow-sm"> key={voteType}
{voteByType["ifNeedBe"]} className="flex h-8 select-none divide-x rounded border bg-gray-50 text-sm"
</div> >
</div> <div className="flex items-center gap-2 pl-2 pr-3">
<div className="flex items-center gap-2"> <VoteIcon type={voteType} />
<VoteIcon type="no" /> <div>{t(voteType)}</div>
<div>{t("no")}</div> </div>
<div className="rounded bg-white px-2 text-sm shadow-sm"> <div className="flex h-full items-center justify-center px-2 text-sm font-semibold text-slate-800">
{voteByType["no"]} {voteByType[voteType].length}
</div> </div>
</div> </div>
);
})}
</div> </div>
); );
}; };
export const NewParticipantModal = (props: NewParticipantModalProps) => { export const NewParticipantModal = (props: NewParticipantModalProps) => {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { register, formState, handleSubmit } = const { register, formState, setFocus, handleSubmit } =
useForm<NewParticipantFormData>(); useForm<NewParticipantFormData>();
const { requiredString, validEmail } = useFormValidation(); const { requiredString, validEmail } = useFormValidation();
const { poll } = usePoll(); const { poll } = usePoll();
const addParticipant = useAddParticipantMutation(); const addParticipant = useAddParticipantMutation();
useMount(() => {
setFocus("name");
});
return ( return (
<div className="max-w-full p-4"> <div className="max-w-full p-4">
<div className="text-lg font-semibold text-slate-800"> <div className="text-lg font-semibold text-slate-800">
@ -96,7 +105,6 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
</label> </label>
<TextInput <TextInput
className="w-full" className="w-full"
autoFocus={true}
error={!!formState.errors.name} error={!!formState.errors.name}
disabled={formState.isSubmitting} disabled={formState.isSubmitting}
placeholder={t("namePlaceholder")} placeholder={t("namePlaceholder")}
@ -132,10 +140,7 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
</fieldset> </fieldset>
<fieldset> <fieldset>
<label className="text-slate-500">{t("response")}</label> <label className="text-slate-500">{t("response")}</label>
<VoteSummary <VoteSummary votes={props.votes} />
votes={props.votes}
className="rounded border bg-gray-50 py-2 px-3"
/>
</fieldset> </fieldset>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={props.onCancel}>{t("cancel")}</Button> <Button onClick={props.onCancel}>{t("cancel")}</Button>
@ -157,6 +162,8 @@ export const useNewParticipantModal = () => {
const showNewParticipantModal = (props: NewParticipantModalProps) => { const showNewParticipantModal = (props: NewParticipantModalProps) => {
return modalContext.render({ return modalContext.render({
showClose: true,
overlayClosable: true,
content: function Content({ close }) { content: function Content({ close }) {
return ( return (
<NewParticipantModal <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 { Trans, useTranslation } from "next-i18next";
import * as React from "react"; import * as React from "react";
import { useMeasure } from "react-use"; import { useMeasure } from "react-use";
@ -62,7 +61,7 @@ const Poll: React.FunctionComponent = () => {
columnWidth * options.length - columnWidth * numberOfVisibleColumns; columnWidth * options.length - columnWidth * numberOfVisibleColumns;
const [shouldShowNewParticipantForm, setShouldShowNewParticipantForm] = const [shouldShowNewParticipantForm, setShouldShowNewParticipantForm] =
React.useState(!poll.closed && !userAlreadyVoted); React.useState(!(poll.closed || userAlreadyVoted));
const pollWidth = sidebarWidth + options.length * columnWidth; const pollWidth = sidebarWidth + options.length * columnWidth;
@ -109,18 +108,20 @@ const Poll: React.FunctionComponent = () => {
> >
<div className="flex flex-col overflow-hidden"> <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="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 ? ( {shouldShowNewParticipantForm || editingParticipantId ? (
<Trans <div className="px-1">
t={t} <Trans
i18nKey="saveInstruction" t={t}
values={{ i18nKey="saveInstruction"
action: shouldShowNewParticipantForm values={{
? t("continue") action: shouldShowNewParticipantForm
: t("save"), ? t("continue")
}} : t("save"),
components={{ b: <strong /> }} }}
/> components={{ b: <strong /> }}
/>
</div>
) : ( ) : (
<div className="flex gap-2"> <div className="flex gap-2">
<div className="font-semibold text-slate-800"> <div className="font-semibold text-slate-800">
@ -141,7 +142,7 @@ const Poll: React.FunctionComponent = () => {
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-1"> <div className="px-3">
{t("optionCount", { count: options.length })} {t("optionCount", { count: options.length })}
</div> </div>
{maxScrollPosition > 0 ? ( {maxScrollPosition > 0 ? (
@ -182,111 +183,91 @@ const Poll: React.FunctionComponent = () => {
<div> <div>
<div className="flex py-3"> <div className="flex py-3">
<div <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 }} style={{ width: sidebarWidth }}
></div> >
<div className="font-semibold text-slate-800"></div>
</div>
<PollHeader /> <PollHeader />
</div> </div>
</div> </div>
<div className="pb-2"> <div>
<AnimatePresence initial={false}> <div>
{shouldShowNewParticipantForm && {shouldShowNewParticipantForm &&
!poll.closed && !poll.closed &&
!editingParticipantId ? ( !editingParticipantId ? (
<m.div <ParticipantRowForm
variants={{ className="mb-2 shrink-0"
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);
}
}}
onSubmit={async ({ votes }) => { onSubmit={async ({ votes }) => {
await updateParticipant.mutateAsync({ showNewParticipantModal({
participantId: participant.id,
pollId: poll.id,
votes, votes,
onSubmit: () => {
setShouldShowNewParticipantForm(false);
},
}); });
setEditingParticipantId(null);
}} }}
/> />
); ) : null}
})} {participants.length > 0 ? (
</div> <div className="py-2">
<AnimatePresence initial={false}> {participants.map((participant, i) => {
{!poll.closed && return (
(shouldShowNewParticipantForm || editingParticipantId) ? ( <ParticipantRow
<m.div key={i}
variants={{ participant={participant}
hidden: { height: 0, y: 30, opacity: 0 }, disableEditing={!!editingParticipantId}
visible: { height: "auto", y: 0, opacity: 1 }, editMode={editingParticipantId === participant.id}
}} onChangeEditMode={(isEditing) => {
initial="hidden" if (isEditing) {
animate="visible" setShouldShowNewParticipantForm(false);
exit="hidden" setEditingParticipantId(participant.id);
className="flex shrink-0 items-center border-t bg-gray-50" }
> }}
<div className="flex w-full items-center justify-between gap-3 p-3"> onSubmit={async ({ votes }) => {
<Button await updateParticipant.mutateAsync({
onClick={() => { participantId: participant.id,
if (editingParticipantId) { pollId: poll.id,
setEditingParticipantId(null); votes,
} else { });
setShouldShowNewParticipantForm(false); setEditingParticipantId(null);
} }}
}} />
> );
{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>
</m.div> ) : null}
) : null} </div>
</AnimatePresence> </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>
</div> </div>
</PollContext.Provider> </PollContext.Provider>

View file

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

View file

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

View file

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

View file

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

View file

@ -20,7 +20,7 @@ export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
ref={ref} ref={ref}
type="text" type="text"
className={clsx( 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, className,
{ {
"px-2 py-1": size === "md", "px-2 py-1": size === "md",

View file

@ -123,6 +123,19 @@ export const participants = router({
return participant; 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 update: publicProcedure
.input( .input(
z.object({ 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"), page.locator("data-testid=participant-selector").locator("text=You"),
).toBeVisible(); ).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("data-testid=poll-option >> nth=1");
await page.click("text=Save"); 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(); 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(); await expect(page.locator("text='Test user'")).not.toBeVisible();
}); });