Prefetch user (#429)

This commit is contained in:
Luke Vella 2023-01-23 14:19:17 +00:00 committed by GitHub
parent 37f777cace
commit 249376c43e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 220 additions and 181 deletions

View file

@ -18,9 +18,9 @@ import {
UserDetailsData,
UserDetailsForm,
} from "./forms";
import { SessionProps, useSession, withSession } from "./session";
import StandardLayout from "./standard-layout";
import Steps from "./steps";
import { useUser } from "./user-provider";
type StepName = "eventDetails" | "options" | "userDetails";
@ -37,7 +37,7 @@ const required = <T,>(v: T | undefined): T => {
const initialNewEventData: NewEventData = { currentStep: 0 };
const sessionStorageKey = "newEventFormData";
export interface CreatePollPageProps extends SessionProps {
export interface CreatePollPageProps {
title?: string;
location?: string;
description?: string;
@ -54,7 +54,7 @@ const Page: NextPage<CreatePollPageProps> = ({
const router = useRouter();
const session = useSession();
const session = useUser();
const [persistedFormData, setPersistedFormData] =
useSessionStorage<NewEventData>(sessionStorageKey, {
@ -228,4 +228,4 @@ const Page: NextPage<CreatePollPageProps> = ({
);
};
export default withSession(Page);
export default Page;

View file

@ -17,7 +17,7 @@ import NameInput from "../name-input";
import TruncatedLinkify from "../poll/truncated-linkify";
import UserAvatar from "../poll/user-avatar";
import { usePoll } from "../poll-context";
import { isUnclaimed, useSession } from "../session";
import { isUnclaimed, useUser } from "../user-provider";
interface CommentForm {
authorName: string;
@ -68,7 +68,7 @@ const Discussion: React.VoidFunctionComponent = () => {
},
});
const session = useSession();
const session = useUser();
const { register, reset, control, handleSubmit, formState } =
useForm<CommentForm>({

View file

@ -15,8 +15,8 @@ import { GetPollApiResponse } from "@/utils/trpc/types";
import { useDayjs } from "../utils/dayjs";
import ErrorPage from "./error-page";
import { useParticipants } from "./participants-provider";
import { useSession } from "./session";
import { useRequiredContext } from "./use-required-context";
import { useUser } from "./user-provider";
type PollContextValue = {
userAlreadyVoted: boolean;
@ -61,7 +61,7 @@ export const PollContextProvider: React.VoidFunctionComponent<{
const { t } = useTranslation("app");
const { participants } = useParticipants();
const [isDeleted, setDeleted] = React.useState(false);
const { user } = useSession();
const { user } = useUser();
const [targetTimeZone, setTargetTimeZone] =
React.useState(getBrowserTimeZone);

View file

@ -28,8 +28,8 @@ import { useTouchBeacon } from "./poll/use-touch-beacon";
import { UserAvatarProvider } from "./poll/user-avatar";
import VoteIcon from "./poll/vote-icon";
import { usePoll } from "./poll-context";
import { useSession } from "./session";
import Sharing from "./sharing";
import { useUser } from "./user-provider";
const PollPage: NextPage = () => {
const { poll, urlId, admin } = usePoll();
@ -40,7 +40,7 @@ const PollPage: NextPage = () => {
const { t } = useTranslation("app");
const session = useSession();
const session = useUser();
const queryClient = trpc.useContext();
const plausible = usePlausible();

View file

@ -6,7 +6,7 @@ import CompactButton from "@/components/compact-button";
import Pencil from "@/components/icons/pencil-alt.svg";
import Trash from "@/components/icons/trash.svg";
import { usePoll } from "@/components/poll-context";
import { useSession } from "@/components/session";
import { useUser } from "@/components/user-provider";
import { ParticipantFormSubmitted } from "../types";
import { useDeleteParticipantModal } from "../use-delete-participant-modal";
@ -108,7 +108,7 @@ const ParticipantRow: React.VoidFunctionComponent<ParticipantRowProps> = ({
const confirmDeleteParticipant = useDeleteParticipantModal();
const session = useSession();
const session = useUser();
const { poll, getVote, options } = usePoll();
const isYou = session.user && session.ownsObject(participant) ? true : false;

View file

@ -18,8 +18,8 @@ import { Button } from "../button";
import { styleMenuItem } from "../menu-styles";
import NameInput from "../name-input";
import { useParticipants } from "../participants-provider";
import { isUnclaimed, useSession } from "../session";
import TimeZonePicker from "../time-zone-picker";
import { isUnclaimed, useUser } from "../user-provider";
import GroupedOptions from "./mobile-poll/grouped-options";
import {
normalizeVotes,
@ -50,7 +50,7 @@ const MobilePoll: React.VoidFunctionComponent = () => {
const { participants } = useParticipants();
const { timeZone } = poll;
const session = useSession();
const session = useUser();
const form = useForm<ParticipantForm>({
defaultValues: {

View file

@ -2,7 +2,7 @@ import { usePlausible } from "next-plausible";
import { trpc } from "../../utils/trpc";
import { usePoll } from "../poll-context";
import { useSession } from "../session";
import { useUser } from "../user-provider";
import { ParticipantForm } from "./types";
export const normalizeVotes = (
@ -17,7 +17,7 @@ export const normalizeVotes = (
export const useAddParticipantMutation = () => {
const queryClient = trpc.useContext();
const session = useSession();
const session = useUser();
const plausible = usePlausible();
return trpc.useMutation(["polls.participants.add"], {

View file

@ -12,10 +12,10 @@ import { trpc } from "../utils/trpc";
import { EmptyState } from "./empty-state";
import LoginForm from "./login-form";
import { UserDetails } from "./profile/user-details";
import { useSession } from "./session";
import { useUser } from "./user-provider";
export const Profile: React.VoidFunctionComponent = () => {
const { user } = useSession();
const { user } = useUser();
const { dayjs } = useDayjs();
const { t } = useTranslation("app");

View file

@ -6,8 +6,8 @@ import { useForm } from "react-hook-form";
import { requiredString, validEmail } from "../../utils/form-validation";
import { trpc } from "../../utils/trpc";
import { Button } from "../button";
import { useSession } from "../session";
import { TextInput } from "../text-input";
import { useUser } from "../user-provider";
export interface UserDetailsProps {
userId: string;
@ -30,7 +30,7 @@ export const UserDetails: React.VoidFunctionComponent<UserDetailsProps> = ({
defaultValues: { name, email },
});
const { refresh } = useSession();
const { refresh } = useUser();
const changeName = trpc.useMutation("user.changeName", {
onSuccess: () => {

View file

@ -1,117 +0,0 @@
import { IronSessionData } from "iron-session";
import React from "react";
import toast from "react-hot-toast";
import { trpc } from "@/utils/trpc";
import FullPageLoader from "./full-page-loader";
import { useRequiredContext } from "./use-required-context";
export type UserSessionData = NonNullable<IronSessionData["user"]>;
export type SessionProps = {
user: UserSessionData;
};
type ParticipantOrComment = {
userId: string | null;
};
export type UserSessionDataExtended =
| {
isGuest: true;
id: string;
shortName: string;
}
| {
isGuest: false;
id: string;
name: string;
shortName: string;
email: string;
};
type SessionContextValue = {
logout: () => void;
user: UserSessionDataExtended;
refresh: () => void;
ownsObject: (obj: ParticipantOrComment) => boolean;
isLoading: boolean;
};
export const SessionContext =
React.createContext<SessionContextValue | null>(null);
SessionContext.displayName = "SessionContext";
export const SessionProvider: React.VoidFunctionComponent<{
children?: React.ReactNode;
}> = ({ children }) => {
const queryClient = trpc.useContext();
const { data: user, refetch, isLoading } = trpc.useQuery(["session.get"]);
const logout = trpc.useMutation(["session.destroy"], {
onSuccess: () => {
queryClient.invalidateQueries(["session.get"]);
},
});
if (!user) {
return <FullPageLoader>Loading user</FullPageLoader>;
}
const sessionData: SessionContextValue = {
user: {
...user,
shortName:
// try to get the first name in the event
// that the user entered a full name
user.isGuest
? user.id.substring(0, 10)
: user.name.length > 12 && user.name.indexOf(" ") !== -1
? user.name.substring(0, user.name.indexOf(" "))
: user.name,
},
refresh: () => {
refetch();
},
isLoading,
logout: () => {
toast.promise(logout.mutateAsync(), {
loading: "Logging out…",
success: "Logged out",
error: "Failed to log out",
});
},
ownsObject: (obj) => {
return obj.userId === user.id;
},
};
return (
<SessionContext.Provider value={sessionData}>
{children}
</SessionContext.Provider>
);
};
export const useSession = () => {
return useRequiredContext(SessionContext);
};
export const withSession = <P extends SessionProps>(
component: React.ComponentType<P>,
) => {
const ComposedComponent: React.VoidFunctionComponent<P> = (props: P) => {
const Component = component;
return (
<SessionProvider>
<Component {...props} />
</SessionProvider>
);
};
ComposedComponent.displayName = component.displayName;
return ComposedComponent;
};
export const isUnclaimed = (obj: ParticipantOrComment) => !obj.userId;

View file

@ -27,7 +27,7 @@ import { useModal } from "./modal";
import ModalProvider, { useModalContext } from "./modal/modal-provider";
import Popover from "./popover";
import Preferences from "./preferences";
import { useSession } from "./session";
import { useUser } from "./user-provider";
const HomeLink = () => {
return (
@ -40,7 +40,7 @@ const HomeLink = () => {
const MobileNavigation: React.VoidFunctionComponent<{
openLoginModal: () => void;
}> = ({ openLoginModal }) => {
const { user } = useSession();
const { user } = useUser();
const { t } = useTranslation(["common", "app"]);
return (
<div
@ -152,7 +152,7 @@ const AppMenu: React.VoidFunctionComponent<{ className?: string }> = ({
const UserDropdown: React.VoidFunctionComponent<
DropdownProps & { openLoginModal: () => void }
> = ({ children, openLoginModal, ...forwardProps }) => {
const { logout, user } = useSession();
const { logout, user } = useUser();
const { t } = useTranslation(["common", "app"]);
const modalContext = useModalContext();
if (!user) {
@ -243,7 +243,7 @@ const UserDropdown: React.VoidFunctionComponent<
const StandardLayout: React.VoidFunctionComponent<{
children?: React.ReactNode;
}> = ({ children, ...rest }) => {
const { user } = useSession();
const { user } = useUser();
const { t } = useTranslation(["common", "app"]);
const [loginModal, openLoginModal] = useModal({
footer: null,

View file

@ -0,0 +1,109 @@
import { useTranslation } from "next-i18next";
import React from "react";
import { UserSession } from "@/utils/auth";
import { trpcNext } from "../utils/trpc";
import { useRequiredContext } from "./use-required-context";
export const UserContext =
React.createContext<{
user: UserSession & { shortName: string };
refresh: () => void;
logout: () => Promise<void>;
ownsObject: (obj: { userId: string | null }) => boolean;
} | null>(null);
export const useUser = () => {
return useRequiredContext(UserContext, "UserContext");
};
export const useAuthenticatedUser = () => {
const { user, ...rest } = useRequiredContext(UserContext, "UserContext");
if (user.isGuest) {
throw new Error("Forget to prefetch user identity");
}
return { user, ...rest };
};
export const IfAuthenticated = (props: { children?: React.ReactNode }) => {
const { user } = useUser();
if (user.isGuest) {
return null;
}
return <>{props.children}</>;
};
export const IfGuest = (props: { children?: React.ReactNode }) => {
const { user } = useUser();
if (!user.isGuest) {
return null;
}
return <>{props.children}</>;
};
export const UserProvider = (props: { children?: React.ReactNode }) => {
const { t } = useTranslation("app");
const { data: user, refetch } = trpcNext.whoami.get.useQuery();
const logout = trpcNext.whoami.destroy.useMutation();
const shortName = user
? user.isGuest === false
? user.name
: `${t("guest")}-${user.id.substring(user.id.length - 4)}`
: t("guest");
if (!user) {
return null;
}
return (
<UserContext.Provider
value={{
user: { ...user, shortName },
refresh: refetch,
ownsObject: ({ userId }) => {
if (userId && user.id === userId) {
return true;
}
return false;
},
logout: async () => {
await logout.mutateAsync();
refetch();
},
}}
>
{props.children}
</UserContext.Provider>
);
};
type ParticipantOrComment = {
userId: string | null;
};
// eslint-disable-next-line @typescript-eslint/ban-types
export const withSession = <P extends {} = {}>(
component: React.ComponentType<P>,
) => {
const ComposedComponent: React.VoidFunctionComponent<P> = (props: P) => {
const Component = component;
return (
<UserProvider>
<Component {...props} />
</UserProvider>
);
};
ComposedComponent.displayName = `withUser(${component.displayName})`;
return ComposedComponent;
};
/**
* @deprecated Stop using this function. All object
*/
export const isUnclaimed = (obj: ParticipantOrComment) => !obj.userId;

View file

@ -49,10 +49,7 @@ const MyApp: NextPage<AppProps> = ({ Component, pageProps }) => {
customDomain={process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
trackOutboundLinks={true}
selfHosted={true}
enabled={
typeof window !== undefined &&
!!process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN
}
enabled={!!process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
>
<DefaultSeo
openGraph={{

View file

@ -2,10 +2,11 @@ import { GetServerSideProps } from "next";
import CreatePoll from "@/components/create-poll";
import { withSession } from "../components/user-provider";
import { withSessionSsr } from "../utils/auth";
import { withPageTranslations } from "../utils/with-page-translations";
export default CreatePoll;
export default withSession(CreatePoll);
export const getServerSideProps: GetServerSideProps = withSessionSsr(
withPageTranslations(["common", "app"]),

View file

@ -6,10 +6,10 @@ import React from "react";
import FullPageLoader from "@/components/full-page-loader";
import PollPage from "@/components/poll";
import { PollContextProvider } from "@/components/poll-context";
import { withSession } from "@/components/session";
import { ParticipantsProvider } from "../components/participants-provider";
import StandardLayout from "../components/standard-layout";
import { withSession } from "../components/user-provider";
import { withSessionSsr } from "../utils/auth";
import { trpc } from "../utils/trpc";
import { withPageTranslations } from "../utils/with-page-translations";

View file

@ -3,8 +3,8 @@ import { NextPage } from "next";
import { withSessionSsr } from "@/utils/auth";
import { Profile } from "../components/profile";
import { withSession } from "../components/session";
import StandardLayout from "../components/standard-layout";
import { withSession } from "../components/user-provider";
import { withPageTranslations } from "../utils/with-page-translations";
const Page: NextPage = () => {

View file

@ -1,13 +1,10 @@
import { z } from "zod";
import { prisma } from "~/prisma/db";
import { createRouter } from "../createRouter";
import { mergeRouters, publicProcedure, router } from "../trpc";
import { mergeRouters, router } from "../trpc";
import { login } from "./login";
import { polls } from "./polls";
import { session } from "./session";
import { user } from "./user";
import { whoami } from "./whoami";
const legacyRouter = createRouter()
.merge("user.", user)
@ -18,24 +15,7 @@ const legacyRouter = createRouter()
export const appRouter = mergeRouters(
legacyRouter.interop(),
router({
p: router({
touch: publicProcedure
.input(
z.object({
pollId: z.string(),
}),
)
.mutation(async ({ input }) => {
await prisma.poll.update({
where: {
id: input.pollId,
},
data: {
touchedAt: new Date(),
},
});
}),
}),
whoami,
}),
);

View file

@ -0,0 +1,30 @@
import { prisma } from "~/prisma/db";
import { createGuestUser, UserSession } from "../../utils/auth";
import { publicProcedure, router } from "../trpc";
export const whoami = router({
get: publicProcedure.query(async ({ ctx }): Promise<UserSession> => {
if (ctx.user.isGuest) {
return { isGuest: true, id: ctx.user.id };
}
const user = await prisma.user.findUnique({
select: { id: true, name: true, email: true },
where: { id: ctx.user.id },
});
if (user === null) {
const guestUser = await createGuestUser();
ctx.session.user = guestUser;
await ctx.session.save();
return guestUser;
}
return { isGuest: false, ...user };
}),
destroy: publicProcedure.mutation(async ({ ctx }) => {
ctx.session.destroy();
}),
});

View file

@ -9,6 +9,7 @@ import { GetServerSideProps, NextApiHandler } from "next";
import { prisma } from "~/prisma/db";
import { createSSGHelperFromContext } from "../server/context";
import { randomid } from "./nanoid";
const sessionOptions: IronSessionOptions = {
@ -20,12 +21,42 @@ const sessionOptions: IronSessionOptions = {
ttl: 0, // basically forever
};
export type RegisteredUserSession = {
isGuest: false;
id: string;
name: string;
email: string;
};
export type GuestUserSession = {
isGuest: true;
id: string;
};
export type UserSession = GuestUserSession | RegisteredUserSession;
const setUser = async (session: IronSession) => {
if (!session.user) {
session.user = await createGuestUser();
await session.save();
}
if (!session.user.isGuest) {
// Check registered user still exists
const user = await prisma.user.findUnique({
where: { id: session.user.id },
});
if (!user) {
session.user = await createGuestUser();
await session.save();
}
}
};
export function withSessionRoute(handler: NextApiHandler) {
return withIronSessionApiRoute(async (req, res) => {
if (!req.session.user) {
req.session.user = await createGuestUser();
await req.session.save();
}
await setUser(req.session);
return await handler(req, res);
}, sessionOptions);
}
@ -33,14 +64,23 @@ export function withSessionRoute(handler: NextApiHandler) {
export function withSessionSsr(handler: GetServerSideProps) {
return withIronSessionSsr(async (context) => {
const { req } = context;
if (!req.session.user) {
req.session.user = await createGuestUser();
await req.session.save();
}
await setUser(req.session);
const ssg = await createSSGHelperFromContext(context);
await ssg.whoami.get.prefetch();
const res = await handler(context);
if ("props" in res) {
return { ...res, props: { ...res.props, user: req.session.user } };
return {
...res,
props: {
...res.props,
user: req.session.user,
trpcState: ssg.dehydrate(),
},
};
}
return res;

View file

@ -18,7 +18,6 @@ test("should be able to vote and comment on a poll", async ({ page }) => {
await page.locator("data-testid=vote-selector >> nth=2").click();
await page.click("text='Save'");
await expect(page.locator("text='Test user'")).toBeVisible();
await expect(page.locator("text=Guest")).toBeVisible();
await expect(
page.locator("data-testid=participant-row >> nth=4").locator("text=You"),
).toBeVisible();