Voting with usePlusVoting hook

This commit is contained in:
Kalle
2022-06-12 11:35:08 +03:00
parent 11eaa6c893
commit 9e9e9ed44e
7 changed files with 314 additions and 66 deletions

View File

@@ -131,7 +131,7 @@ function mapFindVisibleForUserRowsToResult(
id: row.suggestedId,
discordId: row.suggestedDiscordId,
discordName: row.suggestedDiscordName,
discordDiscriminator: row.suggestedDiscriminator,
discordDiscriminator: row.suggestedDiscordDiscriminator,
discordAvatar: row.suggestedDiscordAvatar,
bio: includeBio ? row.suggestedBio : null,
},

View File

@@ -5,3 +5,5 @@ export {
} from "./voting-time";
export type { MonthYear } from "./types";
export { usePlusVoting } from "./usePlusVoting";

View File

@@ -0,0 +1,141 @@
import type { UsersForVoting } from "~/db/models/plusVotes.server";
import * as React from "react";
import { upcomingVoting } from "./voting-time";
import type { PlusVotingResult, User } from "~/db/types";
import invariant from "tiny-invariant";
const LOCAL_STORAGE_KEY = "plusVoting";
interface PlusVote {
userId: User["id"];
score: PlusVotingResult["score"];
}
interface VotingLocalStorageData {
month: number;
year: number;
votes: PlusVote[];
usersForVoting: UsersForVoting;
}
export function usePlusVoting(usersForVotingFromServer: UsersForVoting) {
const [usersForVoting, setUsersForVoting] = React.useState<UsersForVoting>();
const [votes, setVotes] = React.useState<PlusVote[]>([]);
useLoadInitialStateFromLocalStorageEffect({
usersForVotingFromServer,
setUsersForVoting,
setVotes,
});
const vote = React.useCallback(
({ score, userId }: PlusVote) => {
setVotes((votes) => {
const newVotes = [...votes, { userId, score }];
votesToLocalStorage({ usersForVoting, votes: newVotes });
return newVotes;
});
},
[usersForVoting]
);
const undoLast = React.useCallback(() => {
setVotes((votes) => {
const newVotes = [...votes];
newVotes.pop();
votesToLocalStorage({ usersForVoting, votes: newVotes });
return newVotes;
});
}, [usersForVoting]);
const currentUser = usersForVoting?.[votes.length];
return {
vote,
undoLast,
currentUser,
previous: previousUser({ usersForVoting, votes }),
isReady: Boolean(usersForVoting),
};
}
function useLoadInitialStateFromLocalStorageEffect({
usersForVotingFromServer,
setUsersForVoting,
setVotes,
}: {
usersForVotingFromServer: UsersForVoting;
setUsersForVoting: React.Dispatch<
React.SetStateAction<UsersForVoting | undefined>
>;
setVotes: React.Dispatch<React.SetStateAction<PlusVote[]>>;
}) {
const { month, year } = upcomingVoting(new Date());
React.useEffect(() => {
const usersForVotingFromLocalStorage =
localStorage.getItem(LOCAL_STORAGE_KEY);
if (!usersForVotingFromLocalStorage) {
setUsersForVoting(usersForVotingFromServer);
return;
}
const parsedUsersForVoting = JSON.parse(
usersForVotingFromLocalStorage
) as VotingLocalStorageData;
if (
parsedUsersForVoting.month !== month ||
parsedUsersForVoting.year !== year
) {
setUsersForVoting(usersForVotingFromServer);
return;
}
setUsersForVoting(parsedUsersForVoting.usersForVoting);
setVotes(parsedUsersForVoting.votes);
}, [month, year, usersForVotingFromServer, setUsersForVoting, setVotes]);
}
function previousUser({
usersForVoting,
votes,
}: {
usersForVoting?: UsersForVoting;
votes: PlusVote[];
}) {
if (!usersForVoting) return;
const previousUser = usersForVoting?.[votes.length - 1];
if (!previousUser) return;
const previousScore = votes[votes.length - 1]?.score;
invariant(previousScore);
return {
user: previousUser,
score: previousScore,
};
}
function votesToLocalStorage({
usersForVoting,
votes,
}: {
usersForVoting?: UsersForVoting;
votes: PlusVote[];
}) {
const { month, year } = upcomingVoting(new Date());
invariant(usersForVoting);
const toLocalStorage: VotingLocalStorageData = {
month,
year,
votes,
usersForVoting,
};
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(toLocalStorage));
}

View File

@@ -1,6 +1,12 @@
import type { LinksFunction } from "@remix-run/node";
import { Outlet } from "@remix-run/react";
import { Main } from "~/components/Main";
import { SubNav, SubNavLink } from "~/components/SubNav";
import styles from "~/styles/plus.css";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export default function PlusPageLayout() {
return (

View File

@@ -1,6 +1,5 @@
import type {
ActionFunction,
LinksFunction,
LoaderFunction,
MetaFunction,
} from "@remix-run/node";
@@ -18,14 +17,13 @@ import { TrashIcon } from "~/components/icons/Trash";
import { upcomingVoting } from "~/modules/plus-server";
import { db } from "~/db";
import type * as plusSuggestions from "~/db/models/plusSuggestions.server";
import type { PlusSuggestion } from "~/db/types";
import type { PlusSuggestion, User } from "~/db/types";
import { requireUser, useUser } from "~/modules/auth";
import {
canAddCommentToSuggestionFE,
canSuggestNewUserFE,
canDeleteComment,
} from "~/permissions";
import styles from "~/styles/plus.css";
import { makeTitle, parseRequestFormData, validate } from "~/utils/remix";
import { discordFullName } from "~/utils/strings";
import { actualNumber } from "~/utils/zod";
@@ -33,10 +31,6 @@ import { userPage } from "~/utils/urls";
import { RelativeTime } from "~/components/RelativeTime";
import { databaseTimestampToDate } from "~/utils/dates";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const meta: MetaFunction = () => {
return {
title: makeTitle("Plus Server suggestions"),
@@ -290,53 +284,84 @@ function SuggestedUser({
</LinkButton>
) : null}
</div>
<details>
<summary
className="plus__view-comments-action"
data-cy="comments-summary"
>
Comments ({suggested.suggestions.length})
</summary>
<div className="stack sm mt-2">
{suggested.suggestions.map((suggestion) => {
invariant(data.suggestions);
return (
<fieldset key={suggestion.id} className="plus__comment">
<legend>{discordFullName(suggestion.author)}</legend>
{suggestion.text}
<div className="stack vertical xs items-center">
<span className="plus__comment-time">
<RelativeTime
timestamp={databaseTimestampToDate(
suggestion.createdAt
).getTime()}
>
{suggestion.createdAtRelative}
</RelativeTime>
</span>
{canDeleteComment({
author: suggestion.author,
user,
suggestionId: suggestion.id,
suggestions: data.suggestions,
}) ? (
<CommentDeleteButton
suggestionId={suggestion.id}
tier={tier}
suggestedDiscordName={suggested.suggestedUser.discordName}
isFirstSuggestion={suggested.suggestions.length === 1}
/>
) : null}
</div>
</fieldset>
);
})}
</div>
</details>
<PlusSuggestionComments
suggestions={suggested.suggestions}
deleteButtonArgs={{
suggested,
user,
tier,
suggestions: data.suggestions,
}}
/>
</div>
);
}
export function PlusSuggestionComments({
suggestions,
deleteButtonArgs,
defaultOpen,
}: {
suggestions: plusSuggestions.FindVisibleForUserSuggestedUserInfo["suggestions"];
deleteButtonArgs?: {
user?: Pick<User, "id">;
suggestions: plusSuggestions.FindVisibleForUser;
tier: string;
suggested: plusSuggestions.FindVisibleForUserSuggestedUserInfo;
};
defaultOpen?: true;
}) {
return (
<details open={defaultOpen} className="w-full">
<summary
className="plus__view-comments-action"
data-cy="comments-summary"
>
Comments ({suggestions.length})
</summary>
<div className="stack sm mt-2">
{suggestions.map((suggestion) => {
return (
<fieldset key={suggestion.id} className="plus__comment">
<legend>{discordFullName(suggestion.author)}</legend>
{suggestion.text}
<div className="stack vertical xs items-center">
<span className="plus__comment-time">
<RelativeTime
timestamp={databaseTimestampToDate(
suggestion.createdAt
).getTime()}
>
{suggestion.createdAtRelative}
</RelativeTime>
</span>
{deleteButtonArgs &&
canDeleteComment({
author: suggestion.author,
user: deleteButtonArgs.user,
suggestionId: suggestion.id,
suggestions: deleteButtonArgs.suggestions,
}) ? (
<CommentDeleteButton
suggestionId={suggestion.id}
tier={deleteButtonArgs.tier}
suggestedDiscordName={
deleteButtonArgs.suggested.suggestedUser.discordName
}
isFirstSuggestion={
deleteButtonArgs.suggested.suggestions.length === 1
}
/>
) : null}
</div>
</fieldset>
);
})}
</div>
</details>
);
}
function CommentDeleteButton({
suggestionId,
tier,

View File

@@ -2,12 +2,21 @@ import type { LoaderFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { formatDistance } from "date-fns";
import { Avatar } from "~/components/Avatar";
import { Button } from "~/components/Button";
import { RelativeTime } from "~/components/RelativeTime";
import { db } from "~/db";
import type { UsersForVoting } from "~/db/models/plusVotes.server";
import { getUser } from "~/modules/auth";
import { monthsVotingRange, upcomingVoting } from "~/modules/plus-server";
import {
monthsVotingRange,
upcomingVoting,
usePlusVoting,
} from "~/modules/plus-server";
import { isVotingActive } from "~/permissions";
import { discordFullName } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import { PlusSuggestionComments } from "../suggestions";
type PlusVotingLoaderData =
// voting is not active OR user is not eligible to vote
@@ -20,7 +29,7 @@ type PlusVotingLoaderData =
// user can vote
| {
type: "voting";
usersForVoting?: UsersForVoting;
usersForVoting: UsersForVoting;
}
// user already voted
| { type: "votingInfo"; votingInfo: { placeholder: true } };
@@ -59,18 +68,79 @@ export const loader: LoaderFunction = async ({ request }) => {
export default function PlusVotingPage() {
const data = useLoaderData<PlusVotingLoaderData>();
if (data.type === "timeInfo") {
return (
<div className="text-sm text-center">
{data.timing === "starts"
? "Next voting starts"
: "Voting is currently happening. Ends"}{" "}
<RelativeTime timestamp={data.timestamp}>
{data.relativeTime}
</RelativeTime>
</div>
);
switch (data.type) {
case "timeInfo": {
return <VotingTimingInfo {...data} />;
}
case "voting": {
return <Voting {...data} />;
}
case "votingInfo": {
return null;
}
default: {
assertUnreachable(data);
}
}
return null;
}
function VotingTimingInfo(
data: Extract<PlusVotingLoaderData, { type: "timeInfo" }>
) {
return (
<div className="text-sm text-center">
{data.timing === "starts"
? "Next voting starts"
: "Voting is currently happening. Ends"}{" "}
<RelativeTime timestamp={data.timestamp}>
{data.relativeTime}
</RelativeTime>
</div>
);
}
function Voting(data: Extract<PlusVotingLoaderData, { type: "voting" }>) {
const { currentUser, previous, vote, undoLast, isReady } = usePlusVoting(
data.usersForVoting
);
if (!isReady) return null;
return (
<div>
{currentUser ? (
<div className="stack md items-center">
<Avatar
discordAvatar={currentUser.user.discordAvatar}
discordId={currentUser.user.discordId}
size="lg"
/>
<h2>{discordFullName(currentUser.user)}</h2>
<div className="stack vertical md">
<Button
variant="outlined"
onClick={() => vote({ score: -1, userId: currentUser.user.id })}
>
-1
</Button>
<Button
variant="outlined"
onClick={() => vote({ score: 1, userId: currentUser.user.id })}
>
+1
</Button>
</div>
{currentUser.suggestions ? (
<PlusSuggestionComments
suggestions={currentUser.suggestions}
defaultOpen
/>
) : null}
{currentUser.user.bio ? (
<article>{currentUser.user.bio}</article>
) : null}
</div>
) : null}
</div>
);
}

View File

@@ -205,6 +205,10 @@
display: none;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}