mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-01 02:01:48 +02:00
✨ Add ability to change participant's name (#577)
This commit is contained in:
parent
05d2c7b1d0
commit
cb52adab01
15 changed files with 406 additions and 261 deletions
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
3
apps/web/src/components/icons/tag.svg
Normal file
3
apps/web/src/components/icons/tag.svg
Normal 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 |
|
@ -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"
|
||||
|
|
|
@ -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];
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
173
apps/web/src/components/participant-dropdown.tsx
Normal file
173
apps/web/src/components/participant-dropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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"),
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue