mirror of
https://github.com/lukevella/rallly.git
synced 2025-08-04 00:48:52 +02:00
✨ Improvements to table component (#809)
This commit is contained in:
parent
b727c3f5e5
commit
83ad12b884
18 changed files with 711 additions and 618 deletions
|
@ -64,8 +64,6 @@
|
|||
"smoothscroll-polyfill": "^0.4.4",
|
||||
"spacetime": "^7.1.4",
|
||||
"superjson": "^1.12.2",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
"timezone-soft": "^1.4.1",
|
||||
"typescript": "^4.9.4",
|
||||
"zod": "^3.20.2"
|
||||
|
|
|
@ -10,22 +10,15 @@ export const DateIconInner = (props: {
|
|||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"w-14 overflow-hidden rounded-md border bg-white text-center text-slate-800",
|
||||
"inline-flex h-12 w-12 flex-col overflow-hidden rounded-md border bg-gray-50 text-center text-slate-800",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="h-4 border-b border-slate-200 bg-slate-50 text-xs leading-4">
|
||||
<div className="text-muted-foreground border-b border-gray-200 text-xs font-normal leading-4">
|
||||
{props.dow}
|
||||
</div>
|
||||
<div className="flex h-10 items-center justify-center">
|
||||
<div>
|
||||
<div className="my-px text-lg font-bold leading-none">
|
||||
{props.day}
|
||||
</div>
|
||||
<div className="text-xs font-bold uppercase tracking-wider">
|
||||
{props.month}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow items-center justify-center bg-white text-lg font-semibold leading-none tracking-tight">
|
||||
{props.day}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -19,7 +19,7 @@ import { Trans } from "next-i18next";
|
|||
const FeedbackButton = () => {
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger className="shadow-huge fixed bottom-8 right-6 hidden h-12 w-12 items-center justify-center rounded-full bg-gray-800 hover:bg-gray-700 active:shadow-none sm:inline-flex">
|
||||
<DropdownMenuTrigger className="shadow-huge fixed bottom-8 right-6 z-20 hidden h-12 w-12 items-center justify-center rounded-full bg-gray-800 hover:bg-gray-700 active:shadow-none sm:inline-flex">
|
||||
<MegaphoneIcon className="h-5 text-white" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={10} align="end">
|
||||
|
|
|
@ -4,7 +4,6 @@ import { keyBy } from "lodash";
|
|||
import { useTranslation } from "next-i18next";
|
||||
import React from "react";
|
||||
|
||||
import { usePermissions } from "@/contexts/permissions";
|
||||
import {
|
||||
decodeOptions,
|
||||
ParsedDateOption,
|
||||
|
@ -16,10 +15,8 @@ import { GetPollApiResponse } from "@/utils/trpc/types";
|
|||
import ErrorPage from "./error-page";
|
||||
import { useParticipants } from "./participants-provider";
|
||||
import { useRequiredContext } from "./use-required-context";
|
||||
import { useUser } from "./user-provider";
|
||||
|
||||
type PollContextValue = {
|
||||
userAlreadyVoted: boolean;
|
||||
poll: GetPollApiResponse;
|
||||
urlId: string;
|
||||
admin: boolean;
|
||||
|
@ -51,9 +48,6 @@ export const PollContextProvider: React.FunctionComponent<{
|
|||
}> = ({ poll, urlId, admin, children }) => {
|
||||
const { t } = useTranslation();
|
||||
const { participants } = useParticipants();
|
||||
const { user } = useUser();
|
||||
|
||||
const { canEditParticipant } = usePermissions();
|
||||
|
||||
const getScore = React.useCallback(
|
||||
(optionId: string) => {
|
||||
|
@ -95,11 +89,6 @@ export const PollContextProvider: React.FunctionComponent<{
|
|||
return participant;
|
||||
};
|
||||
|
||||
const userAlreadyVoted =
|
||||
user && participants
|
||||
? participants.some((participant) => canEditParticipant(participant.id))
|
||||
: false;
|
||||
|
||||
const optionIds = poll.options.map(({ id }) => id);
|
||||
|
||||
const participantById = keyBy(
|
||||
|
@ -119,7 +108,6 @@ export const PollContextProvider: React.FunctionComponent<{
|
|||
|
||||
return {
|
||||
optionIds,
|
||||
userAlreadyVoted,
|
||||
poll,
|
||||
urlId,
|
||||
admin,
|
||||
|
@ -137,7 +125,7 @@ export const PollContextProvider: React.FunctionComponent<{
|
|||
},
|
||||
getScore,
|
||||
};
|
||||
}, [admin, canEditParticipant, getScore, participants, poll, urlId, user]);
|
||||
}, [admin, getScore, participants, poll, urlId]);
|
||||
|
||||
if (poll.deleted) {
|
||||
return (
|
||||
|
|
|
@ -4,17 +4,16 @@ import {
|
|||
PlusIcon,
|
||||
Users2Icon,
|
||||
} from "@rallly/icons";
|
||||
import { cn } from "@rallly/ui";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import clsx from "clsx";
|
||||
import { Trans, useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
import { useMeasure, useUpdateEffect } from "react-use";
|
||||
import { useScroll } from "react-use";
|
||||
|
||||
import { TimesShownIn } from "@/components/clock";
|
||||
import { useVotingForm, VotingForm } from "@/components/poll/voting-form";
|
||||
import { usePermissions } from "@/contexts/permissions";
|
||||
import { useRole } from "@/contexts/role";
|
||||
|
||||
import { useNewParticipantModal } from "../new-participant-modal";
|
||||
import {
|
||||
useParticipants,
|
||||
useVisibleParticipants,
|
||||
|
@ -22,269 +21,228 @@ import {
|
|||
import { usePoll } from "../poll-context";
|
||||
import ParticipantRow from "./desktop-poll/participant-row";
|
||||
import ParticipantRowForm from "./desktop-poll/participant-row-form";
|
||||
import { PollContext } from "./desktop-poll/poll-context";
|
||||
import PollHeader from "./desktop-poll/poll-header";
|
||||
import {
|
||||
useAddParticipantMutation,
|
||||
useUpdateParticipantMutation,
|
||||
} from "./mutations";
|
||||
|
||||
const minSidebarWidth = 200;
|
||||
const useIsOverflowing = <E extends Element | null>(
|
||||
ref: React.RefObject<E>,
|
||||
) => {
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||
|
||||
const Poll: React.FunctionComponent = () => {
|
||||
React.useEffect(() => {
|
||||
const checkOverflow = () => {
|
||||
if (ref.current) {
|
||||
const element = ref.current;
|
||||
const overflowX = element.scrollWidth > element.clientWidth;
|
||||
const overflowY = element.scrollHeight > element.clientHeight;
|
||||
|
||||
setIsOverflowing(overflowX || overflowY);
|
||||
}
|
||||
};
|
||||
|
||||
if (ref.current) {
|
||||
const resizeObserver = new ResizeObserver(checkOverflow);
|
||||
resizeObserver.observe(ref.current);
|
||||
|
||||
// Initial check
|
||||
checkOverflow();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
return isOverflowing;
|
||||
};
|
||||
|
||||
const DesktopPoll: React.FunctionComponent = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { poll, userAlreadyVoted } = usePoll();
|
||||
const { poll } = usePoll();
|
||||
|
||||
const { participants } = useParticipants();
|
||||
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
const votingForm = useVotingForm();
|
||||
|
||||
const [editingParticipantId, setEditingParticipantId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const columnWidth = 80;
|
||||
|
||||
const numberOfVisibleColumns = Math.min(
|
||||
poll.options.length,
|
||||
Math.floor((width - minSidebarWidth) / columnWidth),
|
||||
);
|
||||
|
||||
const sidebarWidth = Math.min(
|
||||
width - numberOfVisibleColumns * columnWidth,
|
||||
275,
|
||||
);
|
||||
|
||||
const availableSpace = Math.min(
|
||||
numberOfVisibleColumns * columnWidth,
|
||||
poll.options.length * columnWidth,
|
||||
);
|
||||
|
||||
const [activeOptionId, setActiveOptionId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [scrollPosition, setScrollPosition] = React.useState(0);
|
||||
|
||||
const maxScrollPosition =
|
||||
columnWidth * poll.options.length - columnWidth * numberOfVisibleColumns;
|
||||
const mode = votingForm.watch("mode");
|
||||
|
||||
const { canAddNewParticipant } = usePermissions();
|
||||
|
||||
const role = useRole();
|
||||
const [shouldShowNewParticipantForm, setShouldShowNewParticipantForm] =
|
||||
React.useState(
|
||||
canAddNewParticipant && !userAlreadyVoted && role === "participant",
|
||||
);
|
||||
|
||||
const pollWidth = sidebarWidth + poll.options.length * columnWidth;
|
||||
const addParticipant = useAddParticipantMutation();
|
||||
|
||||
useUpdateEffect(() => {
|
||||
if (!canAddNewParticipant) {
|
||||
setShouldShowNewParticipantForm(false);
|
||||
}
|
||||
}, [canAddNewParticipant]);
|
||||
|
||||
const goToNextPage = () => {
|
||||
setScrollPosition(
|
||||
Math.min(
|
||||
maxScrollPosition,
|
||||
scrollPosition + numberOfVisibleColumns * columnWidth,
|
||||
),
|
||||
);
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollLeft += 220;
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreviousPage = () => {
|
||||
setScrollPosition(
|
||||
Math.max(0, scrollPosition - numberOfVisibleColumns * columnWidth),
|
||||
);
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollLeft -= 220;
|
||||
}
|
||||
};
|
||||
|
||||
const updateParticipant = useUpdateParticipantMutation();
|
||||
const showNewParticipantModal = useNewParticipantModal();
|
||||
|
||||
const visibleParticipants = useVisibleParticipants();
|
||||
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const isOverflowing = useIsOverflowing(scrollRef);
|
||||
|
||||
const { x } = useScroll(scrollRef);
|
||||
|
||||
return (
|
||||
<PollContext.Provider
|
||||
value={{
|
||||
activeOptionId,
|
||||
setActiveOptionId,
|
||||
scrollPosition,
|
||||
setScrollPosition,
|
||||
columnWidth,
|
||||
sidebarWidth,
|
||||
goToNextPage,
|
||||
goToPreviousPage,
|
||||
numberOfColumns: numberOfVisibleColumns,
|
||||
availableSpace,
|
||||
maxScrollPosition,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"relative min-w-full max-w-full duration-300",
|
||||
width === 0 ? "invisible" : "visible",
|
||||
)} // Don't add styles like border, margin, padding – that can mess up the sizing calculations
|
||||
style={{ width: pollWidth }}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between rounded-t-md border-b bg-gradient-to-b from-gray-50 to-gray-100/50 py-3 px-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between rounded-t-md border-b bg-gradient-to-b from-gray-50 to-gray-100/50 py-3 px-4">
|
||||
<div>
|
||||
{mode !== "view" ? (
|
||||
<div>
|
||||
{shouldShowNewParticipantForm || editingParticipantId ? (
|
||||
<div className="px-1">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="saveInstruction"
|
||||
values={{
|
||||
action: shouldShowNewParticipantForm
|
||||
? t("continue")
|
||||
: t("save"),
|
||||
}}
|
||||
components={{ b: <strong /> }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users2Icon className="h-5 w-5 shrink-0" />
|
||||
<div className="font-semibold">
|
||||
{t("participants", { count: participants.length })} (
|
||||
{participants.length})
|
||||
</div>
|
||||
{canAddNewParticipant ? (
|
||||
<Button
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
data-testid="add-participant-button"
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
setEditingParticipantId(null);
|
||||
setShouldShowNewParticipantForm(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="saveInstruction"
|
||||
values={{
|
||||
action: mode === "new" ? t("continue") : t("save"),
|
||||
}}
|
||||
components={{ b: <strong /> }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users2Icon className="h-5 w-5 shrink-0" />
|
||||
<div className="font-semibold">
|
||||
{t("optionCount", { count: poll.options.length })}
|
||||
{t("participants", { count: participants.length })} (
|
||||
{participants.length})
|
||||
</div>
|
||||
{maxScrollPosition > 0 ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={goToPreviousPage}
|
||||
disabled={scrollPosition === 0}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={scrollPosition === maxScrollPosition}
|
||||
onClick={() => {
|
||||
goToNextPage();
|
||||
}}
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{poll.options[0].duration !== 0 ? (
|
||||
<div className="border-b bg-gray-50 p-3">
|
||||
<TimesShownIn />
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div className="flex py-3">
|
||||
<div
|
||||
className="flex shrink-0 items-end pl-4 pr-2 font-medium"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<div className="font-semibold text-gray-800"></div>
|
||||
</div>
|
||||
<PollHeader />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
{shouldShowNewParticipantForm && !editingParticipantId ? (
|
||||
<ParticipantRowForm
|
||||
className="mb-2 shrink-0"
|
||||
onSubmit={async ({ votes }) => {
|
||||
showNewParticipantModal({
|
||||
votes,
|
||||
onSubmit: () => {
|
||||
setShouldShowNewParticipantForm(false);
|
||||
},
|
||||
});
|
||||
{canAddNewParticipant ? (
|
||||
<Button
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
data-testid="add-participant-button"
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
votingForm.newParticipant();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{visibleParticipants.length > 0 ? (
|
||||
<div className="py-2">
|
||||
{visibleParticipants.map((participant, i) => {
|
||||
return (
|
||||
<ParticipantRow
|
||||
key={i}
|
||||
participant={participant}
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="font-semibold">
|
||||
{t("optionCount", { count: poll.options.length })}
|
||||
</div>
|
||||
|
||||
{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"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={
|
||||
addParticipant.isLoading || updateParticipant.isLoading
|
||||
}
|
||||
>
|
||||
{shouldShowNewParticipantForm ? t("continue") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
{isOverflowing ? (
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={x === 0} onClick={goToPreviousPage}>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={Boolean(
|
||||
scrollRef.current &&
|
||||
x + scrollRef.current.offsetWidth >=
|
||||
scrollRef.current.scrollWidth,
|
||||
)}
|
||||
onClick={() => {
|
||||
goToNextPage();
|
||||
}}
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</PollContext.Provider>
|
||||
{poll.options[0].duration !== 0 ? (
|
||||
<div className="border-b bg-gray-50 p-3">
|
||||
<TimesShownIn />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute left-[240px] top-0 bottom-2 z-30 w-4 border-l bg-gradient-to-r from-gray-200/50 via-transparent to-transparent transition-opacity",
|
||||
x > 0 ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-thin hover:scrollbar-thumb-gray-400 scrollbar-thumb-gray-300 scrollbar-track-gray-100 relative z-10 flex-grow overflow-auto scroll-smooth pr-3 pb-3"
|
||||
>
|
||||
<table className="w-full table-auto border-separate border-spacing-0 ">
|
||||
<thead>
|
||||
<PollHeader />
|
||||
</thead>
|
||||
<tbody>
|
||||
{mode === "new" ? (
|
||||
<>
|
||||
<ParticipantRowForm />
|
||||
<tr aria-hidden="true">
|
||||
<td colSpan={poll.options.length + 1} className="py-2" />
|
||||
</tr>
|
||||
</>
|
||||
) : null}
|
||||
{visibleParticipants.length > 0
|
||||
? visibleParticipants.map((participant, i) => {
|
||||
return (
|
||||
<ParticipantRow
|
||||
key={i}
|
||||
participant={participant}
|
||||
editMode={
|
||||
votingForm.watch("participantId") === participant.id
|
||||
}
|
||||
onChangeEditMode={(isEditing) => {
|
||||
if (isEditing) {
|
||||
votingForm.setEditingParticipantId(participant.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{mode !== "view" ? (
|
||||
<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={() => {
|
||||
votingForm.cancel();
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
{mode === "new" ? (
|
||||
<Button
|
||||
form="voting-form"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={votingForm.formState.isSubmitting}
|
||||
>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
form="voting-form"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={votingForm.formState.isSubmitting}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Poll);
|
||||
const WrappedDesktopPoll = () => {
|
||||
return (
|
||||
<VotingForm>
|
||||
<DesktopPoll />
|
||||
</VotingForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default WrappedDesktopPoll;
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
import clsx from "clsx";
|
||||
import { AnimatePresence, m } from "framer-motion";
|
||||
import React from "react";
|
||||
|
||||
import { usePollContext } from "./poll-context";
|
||||
|
||||
const ControlledScrollArea: React.FunctionComponent<{
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ className, children }) => {
|
||||
const { availableSpace, scrollPosition } = usePollContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("min-w-0 overflow-hidden", className)}
|
||||
style={{ width: availableSpace, maxWidth: availableSpace }}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
<m.div
|
||||
className="flex h-full"
|
||||
transition={{
|
||||
type: "spring",
|
||||
mass: 0.4,
|
||||
}}
|
||||
initial={{ x: 0 }}
|
||||
animate={{ x: scrollPosition * -1 }}
|
||||
>
|
||||
{children}
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlledScrollArea;
|
|
@ -1,45 +1,31 @@
|
|||
import clsx from "clsx";
|
||||
import { cn } from "@rallly/ui";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
import { useVotingForm } from "@/components/poll/voting-form";
|
||||
|
||||
import { usePoll } from "../../poll-context";
|
||||
import { normalizeVotes } from "../mutations";
|
||||
import { ParticipantForm, ParticipantFormSubmitted } from "../types";
|
||||
import UserAvatar, { YouAvatar } from "../user-avatar";
|
||||
import { VoteSelector } from "../vote-selector";
|
||||
import ControlledScrollArea from "./controlled-scroll-area";
|
||||
import { usePollContext } from "./poll-context";
|
||||
|
||||
export interface ParticipantRowFormProps {
|
||||
name?: string;
|
||||
defaultValues?: Partial<ParticipantForm>;
|
||||
onSubmit: (data: ParticipantFormSubmitted) => Promise<void>;
|
||||
className?: string;
|
||||
isYou?: boolean;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const ParticipantRowForm: React.ForwardRefRenderFunction<
|
||||
HTMLFormElement,
|
||||
ParticipantRowFormProps
|
||||
> = ({ defaultValues, onSubmit, name, isYou, className, onCancel }, ref) => {
|
||||
const ParticipantRowForm = ({
|
||||
name,
|
||||
isYou,
|
||||
className,
|
||||
onCancel,
|
||||
}: ParticipantRowFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
columnWidth,
|
||||
scrollPosition,
|
||||
sidebarWidth,
|
||||
numberOfColumns,
|
||||
goToNextPage,
|
||||
} = usePollContext();
|
||||
|
||||
const { optionIds } = usePoll();
|
||||
const { handleSubmit, control } = useForm({
|
||||
defaultValues: {
|
||||
votes: [],
|
||||
...defaultValues,
|
||||
},
|
||||
});
|
||||
const form = useVotingForm();
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("keydown", (e) => {
|
||||
|
@ -49,79 +35,38 @@ const ParticipantRowForm: React.ForwardRefRenderFunction<
|
|||
});
|
||||
}, [onCancel]);
|
||||
|
||||
const isColumnVisible = (index: number) => {
|
||||
return scrollPosition + numberOfColumns * columnWidth > columnWidth * index;
|
||||
};
|
||||
|
||||
const checkboxRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
return (
|
||||
<form
|
||||
id="participant-row-form"
|
||||
ref={ref}
|
||||
onSubmit={handleSubmit(async ({ votes }) => {
|
||||
await onSubmit({
|
||||
votes: normalizeVotes(optionIds, votes),
|
||||
});
|
||||
<tr className={cn(className)}>
|
||||
<td className="sticky left-0 z-10 bg-white pl-4 pr-4">
|
||||
<div className="flex items-center">
|
||||
{name ? (
|
||||
<UserAvatar name={name ?? t("you")} isYou={isYou} showName={true} />
|
||||
) : (
|
||||
<YouAvatar />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{optionIds.map((optionId, i) => {
|
||||
return (
|
||||
<td key={optionId} className="h-12 bg-white p-1">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={`votes.${i}`}
|
||||
render={({ field }) => (
|
||||
<VoteSelector
|
||||
className="h-full w-full"
|
||||
value={field.value.type}
|
||||
onChange={(vote) => {
|
||||
field.onChange({ optionId, type: vote });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
className={clsx("flex h-12 shrink-0", className)}
|
||||
>
|
||||
<div className="flex items-center px-5" style={{ width: sidebarWidth }}>
|
||||
{name ? (
|
||||
<UserAvatar name={name ?? t("you")} isYou={isYou} showName={true} />
|
||||
) : (
|
||||
<YouAvatar />
|
||||
)}
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="votes"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<ControlledScrollArea>
|
||||
{optionIds.map((optionId, index) => {
|
||||
const value = field.value[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={optionId}
|
||||
className="flex shrink-0 items-center justify-center p-1"
|
||||
style={{ width: columnWidth }}
|
||||
>
|
||||
<VoteSelector
|
||||
className="h-full w-full"
|
||||
value={value?.type}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.code === "Tab" &&
|
||||
index < optionIds.length - 1 &&
|
||||
!isColumnVisible(index + 1)
|
||||
) {
|
||||
e.preventDefault();
|
||||
goToNextPage();
|
||||
setTimeout(() => {
|
||||
checkboxRefs.current[index + 1]?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
onChange={(vote) => {
|
||||
const newValue = [...field.value];
|
||||
newValue[index] = { optionId, type: vote };
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
ref={(el) => {
|
||||
checkboxRefs.current[index] = el;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ControlledScrollArea>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.forwardRef(ParticipantRowForm);
|
||||
export default ParticipantRowForm;
|
||||
|
|
|
@ -9,85 +9,63 @@ import { usePoll } from "@/components/poll-context";
|
|||
import { useUser } from "@/components/user-provider";
|
||||
import { usePermissions } from "@/contexts/permissions";
|
||||
|
||||
import { ParticipantFormSubmitted } from "../types";
|
||||
import UserAvatar from "../user-avatar";
|
||||
import VoteIcon from "../vote-icon";
|
||||
import ControlledScrollArea from "./controlled-scroll-area";
|
||||
import ParticipantRowForm from "./participant-row-form";
|
||||
import { usePollContext } from "./poll-context";
|
||||
|
||||
export interface ParticipantRowProps {
|
||||
participant: Participant & { votes: Vote[] };
|
||||
className?: string;
|
||||
editMode?: boolean;
|
||||
onChangeEditMode?: (editMode: boolean) => void;
|
||||
onSubmit?: (data: ParticipantFormSubmitted) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ParticipantRowView: React.FunctionComponent<{
|
||||
name: string;
|
||||
action?: React.ReactNode;
|
||||
votes: Array<VoteType | undefined>;
|
||||
columnWidth: number;
|
||||
className?: string;
|
||||
sidebarWidth: number;
|
||||
isYou?: boolean;
|
||||
participantId: string;
|
||||
}> = ({
|
||||
name,
|
||||
action,
|
||||
votes,
|
||||
className,
|
||||
sidebarWidth,
|
||||
columnWidth,
|
||||
isYou,
|
||||
participantId,
|
||||
}) => {
|
||||
}> = ({ name, action, votes, className, isYou, participantId }) => {
|
||||
return (
|
||||
<div
|
||||
<tr
|
||||
data-testid="participant-row"
|
||||
data-participantid={participantId}
|
||||
className={clsx("flex h-12 items-center", className)}
|
||||
className={clsx(className)}
|
||||
>
|
||||
<div
|
||||
className="flex h-full shrink-0 items-center justify-between gap-2 px-3 sm:pl-5"
|
||||
style={{ width: sidebarWidth }}
|
||||
<td
|
||||
style={{ minWidth: 240, maxWidth: 240 }}
|
||||
className="sticky left-0 z-10 bg-white px-4"
|
||||
>
|
||||
<UserAvatar name={name} showName={true} isYou={isYou} />
|
||||
{action}
|
||||
</div>
|
||||
<ControlledScrollArea className="h-full">
|
||||
{votes.map((vote, i) => {
|
||||
return (
|
||||
<div className="flex max-w-full items-center justify-between gap-x-4 ">
|
||||
<UserAvatar name={name} showName={true} isYou={isYou} />
|
||||
{action}
|
||||
</div>
|
||||
</td>
|
||||
{votes.map((vote, i) => {
|
||||
return (
|
||||
<td key={i} className={clsx("h-12 p-1")}>
|
||||
<div
|
||||
key={i}
|
||||
className={clsx("relative flex h-full shrink-0 p-1")}
|
||||
style={{ width: columnWidth }}
|
||||
className={clsx(
|
||||
"flex h-full w-full items-center justify-center rounded border bg-gray-50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex h-full w-full items-center justify-center rounded border bg-gray-50",
|
||||
)}
|
||||
>
|
||||
<VoteIcon type={vote} />
|
||||
</div>
|
||||
<VoteIcon type={vote} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ControlledScrollArea>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const ParticipantRow: React.FunctionComponent<ParticipantRowProps> = ({
|
||||
participant,
|
||||
editMode,
|
||||
onSubmit,
|
||||
className,
|
||||
onChangeEditMode,
|
||||
}) => {
|
||||
const { columnWidth, sidebarWidth } = usePollContext();
|
||||
|
||||
const { user, ownsObject } = useUser();
|
||||
const { getVote, optionIds } = usePoll();
|
||||
|
||||
|
@ -100,47 +78,33 @@ const ParticipantRow: React.FunctionComponent<ParticipantRowProps> = ({
|
|||
return (
|
||||
<ParticipantRowForm
|
||||
name={participant.name}
|
||||
defaultValues={{
|
||||
votes: optionIds.map((optionId) => {
|
||||
const type = getVote(participant.id, optionId);
|
||||
return type ? { optionId, type } : undefined;
|
||||
}),
|
||||
}}
|
||||
isYou={isYou}
|
||||
onSubmit={async ({ votes }) => {
|
||||
await onSubmit?.({ votes });
|
||||
onChangeEditMode?.(false);
|
||||
}}
|
||||
onCancel={() => onChangeEditMode?.(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ParticipantRowView
|
||||
sidebarWidth={sidebarWidth}
|
||||
columnWidth={columnWidth}
|
||||
className={className}
|
||||
name={participant.name}
|
||||
votes={optionIds.map((optionId) => {
|
||||
return getVote(participant.id, optionId);
|
||||
})}
|
||||
participantId={participant.id}
|
||||
action={
|
||||
canEdit ? (
|
||||
<ParticipantDropdown
|
||||
participant={participant}
|
||||
align="start"
|
||||
onEdit={() => onChangeEditMode?.(true)}
|
||||
>
|
||||
<Button size="sm" icon={MoreHorizontalIcon} />
|
||||
</ParticipantDropdown>
|
||||
) : null
|
||||
}
|
||||
isYou={isYou}
|
||||
/>
|
||||
</>
|
||||
<ParticipantRowView
|
||||
className={className}
|
||||
name={participant.name}
|
||||
votes={optionIds.map((optionId) => {
|
||||
return getVote(participant.id, optionId);
|
||||
})}
|
||||
participantId={participant.id}
|
||||
action={
|
||||
canEdit ? (
|
||||
<ParticipantDropdown
|
||||
participant={participant}
|
||||
align="start"
|
||||
onEdit={() => onChangeEditMode?.(true)}
|
||||
>
|
||||
<Button size="sm" icon={MoreHorizontalIcon} />
|
||||
</ParticipantDropdown>
|
||||
) : null
|
||||
}
|
||||
isYou={isYou}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
import noop from "lodash/noop";
|
||||
import React from "react";
|
||||
|
||||
export const PollContext = React.createContext<{
|
||||
activeOptionId: string | null;
|
||||
setActiveOptionId: (optionId: string | null) => void;
|
||||
scrollPosition: number;
|
||||
maxScrollPosition: number;
|
||||
setScrollPosition: (position: number) => void;
|
||||
columnWidth: number;
|
||||
sidebarWidth: number;
|
||||
numberOfColumns: number;
|
||||
availableSpace: number | string;
|
||||
goToNextPage: () => void;
|
||||
goToPreviousPage: () => void;
|
||||
}>({
|
||||
activeOptionId: null,
|
||||
setActiveOptionId: noop,
|
||||
scrollPosition: 0,
|
||||
maxScrollPosition: 100,
|
||||
setScrollPosition: noop,
|
||||
columnWidth: 100,
|
||||
sidebarWidth: 200,
|
||||
numberOfColumns: 0,
|
||||
availableSpace: "auto",
|
||||
goToNextPage: noop,
|
||||
goToPreviousPage: noop,
|
||||
});
|
||||
|
||||
export const usePollContext = () => React.useContext(PollContext);
|
|
@ -1,3 +1,4 @@
|
|||
import { cn } from "@rallly/ui";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
|
@ -5,8 +6,6 @@ import { DateIconInner } from "@/components/date-icon";
|
|||
import { useOptions } from "@/components/poll-context";
|
||||
|
||||
import { ConnectedScoreSummary } from "../score-summary";
|
||||
import ControlledScrollArea from "./controlled-scroll-area";
|
||||
import { usePollContext } from "./poll-context";
|
||||
|
||||
const TimeRange: React.FunctionComponent<{
|
||||
start: string;
|
||||
|
@ -16,43 +15,145 @@ const TimeRange: React.FunctionComponent<{
|
|||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"relative -mr-2 inline-block pr-2 text-right text-xs font-semibold after:absolute after:right-0 after:top-2 after:h-4 after:w-1 after:border-b after:border-r after:border-t after:border-gray-300 after:content-['']",
|
||||
"relative -mr-2 inline-block whitespace-nowrap pr-2 text-right text-xs font-normal after:absolute after:right-0 after:top-2 after:h-4 after:w-1 after:border-b after:border-r after:border-t after:border-gray-300 after:content-['']",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div>{start}</div>
|
||||
<div className="text-gray-500">{end}</div>
|
||||
<div className="font-medium tabular-nums">{start}</div>
|
||||
<div className="text-muted-foreground tabular-nums">{end}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TimelineRow = ({
|
||||
children,
|
||||
top,
|
||||
}: React.PropsWithChildren<{ top?: number }>) => {
|
||||
return (
|
||||
<tr>
|
||||
<th
|
||||
style={{ minWidth: 240, top }}
|
||||
className="sticky left-0 z-30 bg-white pl-4 pr-4"
|
||||
></th>
|
||||
{children}
|
||||
<th className="w-full" />
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const monthRowHeight = 48;
|
||||
const dayRowHeight = 64;
|
||||
|
||||
const scoreRowTop = monthRowHeight + dayRowHeight;
|
||||
|
||||
const Trail = ({ end }: { end?: boolean }) => {
|
||||
return end ? (
|
||||
<div aria-hidden="true" className="absolute top-6 left-0 z-10 h-full w-1/2">
|
||||
<div className="h-px bg-gray-200" />
|
||||
<div className="absolute right-0 top-0 h-5 w-px bg-gray-200" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn("absolute top-6 left-0 z-10 h-full w-full")}
|
||||
>
|
||||
<div className="h-px bg-gray-200" />
|
||||
<div className={cn("absolute right-1/2 top-0 h-2 w-px bg-gray-200")} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PollHeader: React.FunctionComponent = () => {
|
||||
const { options } = useOptions();
|
||||
const { setActiveOptionId, columnWidth } = usePollContext();
|
||||
return (
|
||||
<ControlledScrollArea>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<div
|
||||
key={option.optionId}
|
||||
className="flex shrink-0 flex-col items-center gap-y-3"
|
||||
style={{ width: columnWidth }}
|
||||
onMouseOver={() => setActiveOptionId(option.optionId)}
|
||||
onMouseOut={() => setActiveOptionId(null)}
|
||||
>
|
||||
<DateIconInner
|
||||
day={option.day}
|
||||
dow={option.dow}
|
||||
month={option.month}
|
||||
/>
|
||||
{option.type === "timeSlot" ? (
|
||||
<TimeRange start={option.startTime} end={option.endTime} />
|
||||
) : null}
|
||||
<ConnectedScoreSummary optionId={option.optionId} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ControlledScrollArea>
|
||||
<>
|
||||
<TimelineRow top={0}>
|
||||
{options.map((option, i) => {
|
||||
const firstOfMonth =
|
||||
i === 0 || options[i - 1]?.month !== option.month;
|
||||
const lastOfMonth = options[i + 1]?.month !== option.month;
|
||||
|
||||
return (
|
||||
<th
|
||||
key={option.optionId}
|
||||
style={{ height: monthRowHeight }}
|
||||
className={cn(
|
||||
"sticky top-0 space-y-3 bg-white",
|
||||
firstOfMonth ? "left-[240px] z-20" : "z-10",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
{firstOfMonth ? null : <Trail end={lastOfMonth} />}
|
||||
<div
|
||||
className={cn(
|
||||
"h-5 px-2 py-0.5 text-sm font-semibold",
|
||||
firstOfMonth ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{option.month}
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</TimelineRow>
|
||||
<TimelineRow top={monthRowHeight}>
|
||||
{options.map((option, i) => {
|
||||
const firstOfDay =
|
||||
i === 0 ||
|
||||
options[i - 1]?.day !== option.day ||
|
||||
options[i - 1]?.month !== option.month;
|
||||
const lastOfDay =
|
||||
options[i + 1]?.day !== option.day ||
|
||||
options[i + 1]?.month !== option.month;
|
||||
return (
|
||||
<th
|
||||
key={option.optionId}
|
||||
style={{
|
||||
minWidth: 80,
|
||||
width: 80,
|
||||
maxWidth: 90,
|
||||
height: dayRowHeight,
|
||||
// could enable this to make the date column sticky
|
||||
left: firstOfDay ? 240 : 0,
|
||||
top: monthRowHeight,
|
||||
}}
|
||||
className={cn(
|
||||
"sticky space-y-2 align-top",
|
||||
firstOfDay
|
||||
? "z-20 bg-gradient-to-r from-transparent to-white"
|
||||
: "z-10 bg-white",
|
||||
)}
|
||||
>
|
||||
{firstOfDay ? null : <Trail end={lastOfDay} />}
|
||||
<DateIconInner
|
||||
className={firstOfDay ? "opacity-100" : "opacity-0"}
|
||||
day={option.day}
|
||||
dow={option.dow}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</TimelineRow>
|
||||
<TimelineRow top={scoreRowTop}>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<th
|
||||
key={option.optionId}
|
||||
style={{ minWidth: 80, maxWidth: 90, top: scoreRowTop }}
|
||||
className="sticky z-20 space-y-2 bg-white py-2"
|
||||
>
|
||||
{option.type === "timeSlot" ? (
|
||||
<TimeRange start={option.startTime} end={option.endTime} />
|
||||
) : null}
|
||||
<div>
|
||||
<ConnectedScoreSummary optionId={option.optionId} />
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</TimelineRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -32,14 +32,7 @@ if (typeof window !== "undefined") {
|
|||
const MobilePoll: React.FunctionComponent = () => {
|
||||
const pollContext = usePoll();
|
||||
|
||||
const {
|
||||
poll,
|
||||
admin,
|
||||
getParticipantById,
|
||||
optionIds,
|
||||
getVote,
|
||||
userAlreadyVoted,
|
||||
} = pollContext;
|
||||
const { poll, admin, getParticipantById, optionIds, getVote } = pollContext;
|
||||
|
||||
const { options } = useOptions();
|
||||
const { participants } = useParticipants();
|
||||
|
@ -70,7 +63,7 @@ const MobilePoll: React.FunctionComponent = () => {
|
|||
const { canEditParticipant, canAddNewParticipant } = usePermissions();
|
||||
|
||||
const [isEditing, setIsEditing] = React.useState(
|
||||
canAddNewParticipant && !userAlreadyVoted,
|
||||
canAddNewParticipant && !participants.some((p) => canEditParticipant(p.id)),
|
||||
);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
|
|
|
@ -190,7 +190,7 @@ const PollOption: React.FunctionComponent<PollOptionProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={clsx("space-y-4 overflow-hidden p-3", {
|
||||
className={clsx("space-y-4 overflow-hidden px-4 py-3", {
|
||||
"bg-gray-500/5": editable && active,
|
||||
})}
|
||||
onTouchStart={() => setActive(editable)}
|
||||
|
|
|
@ -48,7 +48,7 @@ export const ScoreSummary: React.FunctionComponent<PopularityScoreProps> =
|
|||
<div
|
||||
data-testid="popularity-score"
|
||||
className={cn(
|
||||
"relative flex select-none items-center gap-1 rounded-full border py-0.5 px-2 text-xs tabular-nums",
|
||||
"relative inline-flex select-none items-center gap-1 rounded-full border py-0.5 px-2 text-xs font-normal tabular-nums",
|
||||
highlight
|
||||
? "border-green-500 text-green-500"
|
||||
: "border-transparent text-gray-600",
|
||||
|
|
|
@ -4,7 +4,7 @@ export interface ParticipantForm {
|
|||
votes: Array<
|
||||
| {
|
||||
optionId: string;
|
||||
type: VoteType;
|
||||
type?: VoteType;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
|
|
138
apps/web/src/components/poll/voting-form.tsx
Normal file
138
apps/web/src/components/poll/voting-form.tsx
Normal file
|
@ -0,0 +1,138 @@
|
|||
import React from "react";
|
||||
import { FormProvider, useForm, useFormContext } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNewParticipantModal } from "@/components/new-participant-modal";
|
||||
import { useParticipants } from "@/components/participants-provider";
|
||||
import {
|
||||
normalizeVotes,
|
||||
useUpdateParticipantMutation,
|
||||
} from "@/components/poll/mutations";
|
||||
import { usePermissions } from "@/contexts/permissions";
|
||||
import { usePoll } from "@/contexts/poll";
|
||||
import { useRole } from "@/contexts/role";
|
||||
|
||||
const formSchema = z.object({
|
||||
mode: z.enum(["new", "edit", "view"]),
|
||||
participantId: z.string().optional(),
|
||||
votes: z.array(
|
||||
z.object({
|
||||
optionId: z.string(),
|
||||
type: z.enum(["yes", "no", "ifNeedBe"]).optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type VotingFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const useVotingForm = () => {
|
||||
const { options } = usePoll();
|
||||
const { participants } = useParticipants();
|
||||
const form = useFormContext<VotingFormValues>();
|
||||
|
||||
return {
|
||||
...form,
|
||||
newParticipant: () => {
|
||||
form.reset({
|
||||
mode: "new",
|
||||
participantId: undefined,
|
||||
votes: options.map((option) => ({
|
||||
optionId: option.id,
|
||||
})),
|
||||
});
|
||||
},
|
||||
setEditingParticipantId: (newParticipantId: string) => {
|
||||
const participant = participants.find((p) => p.id === newParticipantId);
|
||||
if (participant) {
|
||||
form.reset({
|
||||
mode: "edit",
|
||||
participantId: newParticipantId,
|
||||
votes: options.map((option) => ({
|
||||
optionId: option.id,
|
||||
type: participant.votes.find((vote) => vote.optionId === option.id)
|
||||
?.type,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
console.error("Participant not found");
|
||||
}
|
||||
},
|
||||
cancel: () =>
|
||||
form.reset({
|
||||
mode: "view",
|
||||
participantId: undefined,
|
||||
votes: options.map((option) => ({
|
||||
optionId: option.id,
|
||||
})),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const VotingForm = ({ children }: React.PropsWithChildren) => {
|
||||
const { id: pollId, options } = usePoll();
|
||||
const showNewParticipantModal = useNewParticipantModal();
|
||||
const updateParticipant = useUpdateParticipantMutation();
|
||||
const { participants } = useParticipants();
|
||||
|
||||
const { canAddNewParticipant, canEditParticipant } = usePermissions();
|
||||
const userAlreadyVoted = participants.some((participant) =>
|
||||
canEditParticipant(participant.id),
|
||||
);
|
||||
|
||||
const role = useRole();
|
||||
|
||||
const form = useForm<VotingFormValues>({
|
||||
defaultValues: {
|
||||
mode:
|
||||
canAddNewParticipant && !userAlreadyVoted && role === "participant"
|
||||
? "new"
|
||||
: "view",
|
||||
votes: options.map((option) => ({
|
||||
optionId: option.id,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
id="voting-form"
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
const optionIds = options.map((option) => option.id);
|
||||
|
||||
if (data.participantId) {
|
||||
// update participant
|
||||
|
||||
await updateParticipant.mutateAsync({
|
||||
participantId: data.participantId,
|
||||
pollId,
|
||||
votes: normalizeVotes(optionIds, data.votes),
|
||||
});
|
||||
|
||||
form.reset({
|
||||
participantId: undefined,
|
||||
votes: options.map((option) => ({
|
||||
optionId: option.id,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
// new participant
|
||||
showNewParticipantModal({
|
||||
votes: normalizeVotes(optionIds, data.votes),
|
||||
onSubmit: async () => {
|
||||
form.reset({
|
||||
mode: "view",
|
||||
participantId: undefined,
|
||||
votes: options.map((option) => ({
|
||||
optionId: option.id,
|
||||
})),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
})}
|
||||
/>
|
||||
{children}
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue