diff --git a/.eslintrc.json b/.eslintrc.json index f14dfb0b..c7c92f71 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,7 +16,9 @@ "root": true, "ignorePatterns": [ "dist", - "src/scripts" + "src/scripts", + "**/*.d.ts", + "vite.config.js" ], "rules": { "@typescript-eslint/consistent-type-imports": [ diff --git a/.gitignore b/.gitignore index 4d29575d..f04cae75 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # production /build +*.tsbuildinfo # misc .DS_Store @@ -21,3 +22,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# Custom +vite.config.d.ts +vite.config.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 721ed234..59197783 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,7 +10,8 @@ "pipenv" ], "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" + "source.fixAll.eslint": "always", + "source.organizeImports": "never" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, @@ -18,6 +19,9 @@ 80 ], "editor.tabSize": 2, + "files.exclude": { + "**/*.tsbuildinfo": true + }, "javascript.format.semicolons": "remove", "javascript.preferences.quoteStyle": "double", "prettier.configPath": ".prettierrc.json", diff --git a/package.json b/package.json index e3f66c71..9b498387 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,12 @@ { + "//": [ + "Based off of:", + "https://github.com/vitejs/vite/blob/main/packages/create-vite/template-react-ts/package.json", + "Dependency rules:", + "`peerDependencies` should contain everything required to build and test a", + "service's front end.", + "TODO: make devDependencies the same as peerDependencies" + ], "name": "codeforlife", "description": "Common frontend code", "private": true, @@ -47,10 +55,6 @@ "serve": "^14.2.3", "yup": "^1.1.1" }, - "//": [ - "`peerDependencies` should contain everything required to build and test a", - "service's front end." - ], "devDependencies": { "@testing-library/dom": "^9.3.4", "@testing-library/jest-dom": "^6.2.0", @@ -75,10 +79,12 @@ "vitest": "^1.2.0" }, "peerDependencies": { + "@eslint/js": "^9.9.0", "@testing-library/dom": "^9.3.4", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", "@testing-library/user-event": "^14.5.2", + "@types/jest": "^29.5.12", "@types/js-cookie": "^3.0.3", "@types/node": "^20.14.2", "@types/qs": "^6.9.7", @@ -91,9 +97,14 @@ "eslint-config-prettier": "^9.1.0", "eslint-config-react-app": "^7.0.1", "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-react": "^7.35.0", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", "jsdom": "^23.2.0", "prettier": "^3.2.1", "typescript": "^5.3.3", + "typescript-eslint": "^8.1.0", "vite": "^5.0.11", "vitest": "^1.2.0" }, diff --git a/src/api/baseQuery.ts b/src/api/baseQuery.ts deleted file mode 100644 index 678f9731..00000000 --- a/src/api/baseQuery.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - fetchBaseQuery, - type BaseQueryApi, - type BaseQueryFn, - type FetchArgs, - type FetchBaseQueryError, -} from "@reduxjs/toolkit/query" -import Cookies from "js-cookie" -import qs from "qs" - -import { API_BASE_URL, PORTAL_BASE_URL, SERVICE_NAME } from "../env" -import { camelCaseToSnakeCase, snakeCaseToCamelCase } from "../utils/general" - -export type FetchBaseQuery = BaseQueryFn< - FetchArgs, - unknown, - FetchBaseQueryError -> - -export const fetch = fetchBaseQuery({ - baseUrl: API_BASE_URL, - credentials: "include", -}) - -export function parseRequestBody(args: FetchArgs): void { - // Check if the request has a body and its content type is specified. - if (typeof args.body !== "object" || args.body === null) return - - camelCaseToSnakeCase(args.body) - - if (args.headers !== undefined && "Content-Type" in args.headers) { - // Stringify the request body based on its content type. - switch (args.headers["Content-Type"]) { - case "application/x-www-form-urlencoded": - args.body = qs.stringify(args.body) - break - case "application/json": - args.body = JSON.stringify(args.body) - break - } - } -} - -export async function injectCsrfToken( - fetch: FetchBaseQuery, - args: FetchArgs, - api: BaseQueryApi, - serviceName: string = SERVICE_NAME, -): Promise { - // Check if the request method is safe. - // https://datatracker.ietf.org/doc/html/rfc9110.html#section-9.2.1 - if ( - args.method !== undefined && - ["GET", "HEAD", "OPTIONS", "TRACE"].includes(args.method) - ) - return - - // https://docs.djangoproject.com/en/3.2/ref/csrf/ - const cookieName = `${serviceName}_csrftoken` - let csrfToken = Cookies.get(cookieName) - if (csrfToken === undefined) { - // Get the CSRF token. - const { error } = await fetch( - { - url: "csrf/cookie/", - method: "GET", - }, - api, - {}, - ) - - // Validate we got the CSRF token. - if (error !== undefined) { - window.location.href = `${PORTAL_BASE_URL}/error/500` - } - csrfToken = Cookies.get(cookieName) - if (csrfToken === undefined) { - window.location.href = `${PORTAL_BASE_URL}/error/500` - } - } - - // Inject the CSRF token. - args.body = { - ...(typeof args.body !== "object" || args.body === null ? {} : args.body), - csrfmiddlewaretoken: csrfToken, - } -} - -export function handleResponseError(error: FetchBaseQueryError): void { - if ( - error.status === 400 && - typeof error.data === "object" && - error.data !== null - ) { - // Parse the error's data from snake_case to camelCase. - snakeCaseToCamelCase(error.data) - } else if (error.status === 401) { - // TODO: redirect to appropriate login page based on user type. - window.location.href = `${PORTAL_BASE_URL}/login/teacher` - } else { - // Catch-all error pages by status-code. - window.location.href = `${PORTAL_BASE_URL}/error/${ - [403, 404].includes(error.status as number) ? error.status : 500 - }` - } -} - -export function parseResponseBody(data: unknown): void { - // Parse the response's data from snake_case to camelCase. - if (typeof data !== "object" || data === null) return - - snakeCaseToCamelCase(data) -} - -// TODO: https://redux-toolkit.js.org/rtk-query/usage/customizing-queries#implementing-a-custom-basequery -const baseQuery: FetchBaseQuery = async (args, api, extraOptions) => { - await injectCsrfToken(fetch, args, api) - - parseRequestBody(args) - - // Send the HTTP request and fetch the response. - const result = await fetch(args, api, extraOptions) - - if (result.error) handleResponseError(result.error) - - parseResponseBody(result.data) - - return result -} - -export default baseQuery diff --git a/src/api/createApi.ts b/src/api/createApi.ts new file mode 100644 index 00000000..4b5dd66e --- /dev/null +++ b/src/api/createApi.ts @@ -0,0 +1,96 @@ +import { + createApi as _createApi, + fetchBaseQuery, +} from "@reduxjs/toolkit/query/react" + +import { SERVICE_API_URL } from "../env" +import { getCsrfCookie, logout } from "../utils/auth" +import defaultTagTypes from "./tagTypes" + +// TODO: decide if we want to keep any of this. +// export function handleResponseError(error: FetchBaseQueryError): void { +// if ( +// error.status === 400 && +// typeof error.data === "object" && +// error.data !== null +// ) { +// // Parse the error's data from snake_case to camelCase. +// snakeCaseToCamelCase(error.data) +// } else if (error.status === 401) { +// // TODO: redirect to appropriate login page based on user type. +// window.location.href = `${PORTAL_BASE_URL}/login/teacher` +// } else { +// // Catch-all error pages by status-code. +// window.location.href = `${PORTAL_BASE_URL}/error/${ +// [403, 404].includes(error.status as number) ? error.status : 500 +// }` +// } +// } + +export default function createApi({ + tagTypes = [], +}: { + tagTypes?: readonly TagTypes[] +} = {}) { + const fetch = fetchBaseQuery({ + baseUrl: `${SERVICE_API_URL}/`, + credentials: "include", + prepareHeaders: (headers, { type }) => { + if (type === "mutation") { + let csrfToken = getCsrfCookie() + if (csrfToken) headers.set("x-csrftoken", csrfToken) + } + + return headers + }, + }) + + const api = _createApi({ + // https://redux-toolkit.js.org/rtk-query/usage/customizing-queries#implementing-a-custom-basequery + baseQuery: async (args, api, extraOptions) => { + if (api.type === "mutation" && getCsrfCookie() === undefined) { + // Get the CSRF token. + const { error } = await fetch( + { url: "/csrf/cookie", method: "GET" }, + api, + {}, + ) + + // Validate we got the CSRF token. + if (error !== undefined) { + console.error(error) + // TODO + // window.location.href = `${PORTAL_BASE_URL}/error/500` + } + if (getCsrfCookie() === undefined) { + // TODO + // window.location.href = `${PORTAL_BASE_URL}/error/500` + } + } + + // Send the HTTP request and fetch the response. + return await fetch(args, api, extraOptions) + }, + tagTypes: [...defaultTagTypes, ...tagTypes], + endpoints: build => ({ + logout: build.mutation({ + query: () => ({ + url: "session/logout/", + method: "POST", + }), + async onQueryStarted(_, { dispatch, queryFulfilled }) { + try { + await queryFulfilled + } catch (error) { + console.error("Failed to call logout endpoint...", error) + } finally { + logout() + dispatch(api.util.resetApiState()) + } + }, + }), + }), + }) + + return api +} diff --git a/src/api/endpoints/index.ts b/src/api/endpoints/index.ts index 3f8ebfe2..516a6f55 100644 --- a/src/api/endpoints/index.ts +++ b/src/api/endpoints/index.ts @@ -1,47 +1,8 @@ -import getReadAuthFactorEndpoints, { - type ListAuthFactorsArg, - type ListAuthFactorsResult, - AUTH_FACTOR_TAG, -} from "./authFactor" -import getReadClassEndpoints, { - type ListClassesArg, - type ListClassesResult, - type RetrieveClassArg, - type RetrieveClassResult, - CLASS_TAG, -} from "./klass" -import getReadSchoolEndpoints, { - type RetrieveSchoolArg, - type RetrieveSchoolResult, - SCHOOL_TAG, -} from "./school" -import getReadUserEndpoints, { - type ListUsersArg, - type ListUsersResult, - type RetrieveUserArg, - type RetrieveUserResult, - USER_TAG, -} from "./user" - -export { - AUTH_FACTOR_TAG, - CLASS_TAG, - getReadAuthFactorEndpoints, - getReadClassEndpoints, - getReadSchoolEndpoints, - getReadUserEndpoints, - SCHOOL_TAG, - USER_TAG, - type ListAuthFactorsArg, - type ListAuthFactorsResult, - type ListClassesArg, - type ListClassesResult, - type ListUsersArg, - type ListUsersResult, - type RetrieveClassArg, - type RetrieveClassResult, - type RetrieveSchoolArg, - type RetrieveSchoolResult, - type RetrieveUserArg, - type RetrieveUserResult, -} +export * from "./authFactor" +export { default as getReadAuthFactorEndpoints } from "./authFactor" +export * from "./klass" +export { default as getReadClassEndpoints } from "./klass" +export * from "./school" +export { default as getReadSchoolEndpoints } from "./school" +export * from "./user" +export { default as getReadUserEndpoints } from "./user" diff --git a/src/api/index.ts b/src/api/index.ts index 4afd96a0..4afab9a5 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,47 +1,4 @@ -import baseQuery from "./baseQuery" -import type { - AdminSchoolTeacher, - AdminSchoolTeacherUser, - AuthFactor, - Class, - IndependentUser, - NonAdminSchoolTeacher, - NonAdminSchoolTeacherUser, - NonSchoolTeacher, - NonSchoolTeacherUser, - OtpBypassToken, - School, - SchoolTeacher, - SchoolTeacherUser, - Student, - StudentUser, - Teacher, - TeacherUser, - User, -} from "./models" -import tagTypes from "./tagTypes" -import urls from "./urls" - -export { - baseQuery, - tagTypes, - urls, - type AdminSchoolTeacher, - type AdminSchoolTeacherUser, - type AuthFactor, - type Class, - type IndependentUser, - type NonAdminSchoolTeacher, - type NonAdminSchoolTeacherUser, - type NonSchoolTeacher, - type NonSchoolTeacherUser, - type OtpBypassToken, - type School, - type SchoolTeacher, - type SchoolTeacherUser, - type Student, - type StudentUser, - type Teacher, - type TeacherUser, - type User, -} +export { default as createApi } from "./createApi" +export * from "./models" +export { default as tagTypes } from "./tagTypes" +export { default as urls } from "./urls" diff --git a/src/api/urls.ts b/src/api/urls.ts index 12a2681a..83ec730a 100644 --- a/src/api/urls.ts +++ b/src/api/urls.ts @@ -1,17 +1,13 @@ -function url(list: string, detail: string) { - if (list === detail) throw Error("List and detail are the same.") - - return { list, detail } -} +import { modelUrls } from "../utils/api" const urls = { - user: url("users/", "users//"), - teacher: url("users/teachers/", "users/teachers//"), - student: url("users/students/", "users/students//"), - school: url("schools/", "schools//"), - class: url("classes/", "classes//"), - otpBypassToken: url("otp-bypass-tokens/", "otp-bypass-tokens//"), - authFactor: url("auth-factors/", "auth-factors//"), + user: modelUrls("users/", "users//"), + teacher: modelUrls("users/teachers/", "users/teachers//"), + student: modelUrls("users/students/", "users/students//"), + school: modelUrls("schools/", "schools//"), + class: modelUrls("classes/", "classes//"), + otpBypassToken: modelUrls("otp-bypass-tokens/", "otp-bypass-tokens//"), + authFactor: modelUrls("auth-factors/", "auth-factors//"), } export default urls diff --git a/src/components/App.tsx b/src/components/App.tsx index 53e148c1..0f15d79c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,59 +1,85 @@ import { CssBaseline, ThemeProvider } from "@mui/material" import { type ThemeProviderProps } from "@mui/material/styles/ThemeProvider" -import React, { useCallback } from "react" +import { useCallback, type FC, type ReactNode } from "react" import { Provider, type ProviderProps } from "react-redux" +import { BrowserRouter, Routes as RouterRoutes } from "react-router-dom" import { type Action } from "redux" import { InactiveDialog, ScreenTimeDialog } from "../features" -import { useCountdown, useEventListener } from "../hooks" -import "../scripts" -import { - // configureFreshworksWidget, - toggleOneTrustInfoDisplay, -} from "../utils/window" +import { useCountdown, useEventListener, useLocation } from "../hooks" +// import "../scripts" +// import { +// configureFreshworksWidget, +// toggleOneTrustInfoDisplay, +// } from "../utils/window" export interface AppProps { theme: ThemeProviderProps["theme"] store: ProviderProps["store"] - children: React.ReactNode + routes: ReactNode + header?: ReactNode + footer?: ReactNode + headerExcludePaths?: string[] + footerExcludePaths?: string[] maxIdleSeconds?: number maxTotalSeconds?: number } +const Routes: FC< + Pick< + AppProps, + "routes" | "header" | "footer" | "headerExcludePaths" | "footerExcludePaths" + > +> = ({ + routes, + header = <>, // TODO: "header =
" + footer = <>, // TODO: "footer =