From cb52adab0142ca3010ec909554779f6d54c3f9fc Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Fri, 17 Mar 2023 19:00:14 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20ability=20to=20change=20parti?= =?UTF-8?q?cipant's=20name=20(#577)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/public/locales/en/app.json | 6 + apps/web/src/components/dropdown.tsx | 3 +- apps/web/src/components/icons/tag.svg | 3 + apps/web/src/components/modal/modal.tsx | 5 +- apps/web/src/components/modal/use-modal.tsx | 9 + .../src/components/new-participant-modal.tsx | 71 ++++--- .../src/components/participant-dropdown.tsx | 173 ++++++++++++++++ apps/web/src/components/poll/desktop-poll.tsx | 193 ++++++++---------- .../poll/desktop-poll/participant-row.tsx | 82 +++----- apps/web/src/components/poll/mobile-poll.tsx | 82 +++----- .../web/src/components/poll/score-summary.tsx | 2 - .../poll/use-delete-participant-modal.ts | 16 +- apps/web/src/components/text-input.tsx | 2 +- .../src/server/routers/polls/participants.ts | 13 ++ apps/web/tests/mobile-test.spec.ts | 7 +- 15 files changed, 406 insertions(+), 261 deletions(-) create mode 100644 apps/web/src/components/icons/tag.svg create mode 100644 apps/web/src/components/participant-dropdown.tsx diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index a650276d9..682557112 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -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 “{{confirmText}}” 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", diff --git a/apps/web/src/components/dropdown.tsx b/apps/web/src/components/dropdown.tsx index cd9077118..26b84ab46 100644 --- a/apps/web/src/components/dropdown.tsx +++ b/apps/web/src/components/dropdown.tsx @@ -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, diff --git a/apps/web/src/components/icons/tag.svg b/apps/web/src/components/icons/tag.svg new file mode 100644 index 000000000..099dda0e1 --- /dev/null +++ b/apps/web/src/components/icons/tag.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/web/src/components/modal/modal.tsx b/apps/web/src/components/modal/modal.tsx index a24930efa..7422be20d 100644 --- a/apps/web/src/components/modal/modal.tsx +++ b/apps/web/src/components/modal/modal.tsx @@ -66,7 +66,10 @@ const Modal: React.FunctionComponent = ({ 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" > -
+
{showClose ? ( @@ -157,6 +162,8 @@ export const useNewParticipantModal = () => { const showNewParticipantModal = (props: NewParticipantModalProps) => { return modalContext.render({ + showClose: true, + overlayClosable: true, content: function Content({ close }) { return ( void; +}) => { + const { t } = useTranslation("app"); + const confirmDeleteParticipant = useDeleteParticipantModal(); + + const [isChangeNameModalVisible, showChangeNameModal, hideChangeNameModal] = + useModalState(); + + return ( + <> + + + + } + > + + + + confirmDeleteParticipant(participant.id, participant.name) + } + label={t("delete")} + /> + + + } + /> + + ); +}; + +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({ + defaultValues: { + name: props.oldName, + }, + }); + + const { errors } = formState; + + useMount(() => { + setFocus("name", { + shouldSelect: true, + }); + }); + + const handler = React.useCallback>( + 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 ( +
+
+
+ {t("changeName")} +
+
{t("changeNameDescription")}
+
+
+ + + {errors.name ? ( +
{errors.name.message}
+ ) : null} +
{t("changeNameInfo")}
+
+
+ + +
+
+ ); +}; diff --git a/apps/web/src/components/poll/desktop-poll.tsx b/apps/web/src/components/poll/desktop-poll.tsx index 6538193e3..22c6676ab 100644 --- a/apps/web/src/components/poll/desktop-poll.tsx +++ b/apps/web/src/components/poll/desktop-poll.tsx @@ -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 = () => { >
-
+
{shouldShowNewParticipantForm || editingParticipantId ? ( - }} - /> +
+ }} + /> +
) : (
@@ -141,7 +142,7 @@ const Poll: React.FunctionComponent = () => { )}
-
+
{t("optionCount", { count: options.length })}
{maxScrollPosition > 0 ? ( @@ -182,111 +183,91 @@ const Poll: React.FunctionComponent = () => {
+ > +
+
-
- +
+
{shouldShowNewParticipantForm && !poll.closed && !editingParticipantId ? ( - - { - showNewParticipantModal({ - votes, - onSubmit: () => { - setShouldShowNewParticipantForm(false); - }, - }); - }} - /> - - ) : null} - - {participants.map((participant, i) => { - return ( - { - if (isEditing) { - setShouldShowNewParticipantForm(false); - setEditingParticipantId(participant.id); - } - }} + { - await updateParticipant.mutateAsync({ - participantId: participant.id, - pollId: poll.id, + showNewParticipantModal({ votes, + onSubmit: () => { + setShouldShowNewParticipantForm(false); + }, }); - setEditingParticipantId(null); }} /> - ); - })} -
- - {!poll.closed && - (shouldShowNewParticipantForm || editingParticipantId) ? ( - -
- - + ) : null} + {participants.length > 0 ? ( +
+ {participants.map((participant, i) => { + return ( + { + if (isEditing) { + setShouldShowNewParticipantForm(false); + setEditingParticipantId(participant.id); + } + }} + onSubmit={async ({ votes }) => { + await updateParticipant.mutateAsync({ + participantId: participant.id, + pollId: poll.id, + votes, + }); + setEditingParticipantId(null); + }} + /> + ); + })}
- - ) : null} - + ) : null} +
+
+ + {!poll.closed && + (shouldShowNewParticipantForm || editingParticipantId) ? ( +
+
+ + +
+
+ ) : null}
diff --git a/apps/web/src/components/poll/desktop-poll/participant-row.tsx b/apps/web/src/components/poll/desktop-poll/participant-row.tsx index e6464474e..4dc7d33d8 100644 --- a/apps/web/src/components/poll/desktop-poll/participant-row.tsx +++ b/apps/web/src/components/poll/desktop-poll/participant-row.tsx @@ -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; - 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 (
- {editable ? ( -
- - - - } - > - - - -
- ) : null} + {action}
{votes.map((vote, i) => { @@ -97,7 +66,7 @@ export const ParticipantRowView: React.FunctionComponent<{ >
@@ -120,8 +89,6 @@ const ParticipantRow: React.FunctionComponent = ({ }) => { const { columnWidth, sidebarWidth } = usePollContext(); - const confirmDeleteParticipant = useDeleteParticipantModal(); - const session = useUser(); const { poll, getVote, options, admin } = usePoll(); @@ -153,24 +120,27 @@ const ParticipantRow: React.FunctionComponent = ({ } return ( - { - return getVote(participant.id, optionId); - })} - participantId={participant.id} - editable={canEdit} - isYou={isYou} - onEdit={() => { - onChangeEditMode?.(true); - }} - onDelete={() => { - confirmDeleteParticipant(participant.id); - }} - /> + <> + { + return getVote(participant.id, optionId); + })} + participantId={participant.id} + action={ + canEdit ? ( + onChangeEditMode?.(true)} + /> + ) : null + } + isYou={isYou} + /> + ); }; diff --git a/apps/web/src/components/poll/mobile-poll.tsx b/apps/web/src/components/poll/mobile-poll.tsx index 22aec1525..21be22972 100644 --- a/apps/web/src/components/poll/mobile-poll.tsx +++ b/apps/web/src/components/poll/mobile-poll.tsx @@ -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")} ) : selectedParticipant ? ( -
- -
+ { + setIsEditing(true); + reset({ + votes: optionIds.map((optionId) => ({ + optionId, + type: getVote(selectedParticipant.id, optionId), + })), + }); + }} + /> ) : (