diff --git a/frontend/components/CreateAccountForm.tsx b/frontend/components/CreateAccountForm.tsx index e9dbb3bdf..2bc62910b 100644 --- a/frontend/components/CreateAccountForm.tsx +++ b/frontend/components/CreateAccountForm.tsx @@ -3,6 +3,7 @@ import { Component } from "react" import Link from "next/link" import { NextRouter, withRouter } from "next/router" +import { ApolloClient } from "@apollo/client" import { CircularProgress, Paper, TextField, Typography } from "@mui/material" import { styled } from "@mui/material/styles" @@ -60,6 +61,7 @@ export function capitalizeFirstLetter(string: string) { export interface CreateAccountFormProps { onComplete: (...args: any[]) => any + apolloClient?: ApolloClient router: NextRouter } @@ -109,11 +111,14 @@ class CreateAccountForm extends Component { password_confirmation: this.state.password_confirmation, }) - await authenticate({ - email: this.state.email ?? "", - password: this.state.password ?? "", - redirect: false, - }) + await authenticate( + { + email: this.state.email ?? "", + password: this.state.password ?? "", + redirect: false, + }, + this.props.apolloClient, + ) this.props.onComplete() } catch (error: any) { @@ -177,7 +182,7 @@ class CreateAccountForm extends Component { newState.error += t("emailNoAt") newState.errorObj.email = true } - if (email && email.indexOf(".") === -1) { + if (email.indexOf(".") === -1) { newState.error += t("emailNoPoint") newState.errorObj.email = true } diff --git a/frontend/components/Dashboard/Editor/Course/CourseAliasEditForm.tsx b/frontend/components/Dashboard/Editor/Course/CourseAliasEditForm.tsx index e47ec7d15..40509086e 100644 --- a/frontend/components/Dashboard/Editor/Course/CourseAliasEditForm.tsx +++ b/frontend/components/Dashboard/Editor/Course/CourseAliasEditForm.tsx @@ -51,7 +51,7 @@ const CourseAliasEditForm = () => { <> {values.length ? ( values.map((alias, index: number) => ( - + ) : ( - {module ? module.name : "New module"} + {studyModule?.name ?? "New module"} )} @@ -138,10 +138,10 @@ function ModuleCard({ module, loading }: ModuleCardProps) { > - ) : module ? ( - + ) : studyModule ? ( + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} - + ( )) ) : ( <> - {modules?.map((module) => ( - + {modules?.map((studyModule) => ( + ))} diff --git a/frontend/components/Dashboard/Users/UserInfo.tsx b/frontend/components/Dashboard/Users/UserInfo.tsx index a685c9304..5cdd37afe 100644 --- a/frontend/components/Dashboard/Users/UserInfo.tsx +++ b/frontend/components/Dashboard/Users/UserInfo.tsx @@ -139,7 +139,9 @@ const renderAvailableFields = (data: UserDetailedFieldsFragment) => { if (!content || !title) { return null } - return + return ( + + ) }) .filter(notEmpty) } diff --git a/frontend/components/FilterMenu.tsx b/frontend/components/FilterMenu.tsx index 2c6d46aad..c5cd100e3 100644 --- a/frontend/components/FilterMenu.tsx +++ b/frontend/components/FilterMenu.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import React, { useCallback, useState } from "react" import { Clear, Search } from "@mui/icons-material" import { @@ -135,16 +135,16 @@ export default function FilterMenu({ useEffect(() => setLabelWidth(inputLabel?.current?.offsetWidth ?? 0), [])*/ - const onSubmit = () => { + const onSubmit = useCallback(() => { setSearchVariables({ ...searchVariables, search, hidden, handledBy, }) - } + }, [searchVariables, search, hidden, handledBy, setSearchVariables]) - const handleStatusChange = + const handleStatusChange = useCallback( (value: string) => (e: React.ChangeEvent) => { const newStatus = ( e.target.checked @@ -159,23 +159,63 @@ export default function FilterMenu({ ...searchVariables, status: newStatus, }) - } + }, + [setStatus, setSearchVariables, searchVariables], + ) - const handleHiddenChange = (e: React.ChangeEvent) => { - setHidden(e.target.checked) - setSearchVariables({ - ...searchVariables, - hidden: e.target.checked, - }) - } + const handleHiddenChange = useCallback( + (e: React.ChangeEvent) => { + setHidden(e.target.checked) + setSearchVariables({ + ...searchVariables, + hidden: e.target.checked, + }) + }, + [setHidden, setSearchVariables, searchVariables], + ) - const handleHandledByChange = (e: SelectChangeEvent) => { - setHandledBy(e.target.value) + const handleHandledByChange = useCallback( + (e: SelectChangeEvent) => { + setHandledBy(e.target.value) + setSearchVariables({ + ...searchVariables, + handledBy: e.target.value, + }) + }, + [setHandledBy, setSearchVariables, searchVariables], + ) + + const handleSearchReset = useCallback(() => { + setSearch("") + }, [setSearch]) + + const handleSearchChange = useCallback( + (e: React.ChangeEvent) => { + setSearch(e.target.value) + }, + [setSearch], + ) + + const handleSearchEnter = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + onSubmit() + } + }, + [onSubmit], + ) + + const handleResetAll = useCallback(() => { + setHidden(true) + setHandledBy("") + setStatus([CourseStatus.Active, CourseStatus.Upcoming]) setSearchVariables({ - ...searchVariables, - handledBy: e.target.value, + search: "", + hidden: true, + handledBy: null, + status: [CourseStatus.Active, CourseStatus.Upcoming], }) - } + }, [setHidden, setHandledBy, setStatus, setSearchVariables]) return ( @@ -186,17 +226,13 @@ export default function FilterMenu({ value={search} autoComplete="off" variant="outlined" - onChange={(e: React.ChangeEvent) => - setSearch(e.target.value) - } - onKeyDown={(e) => e.key === "Enter" && onSubmit()} + onChange={handleSearchChange} + onKeyDown={handleSearchEnter} InputProps={{ endAdornment: ( { - setSearch("") - }} + onClick={handleSearchReset} disabled={search === ""} edge="end" aria-label="clear search" @@ -286,17 +322,7 @@ export default function FilterMenu({ disabled={loading} color="secondary" variant="contained" - onClick={() => { - setHidden(true) - setHandledBy("") - setStatus([CourseStatus.Active, CourseStatus.Upcoming]) - setSearchVariables({ - search: "", - hidden: true, - handledBy: null, - status: [CourseStatus.Active, CourseStatus.Upcoming], - }) - }} + onClick={handleResetAll} > {t("reset")} diff --git a/frontend/components/HeaderBar/MoocLogo.tsx b/frontend/components/HeaderBar/MoocLogo.tsx index ff3d8fe6b..dc56809a7 100644 --- a/frontend/components/HeaderBar/MoocLogo.tsx +++ b/frontend/components/HeaderBar/MoocLogo.tsx @@ -44,7 +44,8 @@ const MoocLogo = () => ( MOOC.fi diff --git a/frontend/components/Home/CourseAndModuleList.tsx b/frontend/components/Home/CourseAndModuleList.tsx index 54c6911c5..ce82020e1 100644 --- a/frontend/components/Home/CourseAndModuleList.tsx +++ b/frontend/components/Home/CourseAndModuleList.tsx @@ -42,7 +42,7 @@ const CourseAndModuleList = () => { const { studyModules, modulesWithCourses } = useMemo(() => { let studyModules = modulesData?.study_modules ?? [] const modulesWithCourses = studyModules - .map((module) => { + .map((studyModule) => { const moduleCourses = (courses ?? []).filter( (course) => course.study_modules?.some( @@ -50,7 +50,7 @@ const CourseAndModuleList = () => { ) && course?.status !== CourseStatus.Ended, ) - return { ...module, courses: moduleCourses } + return { ...studyModule, courses: moduleCourses } }) .filter((m) => m.courses.length > 0) @@ -126,8 +126,14 @@ const CourseAndModuleList = () => { {language === "fi_FI" && (
- - + +
)} + + + + + + + + ) } export default function CourseCard({ course }: CourseCardProps) { const t = useTranslator(HomeTranslations) + const linkDisabled = + !course?.link || + (course?.status === "Upcoming" && !course?.upcoming_active_link) return ( @@ -83,27 +107,19 @@ export default function CourseCard({ course }: CourseCardProps) { > - {course ? ( - - ) : ( - - )} - {course?.link && - course?.status === "Upcoming" && - course?.upcoming_active_link && ( + + {course.link && + course.status === "Upcoming" && + course.upcoming_active_link && ( diff --git a/frontend/components/Home/CourseHighlights.tsx b/frontend/components/Home/CourseHighlights.tsx index d336d29f8..e719ff20e 100644 --- a/frontend/components/Home/CourseHighlights.tsx +++ b/frontend/components/Home/CourseHighlights.tsx @@ -1,7 +1,7 @@ import Grid from "@mui/material/Grid" import { styled } from "@mui/material/styles" -import CourseCard from "./CourseCard" +import CourseCard, { CourseCardSkeleton } from "./CourseCard" import Container from "/components/Container" import { BackgroundImage } from "/components/Images/GraphicBackground" import { H2Background, SubtitleBackground } from "/components/Text/headers" @@ -24,6 +24,10 @@ const Root = styled("div", { ${(props) => `background-color: ${props.backgroundColor};`} ` +const TitleContainer = styled("div")` + z-index: 20; +` + interface CourseHighlightsProps { courses?: CourseFieldsFragment[] loading: boolean @@ -37,6 +41,29 @@ interface CourseHighlightsProps { titleBackground: string } +interface CourseListProps { + courses: CourseFieldsFragment[] +} + +const CourseList = ({ courses }: CourseListProps) => { + return ( + + {courses.map((course) => ( + + ))} + + ) +} + +const CourseListSkeleton = () => { + return ( + + + + + ) +} + const CourseHighlights = (props: CourseHighlightsProps) => { const { courses, @@ -60,7 +87,7 @@ const CourseHighlights = (props: CourseHighlightsProps) => { brightness={brightness} /> -
+ { {subtitle} )} -
+ - - {loading ? ( - <> - - - - ) : ( - courses?.map((course) => ( - - )) - )} - + {loading ? ( + + ) : ( + + )} ) diff --git a/frontend/components/SignInForm.tsx b/frontend/components/SignInForm.tsx index f3ba2903c..41c643d32 100644 --- a/frontend/components/SignInForm.tsx +++ b/frontend/components/SignInForm.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react" +import { useApolloClient } from "@apollo/client" import { FormControl, FormHelperText, @@ -22,6 +23,7 @@ const StyledForm = styled("form")` function SignIn() { const { logInOrOut } = useLoginStateContext() const t = useTranslator(CommonTranslations) + const client = useApolloClient() const [password, setPassword] = useState("") const [email, setEmail] = useState("") @@ -92,7 +94,7 @@ function SignIn() { onClick={async (e) => { e.preventDefault() try { - await signIn({ email, password, shallow: false }) + await signIn({ email, password, shallow: false }, client) try { await logInOrOut() } catch (e) { diff --git a/frontend/contexts/AlertContext.tsx b/frontend/contexts/AlertContext.tsx index 7616e1700..a99964b13 100644 --- a/frontend/contexts/AlertContext.tsx +++ b/frontend/contexts/AlertContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, + useEffect, useMemo, useReducer, } from "react" @@ -10,6 +11,7 @@ export interface Alert { id?: number title?: string message?: string + timeout?: number component?: JSX.Element severity?: "error" | "warning" | "info" | "success" ignorePages?: string[] @@ -46,11 +48,11 @@ type AlertAction = const reducer = (state: AlertState, action: AlertAction) => { switch (action.type) { case "addAlert": - const nextAlertId = state.nextAlertId + 1 + const nextAlertId = state.nextAlertId return { ...state, alerts: [...state.alerts, { ...action.payload, id: nextAlertId }], - nextAlertId, + nextAlertId: nextAlertId + 1, } case "removeAlert": return { @@ -92,6 +94,27 @@ export const AlertProvider = React.memo(function AlertProvider({ [state.alerts, state.nextAlertId], ) + useEffect(() => { + const lastAlertId = state.nextAlertId - 1 + if (lastAlertId < 0) return () => void 0 + const newestAlert = state.alerts.filter( + (alert) => alert.id === lastAlertId, + )[0] + + let timeout: NodeJS.Timeout + + if (newestAlert.timeout) { + timeout = setTimeout(() => { + removeAlert(newestAlert) + }, newestAlert.timeout) + } + return () => { + if (timeout) { + clearTimeout(timeout) + } + } + }, [state.nextAlertId]) + return ( {children} diff --git a/frontend/contexts/LoginStateContext.tsx b/frontend/contexts/LoginStateContext.tsx index 59cac76ba..46df3fc62 100644 --- a/frontend/contexts/LoginStateContext.tsx +++ b/frontend/contexts/LoginStateContext.tsx @@ -58,7 +58,9 @@ export const LoginStateProvider = React.memo(function LoginStateProvider({ children, }: React.PropsWithChildren) { const { loggedIn, admin, currentUser } = value - const logInOrOut = () => dispatch({ type: "logInOrOut" }) + const logInOrOut = () => { + dispatch({ type: "logInOrOut" }) + } const updateUser = (user: any) => dispatch({ type: "updateUser", payload: { user } }) diff --git a/frontend/hooks/useEditorCourses.tsx b/frontend/hooks/useEditorCourses.tsx index d92fd3b7e..c4a6a1db0 100644 --- a/frontend/hooks/useEditorCourses.tsx +++ b/frontend/hooks/useEditorCourses.tsx @@ -17,6 +17,7 @@ export function useEditorCourses({ slug }: UseEditorCoursesProps) { error: courseError, } = useQuery(CourseEditorDetailsDocument, { variables: { slug }, + ssr: false, }) const { data: studyModulesData, diff --git a/frontend/hooks/useLogPageView.tsx b/frontend/hooks/useLogPageView.tsx new file mode 100644 index 000000000..48d6689b4 --- /dev/null +++ b/frontend/hooks/useLogPageView.tsx @@ -0,0 +1,20 @@ +import { useEffect } from "react" + +import { useRouter } from "next/router" + +import { initGA, logPageView } from "/lib/gtag" + +export function useLogPageView() { + const router = useRouter() + + useEffect(() => { + initGA() + logPageView() + + router.events.on("routeChangeComplete", logPageView) + + return () => { + router.events.off("routeChangeComplete", logPageView) + } + }, [router]) +} diff --git a/frontend/lib/authentication.ts b/frontend/lib/authentication.ts index d2c1e95d5..537e4b4ff 100644 --- a/frontend/lib/authentication.ts +++ b/frontend/lib/authentication.ts @@ -28,15 +28,16 @@ interface SignInProps { shallow?: boolean } -export const signIn = async ({ - email, - password, - redirect = true, - shallow = true, -}: SignInProps) => { +export const signIn = async ( + { email, password, redirect = true, shallow = true }: SignInProps, + apolloClient?: ApolloClient, +) => { const res = await tmcClient.authenticate({ username: email, password }) + const details = await userDetails(res.accessToken) + apolloClient?.resetStore() + document.cookie = `access_token=${res.accessToken};path=/` document.cookie = `admin=${details.administrator};path=/` diff --git a/frontend/lib/redirect.ts b/frontend/lib/redirect.ts index 0fb72a249..0772daad4 100644 --- a/frontend/lib/redirect.ts +++ b/frontend/lib/redirect.ts @@ -3,7 +3,8 @@ import Router from "next/router" import nookies from "nookies" export interface RedirectType { - context: NextContext + context?: NextContext + locale?: NextContext["locale"] target: string savePage?: boolean shallow?: boolean @@ -11,6 +12,7 @@ export interface RedirectType { export default function redirect({ context, + locale, target, savePage = true, shallow = true, @@ -36,6 +38,7 @@ export default function redirect({ // In the browser, we just pretend like this never even happened ;) Router.push(target, target, { shallow, + locale: locale ?? context?.locale, }) } } diff --git a/frontend/lib/with-admin.tsx b/frontend/lib/with-admin.tsx index a4eaf9f3f..6323f881a 100644 --- a/frontend/lib/with-admin.tsx +++ b/frontend/lib/with-admin.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren, Component as ReactComponent } from "react" +import { PropsWithChildren, useContext } from "react" import { NextPageContext as NextContext } from "next" @@ -9,64 +9,63 @@ import redirect from "/lib/redirect" let prevContext: NextContext | null = null -export default function withAdmin(Component: any) { - return class WithAdmin extends ReactComponent< - PropsWithChildren<{ - admin: boolean - signedIn: boolean - }> - > { - static displayName = `withAdmin(${ - Component.displayName || Component.name || "AnonymousComponent" - })` - static contextType = LoginStateContext - - static async getInitialProps(context: NextContext) { - const admin = isAdmin(context) - const signedIn = isSignedIn(context) +interface WithAdminProps { + admin: boolean + signedIn: boolean +} - prevContext = context +export default function withAdmin(Component: any) { + function WithAdmin(props: PropsWithChildren) { + const ctx = useContext(LoginStateContext) + // Needs to be before context check so that we don't redirect twice + if (!props.signedIn) { + return
Redirecting...
+ } - if (!signedIn) { + // Logging out is communicated with a context change + if (!ctx.loggedIn) { + if (prevContext) { redirect({ - context, + context: prevContext, target: "/sign-in", }) - - return { signedIn: false } } + // We don't return here because when logging out it is better to keep the old content for a moment + // than flashing a message while the redirect happens + // return
You've logged out.
+ } - return { - ...(await Component.getInitialProps?.(context)), - admin, - signedIn, - } + if (!props.admin) { + return } - render() { - // Needs to be before context check so that we don't redirect twice - if (!this.props.signedIn) { - return
Redirecting...
- } + return {props.children} + } + WithAdmin.displayName = `withAdmin(${ + Component.displayName || Component.name || "AnonymousComponent" + })` - // Logging out is communicated with a context change - if (!(this.context as any).loggedIn) { - if (prevContext) { - redirect({ - context: prevContext, - target: "/sign-in", - }) - } - // We don't return here because when logging out it is better to keep the old content for a moment - // than flashing a message while the redirect happens - // return
You've logged out.
- } + WithAdmin.getInitialProps = async (context: NextContext) => { + const admin = isAdmin(context) + const signedIn = isSignedIn(context) - if (!this.props.admin) { - return - } + prevContext = context - return {this.props.children} + if (!signedIn) { + redirect({ + context, + target: "/sign-in", + }) + + return { admin: false, signedIn: false } + } + + return { + ...(await Component.getInitialProps?.(context)), + admin, + signedIn, } } + + return WithAdmin } diff --git a/frontend/lib/with-apollo-client/index.tsx b/frontend/lib/with-apollo-client/index.tsx index 32ff61484..ebebbb7d4 100644 --- a/frontend/lib/with-apollo-client/index.tsx +++ b/frontend/lib/with-apollo-client/index.tsx @@ -5,8 +5,8 @@ import { renderToString } from "react-dom/server" import { type ApolloClient, ApolloProvider } from "@apollo/client" import fetchUserDetails from "./fetch-user-details" -import getApollo, { initNewApollo } from "./get-apollo" -import { getAccessToken } from "/lib/authentication" +import initApollo from "./init-apollo" +import { getAccessToken, isAdmin, isSignedIn } from "/lib/authentication" interface Props { apollo: ApolloClient @@ -17,7 +17,7 @@ interface Props { const isAppContext = (ctx: AppContext | NextPageContext): ctx is AppContext => { // @ts-ignore: ctx.ctx doesn't exist in NextPageContext - return Boolean(ctx?.ctx) + return "Component" in ctx } const withApolloClient = (App: any) => { @@ -27,7 +27,7 @@ const withApolloClient = (App: any) => { accessToken, ...pageProps }: Props) => { - const apolloClient = apollo ?? getApollo(apolloState, accessToken) + const apolloClient = apollo ?? initApollo(apolloState, accessToken) return ( @@ -36,20 +36,18 @@ const withApolloClient = (App: any) => { } withApollo.displayName = `withApollo(${App.displayName ?? "App"})` - withApollo.getInitialProps = async (ctx: AppContext | NextPageContext) => { - const inAppContext = isAppContext(ctx) + withApollo.getInitialProps = async ( + pageCtx: AppContext | NextPageContext, + ) => { + const inAppContext = isAppContext(pageCtx) - const { AppTree } = ctx - const Component = inAppContext ? ctx.Component : undefined + const ctx = inAppContext ? pageCtx.ctx : pageCtx - const res = inAppContext ? ctx?.ctx?.res : ctx?.res + const { AppTree } = ctx + const Component = inAppContext ? pageCtx.Component : undefined - let props: any = { - pageProps: {}, - } - if (App.getInitialProps) { - props = await App.getInitialProps(ctx) - } + let pageProps = {} as any + const apolloState = {} as any // @ts-ignore: ctx in ctx // const inAppContext = Boolean(ctx?.ctx) @@ -57,28 +55,35 @@ const withApolloClient = (App: any) => { // Run all GraphQL queries in the component tree // and extract the resulting data // @ts-ignore: ctx in ctx - const accessToken = getAccessToken(inAppContext ? ctx?.ctx : ctx) + const accessToken = getAccessToken(ctx) // It is important to use a new apollo since the page has changed because // 1. access token might have changed // 2. We've decided to discard apollo cache between page transitions to avoid bugs. // @ts-ignore: ignore type error on ctx - const apollo = initNewApollo(accessToken) - // @ts-ignore: ignore - apollo.toJSON = () => null + const apollo = initApollo(apolloState?.data, accessToken) + + if (App.getInitialProps) { + ;(ctx as any).apolloClient = apollo + pageProps = await App.getInitialProps(pageCtx) + } // UserDetailsContext uses this const currentUser = await fetchUserDetails(apollo) - - props.pageProps.currentUser = currentUser + const signedIn = isSignedIn(ctx) + const admin = isAdmin(ctx) + + pageProps = { + ...pageProps, + currentUser, + signedIn, + admin, + } if (typeof window === "undefined") { - if (inAppContext) { - props = { ...props, apollo } - } else { - props = { pageProps: { ...props, apollo } } - } - if (res?.finished) { - return props + if (ctx?.res?.headersSent || ctx?.res?.finished) { + return pageProps } + const props = { ...pageProps, apolloState, apollo } + const appTreeProps = inAppContext ? props : { pageProps: props } const { getMarkupFromTree } = await import("@apollo/client/react/ssr") @@ -88,13 +93,7 @@ const withApolloClient = (App: any) => { // getDataFromTree is using getMarkupFromTree anyway? await getMarkupFromTree({ renderFunction: renderToString, - tree: ( - - ), + tree: , }) // Run all GraphQL queries } catch (error) { @@ -106,12 +105,15 @@ const withApolloClient = (App: any) => { } // Extract query data from the Apollo store - const apolloState = apollo.cache.extract() + apolloState.data = apollo.cache.extract() + // @ts-ignore: ignore + apollo.toJSON = () => null return { - ...props, + ...pageProps, accessToken, apolloState, + apollo, } } diff --git a/frontend/lib/with-apollo-client/get-apollo.ts b/frontend/lib/with-apollo-client/init-apollo.ts similarity index 75% rename from frontend/lib/with-apollo-client/get-apollo.ts rename to frontend/lib/with-apollo-client/init-apollo.ts index f773e37ed..5da748124 100644 --- a/frontend/lib/with-apollo-client/get-apollo.ts +++ b/frontend/lib/with-apollo-client/init-apollo.ts @@ -1,7 +1,9 @@ import { createUploadLink } from "apollo-upload-client" +import deepmerge from "deepmerge" import extractFiles from "extract-files/extractFiles.mjs" import isExtractableFile from "extract-files/isExtractableFile.mjs" import fetch from "isomorphic-unfetch" +import { isEqual } from "lodash" import nookies from "nookies" import { @@ -20,7 +22,52 @@ let apolloClient: ApolloClient | null = null const production = process.env.NODE_ENV === "production" const isBrowser = typeof window !== "undefined" -function create(initialState: any, originalAccessToken?: string) { +function createCache() { + // these cache settings are mainly for the breadcrumbs + return new InMemoryCache({ + dataIdFromObject: (object: any) => { + switch (object.__typename) { + case "Course": + return `Course:${object.slug}:${object.id}` + case "StudyModule": + return `StudyModule:${object.slug}:${object.id}` + default: + return defaultDataIdFromObject(object) + } + }, + typePolicies: { + Query: { + fields: { + userCourseSettings: { + // for "fetch more" type querying of user points + keyArgs: false, + merge: (existing, incoming) => { + const existingEdges = existing?.edges ?? [] + const incomingEdges = incoming?.edges ?? [] + const pageInfo = incoming?.pageInfo ?? { + hasNextPage: false, + endCursor: null, + __typename: "PageInfo", + } + + const edges = [...existingEdges, ...incomingEdges] + + return { + pageInfo, + edges, + totalCount: incoming?.totalCount ?? null, + } + }, + }, + }, + }, + }, + }) +} + +const cache: InMemoryCache = createCache() + +function createApolloClient(initialState: any, originalAccessToken?: string) { const authLink = setContext((_, { headers }) => { // Always get the current access token from cookies in case it has changed let accessToken: string | undefined = nookies.get()["access_token"] @@ -64,61 +111,21 @@ function create(initialState: any, originalAccessToken?: string) { if (networkError) console.log(`[Network error]: ${networkError}`) }) - // these cache settings are mainly for the breadcrumbs - const cache: InMemoryCache = new InMemoryCache({ - dataIdFromObject: (object: any) => { - switch (object.__typename) { - case "Course": - return `Course:${object.slug}:${object.id}` - case "StudyModule": - return `StudyModule:${object.slug}:${object.id}` - default: - return defaultDataIdFromObject(object) - } - }, - typePolicies: { - Query: { - fields: { - userCourseSettings: { - // for "fetch more" type querying of user points - keyArgs: false, - merge: (existing, incoming) => { - const existingEdges = existing?.edges ?? [] - const incomingEdges = incoming?.edges ?? [] - const pageInfo = incoming?.pageInfo ?? { - hasNextPage: false, - endCursor: null, - __typename: "PageInfo", - } - - const edges = [...existingEdges, ...incomingEdges] - - return { - pageInfo, - edges, - totalCount: incoming?.totalCount ?? null, - } - }, - }, - }, - }, - }, - }) - return new ApolloClient({ link: isBrowser ? ApolloLink.from([errorLink, authLink.concat(uploadAndBatchHTTPLink)]) : authLink.concat(uploadAndBatchHTTPLink), - cache: cache.restore(initialState || {}), + // create a new cache on the server + cache: !isBrowser ? createCache() : cache.restore(initialState || {}), ssrMode: !isBrowser, ssrForceFetchDelay: 100, defaultOptions: { watchQuery: { - fetchPolicy: "cache-first", //"no-cache", + fetchPolicy: isBrowser ? "cache-first" : "no-cache", //"no-cache", errorPolicy: "ignore", }, query: { - fetchPolicy: "cache-first", //"no-cache", + fetchPolicy: isBrowser ? "cache-first" : "no-cache", //"no-cache", errorPolicy: "all", }, }, @@ -127,26 +134,45 @@ function create(initialState: any, originalAccessToken?: string) { let previousAccessToken: string | undefined = undefined -export default function getApollo(initialState: any, accessToken?: string) { - // Make sure to create a new client for every server-side request so that data - // isn't shared between connections (which would be bad) - if (typeof window === "undefined") { - return create(initialState, accessToken) - } - +export default function initApollo(initialState: any, accessToken?: string) { // Reuse client on the client-side // Also force new client if access token has changed because we don't want to risk accidentally // serving cached data from the previous user. - if (!apolloClient || accessToken !== previousAccessToken) { - apolloClient = create(initialState, accessToken) - } + const userChanged = accessToken !== previousAccessToken + //if (isBrowser && userChanged) { + // cache = createCache() + //} + + const _apolloClient = + !userChanged && apolloClient && isBrowser + ? apolloClient + : createApolloClient(undefined, accessToken) previousAccessToken = accessToken - return apolloClient -} + if (initialState) { + const existingCache = _apolloClient.extract() + const data = deepmerge(existingCache, initialState, { + arrayMerge: (destination: any, source: any) => [ + ...source, + ...destination.filter((d: any) => + source.every((s: any) => !isEqual(d, s)), + ), + ], + }) + + _apolloClient.cache.restore(data) + } + + // Make sure to create a new client for every server-side request so that data + // isn't shared between connections (which would be bad) + if (!isBrowser) { + return _apolloClient + } + + if (!apolloClient) { + apolloClient = _apolloClient + } -export function initNewApollo(accessToken?: string) { - apolloClient = create(undefined, accessToken) - return apolloClient + return _apolloClient } diff --git a/frontend/lib/with-signed-in.tsx b/frontend/lib/with-signed-in.tsx index 90b26d278..a785ae497 100644 --- a/frontend/lib/with-signed-in.tsx +++ b/frontend/lib/with-signed-in.tsx @@ -1,6 +1,7 @@ -import { PropsWithChildren, Component as ReactComponent } from "react" +import { PropsWithChildren, useContext } from "react" import { NextPageContext as NextContext } from "next" +import { useRouter } from "next/router" import { LoginStateContext } from "/contexts/LoginStateContext" import { isSignedIn } from "/lib/authentication" @@ -10,54 +11,56 @@ let prevContext: NextContext | null = null // TODO: might need to wrap in function to give redirect parameters (= shallow?) export default function withSignedIn(Component: any) { - return class WithSignedIn extends ReactComponent< - PropsWithChildren<{ signedIn: boolean }> - > { - static displayName = `withSignedIn(${ - Component.name || Component.displayName || "AnonymousComponent" - })` - static contextType = LoginStateContext - - static async getInitialProps(context: NextContext) { - const signedIn = isSignedIn(context) - - prevContext = context - - if (!signedIn) { - redirect({ - context, - target: "/sign-in", - }) - - return {} - } + function WithSignedIn(props: PropsWithChildren<{ signedIn: boolean }>) { + const ctx = useContext(LoginStateContext) + const router = useRouter() - return { - ...(await Component.getInitialProps?.(context)), - signedIn, - } + // Needs to be before context check so that we don't redirect twice + if (!props.signedIn) { + return
Redirecting...
} - render() { - // Needs to be before context check so that we don't redirect twice - if (!this.props.signedIn) { - return
Redirecting...
- } + // Logging out is communicated with a context change + if (!ctx.loggedIn) { + const newContext = prevContext ?? ({} as NextContext) + redirect({ + context: newContext, + locale: newContext.locale ?? router.locale, + target: "/sign-in", + }) + // We don't return here because when logging out it is better to keep the old content for a moment + // than flashing a message while the redirect happens + // return
You've logged out.
+ } + + return {props.children} + } - // Logging out is communicated with a context change - if (!(this.context as any).loggedIn) { - if (prevContext) { - redirect({ - context: prevContext, - target: "/sign-in", - }) - } - // We don't return here because when logging out it is better to keep the old content for a moment - // than flashing a message while the redirect happens - // return
You've logged out.
+ WithSignedIn.displayName = `withSignedIn(${ + Component.name || Component.displayName || "AnonymousComponent" + })` + + WithSignedIn.getInitialProps = async (context: NextContext) => { + const signedIn = isSignedIn(context) + + prevContext = context + + if (!signedIn) { + redirect({ + context, + target: "/sign-in", + }) + + return { + signedIn: false, } + } - return {this.props.children} + return { + ...(await Component.getInitialProps?.(context)), + signedIn, } } + + return WithSignedIn } diff --git a/frontend/lib/with-signed-out.tsx b/frontend/lib/with-signed-out.tsx index 1c66b6ada..85763954b 100644 --- a/frontend/lib/with-signed-out.tsx +++ b/frontend/lib/with-signed-out.tsx @@ -1,44 +1,46 @@ -import { PropsWithChildren, Component as ReactComponent } from "react" +import { PropsWithChildren } from "react" import { NextPageContext as NextContext } from "next" import { isSignedIn } from "/lib/authentication" -import redirectTo from "/lib/redirect" +import redirect from "/lib/redirect" // TODO: add more redirect parameters? -export default function withSignedOut(redirect = "/") { +export default function withSignedOut(target = "/") { return (Component: any) => { - return class WithSignedOut extends ReactComponent< - PropsWithChildren<{ signedIn: boolean }> - > { - static displayName = `withSignedOut(${ - Component.name || Component.displayName || "AnonymousComponent" - })` - - static async getInitialProps(context: NextContext) { - const signedIn = isSignedIn(context) - - if (signedIn) { - redirectTo({ - context, - target: redirect, - shallow: false, - }) - } + function WithSignedOut(props: PropsWithChildren<{ signedIn: boolean }>) { + if (props.signedIn) { + return
Redirecting...
+ } + + return {props.children} + } + + WithSignedOut.displayName = `withSignedOut(${ + Component.name || Component.displayName || "AnonymousComponent" + })` + + WithSignedOut.getInitialProps = async (context: NextContext) => { + const signedIn = isSignedIn(context) + + if (signedIn) { + redirect({ + context, + target, + shallow: false, + }) return { - ...(await Component.getInitialProps?.(context)), - signedIn, + signedIn: true, } } - render() { - if (this.props.signedIn) { - return
Redirecting...
- } - - return {this.props.children} + return { + ...(await Component.getInitialProps?.(context)), + signedIn, } } + + return WithSignedOut } } diff --git a/frontend/next.config.js b/frontend/next.config.js index 1fbc9700b..0799c98b6 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -27,7 +27,7 @@ const nextConfiguration = { // enabling emotion here will allow for components to be used as selectors // ie. assuming there's a Card component we can do styled.div`${Card} + ${Card} { padding-top: 0.5rem; }` emotion: { - // would label things with [local] or something; will break styling if not set to never + // would label things with [local] or something; will break styling otherwise autoLabel: "dev-only", labelFormat: "[dirname]-[filename]-[local]", importMap: { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f3a27d537..c9048c370 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,7 @@ "apollo-upload-client": "^17.0.0", "axios": "^0.27.2", "compression": "^1.7.4", + "deepmerge": "^4.2.2", "express": "^4.17.3", "extract-files": "^13.0.0", "formik": "^2.2.9", @@ -11266,7 +11267,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -33541,8 +33541,7 @@ "deepmerge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" }, "defaults": { "version": "1.0.3", diff --git a/frontend/package.json b/frontend/package.json index 788e701a7..f856cca55 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,6 +41,7 @@ "apollo-upload-client": "^17.0.0", "axios": "^0.27.2", "compression": "^1.7.4", + "deepmerge": "^4.2.2", "express": "^4.17.3", "extract-files": "^13.0.0", "formik": "^2.2.9", diff --git a/frontend/pages/_app.tsx b/frontend/pages/_app.tsx index 1891f1d23..49778c09e 100644 --- a/frontend/pages/_app.tsx +++ b/frontend/pages/_app.tsx @@ -1,8 +1,10 @@ // import "@fortawesome/fontawesome-free/css/all.min.css" -import { useEffect, useMemo } from "react" +import { PropsWithChildren, useEffect, useMemo, useState } from "react" import { ConfirmProvider } from "material-ui-confirm" -import type { AppContext, AppProps } from "next/app" +import { NextPageContext } from "next" +import type { AppContext, AppInitialProps, AppProps } from "next/app" +import App from "next/app" import Head from "next/head" import { useRouter } from "next/router" @@ -14,9 +16,8 @@ import NewLayout from "./_new/_layout" import { AlertProvider } from "/contexts/AlertContext" import { BreadcrumbProvider } from "/contexts/BreadcrumbContext" import { LoginStateProvider } from "/contexts/LoginStateContext" +import { useLogPageView } from "/hooks/useLogPageView" import { useScrollToHash } from "/hooks/useScrollToHash" -import { isAdmin, isSignedIn } from "/lib/authentication" -import { initGA, logPageView } from "/lib/gtag" import withApolloClient from "/lib/with-apollo-client" import { createEmotionSsr } from "/src/createEmotionSsr" import { fontCss } from "/src/fonts" @@ -25,6 +26,8 @@ import originalTheme from "/src/theme" import PagesTranslations from "/translations/pages" import { useTranslator } from "/util/useTranslator" +import { CurrentUserQuery } from "/graphql/generated" + const { withAppEmotionCache, augmentDocumentWithEmotionCache } = createEmotionSsr({ key: "emotion-css", @@ -32,27 +35,48 @@ const { withAppEmotionCache, augmentDocumentWithEmotionCache } = export { augmentDocumentWithEmotionCache } -export function MyApp({ Component, pageProps }: AppProps) { +interface MyAppProps extends AppProps { + currentUser: CurrentUserQuery["currentUser"] + signedIn: boolean + admin: boolean +} + +// @ts-ignore: not used, try as workaround for SSR hydration problems +function Hydrated(props: PropsWithChildren) { + const [hydration, setHydration] = useState(false) + + useEffect(() => { + if (typeof window !== "undefined") { + setHydration(true) + } + }, []) + + if (hydration) { + return props.children + } + + return null +} + +export function MyApp({ + Component, + pageProps, + currentUser, + signedIn, + admin, +}: MyAppProps) { const router = useRouter() const t = useTranslator(PagesTranslations) const isNew = router.pathname?.includes("_new") + useLogPageView() useEffect(() => { - initGA() - logPageView() - - router.events.on("routeChangeComplete", logPageView) - const jssStyles = document?.querySelector("#jss-server-side") if (jssStyles?.parentElement) { jssStyles.parentElement.removeChild(jssStyles) } - - return () => { - router.events.off("routeChangeComplete", logPageView) - } - }, [router]) + }, []) useScrollToHash() @@ -65,11 +89,11 @@ export function MyApp({ Component, pageProps }: AppProps) { const loginStateContextValue = useMemo( () => ({ - loggedIn: pageProps?.signedIn, - admin: pageProps?.admin, - currentUser: pageProps?.currentUser, + loggedIn: signedIn, + admin, + currentUser, }), - [pageProps?.loggedIn, pageProps?.admin, pageProps?.currentUser], + [signedIn, admin, currentUser], ) const alternateLanguage = useMemo(() => { @@ -112,35 +136,28 @@ export function MyApp({ Component, pageProps }: AppProps) { // @ts-ignore: initialProps const originalGetInitialProps = MyApp.getInitialProps -MyApp.getInitialProps = async (props: AppContext) => { - const { ctx, Component } = props +const isAppContext = (ctx: AppContext | NextPageContext): ctx is AppContext => { + return "Component" in ctx +} - let originalProps: any = {} +MyApp.getInitialProps = async (appContext: AppContext | NextPageContext) => { + const ctx = isAppContext(appContext) ? appContext.ctx : appContext - if (originalGetInitialProps) { - originalProps = (await originalGetInitialProps(props)) || {} - } - if (Component.getInitialProps) { - originalProps = { - ...originalProps, - pageProps: { - ...originalProps?.pageProps, - ...((await Component.getInitialProps(ctx)) || {}), - }, - } - } + const appProps = isAppContext(appContext) + ? await App.getInitialProps(appContext) + : ({} as AppInitialProps) - const signedIn = isSignedIn(ctx) - const admin = signedIn && isAdmin(ctx) + const componentInitialProps = + isAppContext(appContext) && appContext.Component.getInitialProps + ? await appContext.Component.getInitialProps(ctx) + : {} - return { - ...originalProps, - pageProps: { - ...originalProps.pageProps, - signedIn, - admin, - }, + appProps.pageProps = { + ...appProps.pageProps, + ...componentInitialProps, } + + return appProps } export default withAppEmotionCache(withApolloClient(MyApp)) diff --git a/frontend/pages/courses.tsx b/frontend/pages/courses.tsx index 0c146b0ef..b47450f56 100644 --- a/frontend/pages/courses.tsx +++ b/frontend/pages/courses.tsx @@ -70,6 +70,7 @@ function useCourseSearch() { data: editorData, } = useQuery(EditorCoursesDocument, { variables: searchVariables || initialSearchVariables, + ssr: false, }) const { loading: handlersLoading, @@ -147,7 +148,7 @@ function useCourseSearch() { function Courses() { const t = useTranslator(CoursesTranslations) const { - loading, + loading = true, error, handlersData, editorData, diff --git a/frontend/pages/email-templates/[id].tsx b/frontend/pages/email-templates/[id].tsx index a3eaa9764..e73fd7b65 100644 --- a/frontend/pages/email-templates/[id].tsx +++ b/frontend/pages/email-templates/[id].tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { useEffect, useState } from "react" import { NextSeo } from "next-seo" import Router from "next/router" @@ -89,7 +89,7 @@ const TemplateList = styled("div")` ` interface SnackbarData { - type: "error" | "success" | "warning" | "error" + type: "error" | "success" | "warning" message: string } @@ -140,6 +140,35 @@ const EmailTemplateView = () => { ]) const pageTitle = useSubtitle(data?.email_template?.name ?? "") + useEffect(() => { + if (data && !didInit) { + setName(data.email_template?.name) + setTxtBody(data.email_template?.txt_body) + setHtmlBody(data.email_template?.html_body) + setTitle(data.email_template?.title) + setTemplateType(data.email_template?.template_type) + setExerciseThreshold(data.email_template?.exercise_completions_threshold) + setPointsThreshold(data.email_template?.points_threshold) + setTriggeredByCourseId( + data.email_template?.triggered_automatically_by_course_id, + ) + setDidInit(true) + setEmailTemplate(data.email_template) + } + }, [ + data, + setName, + setTxtBody, + setHtmlBody, + setTitle, + setTemplateType, + didInit, + setExerciseThreshold, + setPointsThreshold, + setTriggeredByCourseId, + setEmailTemplate, + ]) + if (loading) { return } @@ -148,21 +177,6 @@ const EmailTemplateView = () => { return

Error has occurred

} - if (data && !didInit) { - setName(data.email_template?.name) - setTxtBody(data.email_template?.txt_body) - setHtmlBody(data.email_template?.html_body) - setTitle(data.email_template?.title) - setTemplateType(data.email_template?.template_type) - setExerciseThreshold(data.email_template?.exercise_completions_threshold) - setPointsThreshold(data.email_template?.points_threshold) - setTriggeredByCourseId( - data.email_template?.triggered_automatically_by_course_id, - ) - setDidInit(true) - setEmailTemplate(data.email_template) - } - return ( <> @@ -302,12 +316,12 @@ const EmailTemplateView = () => { Limited to template type {value.types.length > 1 ? "s" : ""}{" "} {value.types.map((type, index) => ( - <> + {index > 0 ? ", " : ""} {templateNames[type] ?? type} - + ))}

)} diff --git a/frontend/pages/profile/index.tsx b/frontend/pages/profile/index.tsx index b5dda5ff5..3b99cde62 100644 --- a/frontend/pages/profile/index.tsx +++ b/frontend/pages/profile/index.tsx @@ -69,7 +69,8 @@ function Profile() { if (error) { return } - if (loading) { + if (loading || !data?.currentUser) { + // don't show flash of "no first name" content return } diff --git a/frontend/pages/sign-up.tsx b/frontend/pages/sign-up.tsx index 7fd919b16..ab19da5ef 100644 --- a/frontend/pages/sign-up.tsx +++ b/frontend/pages/sign-up.tsx @@ -1,5 +1,7 @@ import Router from "next/router" +import { useApolloClient } from "@apollo/client" + import { RegularContainer } from "/components/Container" import CreateAccountForm from "/components/CreateAccountForm" import { useAlertContext } from "/contexts/AlertContext" @@ -11,6 +13,7 @@ import { useTranslator } from "/util/useTranslator" const SignUpPage = () => { const t = useTranslator(SignUpTranslations) + const client = useApolloClient() useBreadcrumbs([ { @@ -40,7 +43,7 @@ const SignUpPage = () => { return (
- +
)