Improvements to table component (#809)

This commit is contained in:
Luke Vella 2023-08-14 12:00:35 +01:00 committed by GitHub
parent b727c3f5e5
commit 83ad12b884
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 711 additions and 618 deletions

View file

@ -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"

View file

@ -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>
);

View file

@ -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">

View file

@ -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 (

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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}
/>
);
};

View file

@ -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);

View file

@ -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>
</>
);
};

View file

@ -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(() => {

View file

@ -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)}

View file

@ -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",

View file

@ -4,7 +4,7 @@ export interface ParticipantForm {
votes: Array<
| {
optionId: string;
type: VoteType;
type?: VoteType;
}
| undefined
>;

View 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>
);
};