import type { ActionFunction, LoaderFunction, V2_MetaFunction, } from "@remix-run/node"; import { json } from "@remix-run/node"; import type { ShouldRevalidateFunction } from "@remix-run/react"; import { Link, Outlet, useLoaderData, useSearchParams } from "@remix-run/react"; import clsx from "clsx"; import invariant from "tiny-invariant"; import { z } from "zod"; import { Avatar } from "~/components/Avatar"; import { Button, LinkButton } from "~/components/Button"; import { Catcher } from "~/components/Catcher"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { TrashIcon } from "~/components/icons/Trash"; import { nextNonCompletedVoting } from "~/modules/plus-server"; import { db } from "~/db"; import type * as plusSuggestions from "~/db/models/plusSuggestions/queries.server"; import type { PlusSuggestion, User } from "~/db/types"; import { requireUser, useUser } from "~/modules/auth"; import { canAddCommentToSuggestionFE, canSuggestNewUserFE, canDeleteComment, canDeleteSuggestionOfThemselves, isFirstSuggestion, } from "~/permissions"; import { parseRequestFormData, validate } from "~/utils/remix"; import { makeTitle } from "~/utils/strings"; import { discordFullName } from "~/utils/strings"; import { actualNumber } from "~/utils/zod"; import { userPage } from "~/utils/urls"; import { RelativeTime } from "~/components/RelativeTime"; import { databaseTimestampToDate } from "~/utils/dates"; import { PLUS_TIERS } from "~/constants"; import { assertUnreachable } from "~/utils/types"; import { getUserId } from "~/modules/auth/user.server"; export const meta: V2_MetaFunction = () => { return [ { title: makeTitle("Plus Server suggestions") }, { name: "description", content: "This month's suggestions for +1, +2 and +3.", }, ]; }; const suggestionActionSchema = z.union([ z.object({ _action: z.literal("DELETE_COMMENT"), suggestionId: z.preprocess(actualNumber, z.number()), }), z.object({ _action: z.literal("DELETE_SUGGESTION_OF_THEMSELVES"), tier: z.preprocess( actualNumber, z .number() .min(Math.min(...PLUS_TIERS)) .max(Math.max(...PLUS_TIERS)) ), }), ]); export const action: ActionFunction = async ({ request }) => { const data = await parseRequestFormData({ request, schema: suggestionActionSchema, }); const user = await requireUser(request); switch (data._action) { case "DELETE_COMMENT": { const suggestions = db.plusSuggestions.findVisibleForUser({ ...nextNonCompletedVoting(new Date()), plusTier: user.plusTier, }); const flattenedSuggestedUserInfo = Object.entries(suggestions ?? {}) .flatMap(([tier, suggestions]) => suggestions.map(({ suggestedUser, suggestions }) => ({ tier: Number(tier), suggestedUser, suggestions, })) ) .find(({ suggestions }) => suggestions.some((s) => s.id === data.suggestionId) ); validate(suggestions); validate(flattenedSuggestedUserInfo); validate( canDeleteComment({ user, author: flattenedSuggestedUserInfo.suggestions.find( (s) => s.id === data.suggestionId )!.author, suggestionId: data.suggestionId, suggestions, }) ); const suggestionHasComments = flattenedSuggestedUserInfo.suggestions.length > 1; if ( suggestionHasComments && isFirstSuggestion({ suggestionId: data.suggestionId, suggestions }) ) { // admin only action db.plusSuggestions.deleteSuggestionWithComments({ ...nextNonCompletedVoting(new Date()), tier: flattenedSuggestedUserInfo.tier, suggestedId: flattenedSuggestedUserInfo.suggestedUser.id, }); } else { db.plusSuggestions.del(data.suggestionId); } break; } case "DELETE_SUGGESTION_OF_THEMSELVES": { validate(canDeleteSuggestionOfThemselves()); db.plusSuggestions.deleteAll({ suggestedId: user.id, tier: data.tier }); break; } default: { assertUnreachable(data); } } return null; }; export interface PlusSuggestionsLoaderData { suggestions: plusSuggestions.FindVisibleForUser; suggestedForTiers: number[]; } export const shouldRevalidate: ShouldRevalidateFunction = ({ formMethod }) => { // only reload if form submission not when user changes tabs return Boolean(formMethod && formMethod !== "get"); }; export const loader: LoaderFunction = async ({ request }) => { const user = await getUserId(request); return json({ suggestions: db.plusSuggestions.findAll({ ...nextNonCompletedVoting(new Date()), }), suggestedForTiers: user ? db.plusSuggestions.tiersSuggestedFor({ ...nextNonCompletedVoting(new Date()), userId: user.id, }) : [], }); }; export default function PlusSuggestionsPage() { const data = useLoaderData(); const [searchParams, setSearchParams] = useSearchParams(); const user = useUser(); const tierVisible = searchParamsToLegalTier(searchParams, data.suggestions); const handleTierChange = (tier: string) => { setSearchParams({ tier }); }; const visibleSuggestions = tierVisible && data.suggestions[tierVisible] ? data.suggestions[tierVisible] : []; invariant(visibleSuggestions); return ( <>
{Object.entries(data.suggestions) .sort((a, b) => Number(a[0]) - Number(b[0])) .map(([tier, suggestions]) => { const id = String(tier); return (
handleTierChange(tier)} data-cy={`plus${tier}-radio`} />
); })}
{canSuggestNewUserFE({ user, suggestions: data.suggestions }) ? ( // TODO: resetScroll={false} https://twitter.com/ryanflorence/status/1527775882797907969 Suggest ) : null}
{visibleSuggestions.map((u) => { invariant(tierVisible); return ( ); })} {visibleSuggestions.length === 0 ? (
No suggestions yet
) : null}
); } function searchParamsToLegalTier( searchParams: URLSearchParams, suggestions?: plusSuggestions.FindVisibleForUser ) { const tierFromSearchParams = searchParams.get("tier"); if ( !tierFromSearchParams || !suggestions || !suggestions[tierFromSearchParams] ) { return tierVisibleInitialState(suggestions); } return tierFromSearchParams; } function tierVisibleInitialState( suggestions?: plusSuggestions.FindVisibleForUser ) { if (!suggestions || Object.keys(suggestions).length === 0) return; return String(Math.min(...Object.keys(suggestions).map(Number))); } function SuggestedForInfo() { const data = useLoaderData(); if (data.suggestedForTiers.length === 0) return null; return (
{canDeleteSuggestionOfThemselves() ? (
{data.suggestedForTiers.map((tier) => ( ))}
) : null}
); } function SuggestedUser({ suggested, tier, }: { suggested: plusSuggestions.FindVisibleForUserSuggestedUserInfo; tier: string; }) { const data = useLoaderData(); const user = useUser(); invariant(data.suggestions); return (

{suggested.suggestedUser.discordName}

{canAddCommentToSuggestionFE({ user, suggestions: data.suggestions, suggested: { id: suggested.suggestedUser.id }, targetPlusTier: Number(tier), }) ? ( // TODO: resetScroll={false} https://twitter.com/ryanflorence/status/1527775882797907969 Comment ) : null}
); } export function PlusSuggestionComments({ suggestions, deleteButtonArgs, defaultOpen, }: { suggestions: plusSuggestions.FindVisibleForUserSuggestedUserInfo["suggestions"]; deleteButtonArgs?: { user?: Pick; suggestions: plusSuggestions.FindVisibleForUser; tier: string; suggested: plusSuggestions.FindVisibleForUserSuggestedUserInfo; }; defaultOpen?: true; }) { return (
Comments ({suggestions.length})
{suggestions.map((suggestion) => { return (
{discordFullName(suggestion.author)} {suggestion.text}
{suggestion.createdAtRelative} {deleteButtonArgs && canDeleteComment({ author: suggestion.author, user: deleteButtonArgs.user, suggestionId: suggestion.id, suggestions: deleteButtonArgs.suggestions, }) ? ( ) : null}
); })}
); } function CommentDeleteButton({ suggestionId, tier, suggestedDiscordName, isFirstSuggestion = false, }: { suggestionId: PlusSuggestion["id"]; tier: string; suggestedDiscordName: string; isFirstSuggestion?: boolean; }) { return (